Merge pull request #30410 from backstage/canon-toolbar

Canon - New Header component 
This commit is contained in:
Charles de Dreuille
2025-07-03 22:13:58 +01:00
committed by GitHub
22 changed files with 1561 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
Add new Header component to Canon.
+171
View File
@@ -9540,6 +9540,176 @@
display: grid;
}
.canon-HeaderToolbar {
z-index: 10;
padding-top: 8px;
position: sticky;
top: 0;
&:before {
content: "";
background-color: var(--canon-bg);
z-index: 0;
height: 16px;
position: absolute;
top: 0;
left: 0;
right: 0;
}
}
.canon-HeaderToolbarWrapper {
z-index: 1;
background-color: var(--canon-bg-surface-1);
padding-inline: var(--canon-space-3);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 52px;
display: flex;
position: relative;
}
.canon-HeaderToolbarContent {
align-items: center;
gap: var(--canon-space-2);
flex-direction: row;
display: flex;
}
.canon-HeaderToolbarName {
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
flex-direction: row;
flex-shrink: 0;
display: flex;
}
.canon-HeaderToolbarIcon {
width: 16px;
height: 16px;
color: var(--canon-fg-primary);
& svg {
width: 100%;
height: 100%;
}
}
.canon-HeaderToolbarOptions {
right: var(--canon-space-2);
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.canon-HeaderBreadcrumbs {
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
flex-direction: row;
display: flex;
}
.canon-HeaderBreadcrumb {
align-items: center;
gap: var(--canon-space-2);
flex-direction: row;
display: flex;
}
.canon-HeaderBreadcrumbLink {
color: var(--canon-fg-secondary);
cursor: pointer;
text-decoration: none;
&[data-active="true"] {
color: var(--canon-fg-primary);
}
}
.canon-HeaderBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--canon-fg-secondary);
}
.canon-HeaderTabs {
background-color: var(--canon-bg-surface-1);
padding-inline: var(--canon-space-1);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
--active-tab-left: 0px;
--active-tab-right: 0px;
--active-tab-top: 0px;
--active-tab-bottom: 0px;
--active-tab-width: 0px;
--active-tab-height: 0px;
--hovered-tab-left: 0px;
--hovered-tab-right: 0px;
--hovered-tab-top: 0px;
--hovered-tab-bottom: 0px;
--hovered-tab-width: 0px;
--hovered-tab-height: 0px;
--hovered-tab-opacity: 0;
--hovered-transition-duration: 0s;
flex-direction: row;
align-items: center;
margin-top: 8px;
display: flex;
position: relative;
}
.canon-HeaderTabList {
flex-direction: row;
display: flex;
}
.canon-HeaderTab {
font-size: var(--canon-font-size-3);
color: var(--canon-fg-primary);
cursor: pointer;
z-index: 2;
height: 36px;
padding-inline: var(--canon-space-2);
justify-content: center;
align-items: center;
display: flex;
position: relative;
}
.canon-HeaderTabActive {
content: "";
left: calc(var(--active-tab-left) + var(--canon-space-2));
width: calc(var(--active-tab-width) - var(--canon-space-4));
background-color: var(--canon-fg-primary);
opacity: 1;
border-radius: 4px;
height: 1px;
transition: all .25s ease-out;
position: absolute;
bottom: -1px;
}
.canon-HeaderTabHovered {
content: "";
left: var(--hovered-tab-left);
top: calc(var(--hovered-tab-top) + 4px);
width: var(--hovered-tab-width);
height: calc(var(--hovered-tab-height) - 8px);
background-color: var(--canon-gray-2);
opacity: var(--hovered-tab-opacity);
transition: left var(--hovered-transition-duration) ease-out, top var(--hovered-transition-duration) ease-out, width var(--hovered-transition-duration) ease-out, height var(--hovered-transition-duration) ease-out, opacity .15s ease-out;
border-radius: 4px;
position: absolute;
}
.canon-Heading {
font-family: var(--canon-font-regular);
color: var(--canon-fg-primary);
@@ -9644,6 +9814,7 @@
}
.canon-MenuPositioner {
z-index: 100;
outline: 0;
}
+1
View File
@@ -45,6 +45,7 @@
"@remixicon/react": "^4.6.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
"motion": "^12.20.1",
"react-aria-components": "^1.10.1"
},
"devDependencies": {
+96
View File
@@ -508,6 +508,26 @@ export const componentDefinitions: {
readonly disabled: readonly [true, false];
};
};
readonly Header: {
readonly classNames: {
readonly toolbar: 'canon-HeaderToolbar';
readonly toolbarWrapper: 'canon-HeaderToolbarWrapper';
readonly toolbarContent: 'canon-HeaderToolbarContent';
readonly toolbarOptions: 'canon-HeaderToolbarOptions';
readonly toolbarIcon: 'canon-HeaderToolbarIcon';
readonly toolbarName: 'canon-HeaderToolbarName';
readonly breadcrumbs: 'canon-HeaderBreadcrumbs';
readonly breadcrumb: 'canon-HeaderBreadcrumb';
readonly breadcrumbLink: 'canon-HeaderBreadcrumbLink';
readonly breadcrumbSeparator: 'canon-HeaderBreadcrumbSeparator';
readonly tabs: 'canon-HeaderTabs';
readonly tabList: 'canon-HeaderTabList';
readonly tab: 'canon-HeaderTab';
readonly tabActive: 'canon-HeaderTabActive';
readonly tabHovered: 'canon-HeaderTabHovered';
readonly subNav: 'canon-HeaderSubNav';
};
};
readonly Tooltip: {
readonly classNames: {
readonly trigger: 'canon-TooltipTrigger';
@@ -916,6 +936,82 @@ export interface GridProps extends SpaceProps {
style?: React.CSSProperties;
}
// @public
export const Header: (props: HeaderProps) => JSX_2.Element;
// @public
export interface HeaderBreadcrumb {
// (undocumented)
href: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderOption {
// (undocumented)
label: string;
// (undocumented)
onClick?: () => void;
// (undocumented)
value: string;
}
// @public
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
// @public
export interface HeaderPageOption {
// (undocumented)
label: string;
// (undocumented)
onClick?: () => void;
// (undocumented)
value: string;
}
// @public
export interface HeaderPageProps {
// (undocumented)
description?: string;
// (undocumented)
options?: HeaderPageOption[];
// (undocumented)
tabs?: HeaderPageTab[];
// (undocumented)
title?: string;
}
// @public
export interface HeaderPageTab {
// (undocumented)
href?: string;
// (undocumented)
label: string;
}
// @public
export interface HeaderProps {
// (undocumented)
breadcrumbs?: HeaderBreadcrumb[];
// (undocumented)
icon?: React.ReactNode;
// (undocumented)
options?: HeaderOption[];
// (undocumented)
tabs?: HeaderTab[];
// (undocumented)
title?: string;
}
// @public
export interface HeaderTab {
// (undocumented)
href?: string;
// (undocumented)
label: string;
}
// @public (undocumented)
export const Heading: <T extends ElementType = 'h1'>(
props: HeadingProps<T> & {
@@ -0,0 +1,155 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { Header } from './Header';
import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types';
const meta = {
title: 'Components/Header',
component: Header,
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
const tabs: HeaderTab[] = [
{
label: 'Overview',
},
{
label: 'Checks',
},
{
label: 'Tracks',
},
{
label: 'Campaigns',
},
{
label: 'Integrations',
},
];
const breadcrumbs: HeaderBreadcrumb[] = [
{
label: 'Home',
href: '/',
},
{
label: 'Dashboard',
href: '/dashboard',
},
{
label: 'Settings',
href: '/settings',
},
];
const options: HeaderOption[] = [
{
label: 'Settings',
value: 'settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
},
];
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
<>
<div
style={{
width: '250px',
position: 'fixed',
left: 8,
top: 8,
bottom: 8,
backgroundColor: 'var(--canon-bg-surface-1',
borderRadius: 'var(--canon-radius-2)',
border: '1px solid var(--canon-border)',
zIndex: 1,
}}
/>
<div
style={{
paddingInline: '266px',
minHeight: '200vh',
}}
>
<Story />
</div>
<div
style={{
width: '250px',
position: 'fixed',
right: 8,
top: 8,
bottom: 8,
backgroundColor: 'var(--canon-bg-surface-1',
borderRadius: 'var(--canon-radius-2)',
border: '1px solid var(--canon-border)',
zIndex: 1,
}}
/>
</>
),
];
export const Default: Story = {
args: {},
};
export const WithTabs: Story = {
args: {
tabs,
},
};
export const WithOptions: Story = {
args: {
options,
},
};
export const WithBreadcrumbs: Story = {
args: {
breadcrumbs,
},
};
export const WithAllComponents: Story = {
args: {
options,
tabs,
breadcrumbs,
},
};
export const WithLayout: Story = {
args: {
options,
tabs,
breadcrumbs,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
};
@@ -0,0 +1,193 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.canon-HeaderToolbar {
position: sticky;
top: 0;
z-index: 10;
padding-top: 8px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0px;
right: 0px;
height: 16px;
background-color: var(--canon-bg);
z-index: 0;
}
}
.canon-HeaderToolbarWrapper {
position: relative;
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: var(--canon-bg-surface-1);
padding-inline: var(--canon-space-3);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
height: 52px;
}
.canon-HeaderToolbarContent {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--canon-space-2);
}
.canon-HeaderToolbarName {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
flex-shrink: 0;
}
.canon-HeaderToolbarIcon {
width: 16px;
height: 16px;
color: var(--canon-fg-primary);
& svg {
width: 100%;
height: 100%;
}
}
.canon-HeaderToolbarOptions {
position: absolute;
right: var(--canon-space-2);
top: 50%;
transform: translateY(-50%);
}
.canon-HeaderBreadcrumbs {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
}
.canon-HeaderBreadcrumb {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--canon-space-2);
}
.canon-HeaderBreadcrumbLink {
color: var(--canon-fg-secondary);
text-decoration: none;
cursor: pointer;
&[data-active='true'] {
color: var(--canon-fg-primary);
}
}
.canon-HeaderBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--canon-fg-secondary);
}
.canon-HeaderTabs {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--canon-bg-surface-1);
position: relative;
padding-inline: var(--canon-space-1);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
margin-top: 8px;
/* Initialize CSS variables */
--active-tab-left: 0px;
--active-tab-right: 0px;
--active-tab-top: 0px;
--active-tab-bottom: 0px;
--active-tab-width: 0px;
--active-tab-height: 0px;
--hovered-tab-left: 0px;
--hovered-tab-right: 0px;
--hovered-tab-top: 0px;
--hovered-tab-bottom: 0px;
--hovered-tab-width: 0px;
--hovered-tab-height: 0px;
--hovered-tab-opacity: 0;
--hovered-transition-duration: 0s;
}
.canon-HeaderTabList {
display: flex;
flex-direction: row;
}
.canon-HeaderTab {
font-size: var(--canon-font-size-3);
color: var(--canon-fg-primary);
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
z-index: 2;
padding-inline: var(--canon-space-2);
}
/* Active tab indicator */
.canon-HeaderTabActive {
content: '';
position: absolute;
left: calc(var(--active-tab-left) + var(--canon-space-2));
bottom: -1px;
width: calc(var(--active-tab-width) - var(--canon-space-4));
height: 1px;
background-color: var(--canon-fg-primary);
border-radius: 4px;
transition: all 0.25s ease-out;
opacity: 1;
}
/* Hovered tab indicator */
.canon-HeaderTabHovered {
content: '';
position: absolute;
left: var(--hovered-tab-left);
top: calc(var(--hovered-tab-top) + 4px);
width: var(--hovered-tab-width);
height: calc(var(--hovered-tab-height) - 8px);
background-color: var(--canon-gray-2);
border-radius: 4px;
opacity: var(--hovered-tab-opacity);
transition: left var(--hovered-transition-duration) ease-out,
top var(--hovered-transition-duration) ease-out,
width var(--hovered-transition-duration) ease-out,
height var(--hovered-transition-duration) ease-out, opacity 0.15s ease-out;
}
@@ -0,0 +1,40 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HeaderProps } from './types';
import { HeaderToolbar } from './HeaderToolbar';
import { HeaderTabs } from './HeaderTabs';
/**
* A component that renders a toolbar.
*
* @public
*/
export const Header = (props: HeaderProps) => {
const { tabs, icon, title, options, breadcrumbs } = props;
return (
<>
<HeaderToolbar
icon={icon}
title={title}
options={options}
breadcrumbs={breadcrumbs}
/>
<HeaderTabs tabs={tabs} />
</>
);
};
@@ -0,0 +1,92 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useId, useRef, useState } from 'react';
import { HeaderTabsIndicators } from './HeaderTabsIndicators';
import type { HeaderProps, HeaderTabProps } from './types';
/**
* A component that renders header tabs.
*
* @internal
*/
export const HeaderTabs = (props: HeaderProps) => {
const { tabs } = props;
const { classNames } = useStyles('Header');
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const prevHoveredKey = useRef<string | null>(null);
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
tabRefs.current.set(key, element);
} else {
tabRefs.current.delete(key);
}
};
if (!tabs) return null;
return (
<Tabs className={classNames.tabs} ref={tabsRef} keyboardActivation="manual">
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
{tabs.map((tab, index) => {
return (
<Tab
key={index}
tab={tab}
setTabRef={setTabRef}
setHoveredKey={setHoveredKey}
/>
);
})}
</TabList>
<HeaderTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
/>
</Tabs>
);
};
/**
* A component that renders a toolbar tab.
*
* @internal
*/
const Tab = (props: HeaderTabProps) => {
const { tab, setTabRef, setHoveredKey } = props;
const id = useId();
const { classNames } = useStyles('Header');
return (
<AriaTab
id={id}
className={classNames.tab}
ref={el => setTabRef(id, el as HTMLDivElement)}
onHoverStart={() => setHoveredKey(id)}
onHoverEnd={() => setHoveredKey(null)}
href={tab.href}
>
{tab.label}
</AriaTab>
);
};
@@ -0,0 +1,161 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TabListStateContext } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useContext, useEffect, useCallback } from 'react';
import type { HeaderIndicatorsProps } from './types';
/**
* A component that renders the indicators for the toolbar.
*
* @internal
*/
export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props;
const { classNames } = useStyles('Header');
const state = useContext(TabListStateContext);
const updateCSSVariables = useCallback(() => {
if (!tabsRef.current) return;
const tabsRect = tabsRef.current.getBoundingClientRect();
// Set active tab variables
if (state?.selectedKey) {
const activeTab = tabRefs.current.get(state.selectedKey.toString());
if (activeTab) {
const activeRect = activeTab.getBoundingClientRect();
const relativeLeft = activeRect.left - tabsRect.left;
const relativeTop = activeRect.top - tabsRect.top;
tabsRef.current.style.setProperty(
'--active-tab-left',
`${relativeLeft}px`,
);
tabsRef.current.style.setProperty(
'--active-tab-right',
`${relativeLeft + activeRect.width}px`,
);
tabsRef.current.style.setProperty(
'--active-tab-top',
`${relativeTop}px`,
);
tabsRef.current.style.setProperty(
'--active-tab-bottom',
`${relativeTop + activeRect.height}px`,
);
tabsRef.current.style.setProperty(
'--active-tab-width',
`${activeRect.width}px`,
);
tabsRef.current.style.setProperty(
'--active-tab-height',
`${activeRect.height}px`,
);
}
}
// Set hovered tab variables
if (hoveredKey) {
const hoveredTab = tabRefs.current.get(hoveredKey);
if (hoveredTab) {
const hoveredRect = hoveredTab.getBoundingClientRect();
const relativeLeft = hoveredRect.left - tabsRect.left;
const relativeTop = hoveredRect.top - tabsRect.top;
tabsRef.current.style.setProperty(
'--hovered-tab-left',
`${relativeLeft}px`,
);
tabsRef.current.style.setProperty(
'--hovered-tab-right',
`${relativeLeft + hoveredRect.width}px`,
);
tabsRef.current.style.setProperty(
'--hovered-tab-top',
`${relativeTop}px`,
);
tabsRef.current.style.setProperty(
'--hovered-tab-bottom',
`${relativeTop + hoveredRect.height}px`,
);
tabsRef.current.style.setProperty(
'--hovered-tab-width',
`${hoveredRect.width}px`,
);
tabsRef.current.style.setProperty(
'--hovered-tab-height',
`${hoveredRect.height}px`,
);
// Control transition timing based on whether this is a new hover session
const isNewHoverSession = prevHoveredKey.current === null;
if (isNewHoverSession) {
// Starting new hover session: no transitions for position
tabsRef.current.style.setProperty(
'--hovered-transition-duration',
'0s',
);
// Enable transitions on next frame for future tab switches
requestAnimationFrame(() => {
if (tabsRef.current) {
tabsRef.current.style.setProperty(
'--hovered-transition-duration',
'0.2s',
);
}
});
} else {
// Moving between tabs in same session: full transitions
tabsRef.current.style.setProperty(
'--hovered-transition-duration',
'0.2s',
);
}
// Update previous hover key for next time
prevHoveredKey.current = hoveredKey;
tabsRef.current.style.setProperty('--hovered-tab-opacity', '1');
}
} else {
// When not hovering, hide with opacity and reset for next hover session
tabsRef.current.style.setProperty('--hovered-tab-opacity', '0');
// Reset previous hover key so next hover is treated as new session
prevHoveredKey.current = null;
}
}, [state?.selectedKey, hoveredKey]);
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables]);
useEffect(() => {
const handleResize = () => updateCSSVariables();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [updateCSSVariables]);
return (
<>
<div className={classNames.tabActive} />
<div className={classNames.tabHovered} />
</>
);
};
@@ -0,0 +1,164 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Link } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useRef, useState, useEffect } from 'react';
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
import type { HeaderProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { motion, useScroll, useTransform } from 'motion/react';
/**
* A component that renders a toolbar.
*
* @internal
*/
export const HeaderToolbar = (props: HeaderProps) => {
const { icon, title, options, breadcrumbs } = props;
const { classNames } = useStyles('Header');
const { scrollY } = useScroll();
const breadcrumbOpacity = useTransform(scrollY, [80, 120], [0, 1]);
// Refs for collision detection
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarOptionsRef = useRef<HTMLDivElement>(null);
// State for breadcrumb visibility
const [showBreadcrumbs, setShowBreadcrumbs] = useState(true);
// Set up resize observer
useEffect(() => {
const wrapper = toolbarWrapperRef.current;
if (!wrapper) return;
const resizeObserver = new ResizeObserver(() => {
const wrapper = toolbarWrapperRef.current;
const content = toolbarContentRef.current;
const options = toolbarOptionsRef.current;
if (!wrapper || !content) return;
// Get dimensions
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperWidth = wrapperRect.width;
const wrapperPadding = 24; // 12px on each side (var(--canon-space-3))
const availableWidth = wrapperWidth - wrapperPadding;
// Calculate required width for content
const contentRect = content.getBoundingClientRect();
const contentWidth = contentRect?.width || 0;
// Calculate options width (if exists)
const optionsRect = options?.getBoundingClientRect();
const optionsWidth = optionsRect?.width || 0;
// Check if we need to hide breadcrumbs
const shouldShowBreadcrumbs =
contentWidth + optionsWidth + 32 <= availableWidth;
// Only update state if the value actually changed to prevent flickering
setShowBreadcrumbs(prev =>
prev !== shouldShowBreadcrumbs ? shouldShowBreadcrumbs : prev,
);
});
resizeObserver.observe(wrapper);
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<div className={classNames.toolbar}>
<div className={classNames.toolbarWrapper} ref={toolbarWrapperRef}>
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
<div className={classNames.toolbarName}>
<div className={classNames.toolbarIcon}>
{icon || <RiShapesLine />}
</div>
{title || 'Your plugin'}
</div>
{breadcrumbs && (
<motion.div
className={classNames.breadcrumbs}
style={{
opacity: breadcrumbOpacity,
visibility: showBreadcrumbs ? 'visible' : 'hidden',
}}
>
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
{breadcrumbs.map((breadcrumb, index) => (
<div key={breadcrumb.label} className={classNames.breadcrumb}>
<Link
href={breadcrumb.href}
className={classNames.breadcrumbLink}
data-active={index === breadcrumbs.length - 1}
>
{breadcrumb.label}
</Link>
{index < breadcrumbs.length - 1 && (
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
)}
</div>
))}
</motion.div>
)}
</div>
<div className={classNames.toolbarOptions} ref={toolbarOptionsRef}>
{options && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="secondary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{options.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,23 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Header } from './Header';
export type {
HeaderProps,
HeaderTab,
HeaderOption,
HeaderBreadcrumb,
} from './types';
@@ -0,0 +1,79 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MutableRefObject } from 'react';
/**
* Props for the main Header component.
*
* @public
*/
export interface HeaderProps {
icon?: React.ReactNode;
title?: string;
tabs?: HeaderTab[];
options?: HeaderOption[];
breadcrumbs?: HeaderBreadcrumb[];
}
/**
* Represents a tab item in the header navigation.
*
* @public
*/
export interface HeaderTab {
label: string;
href?: string;
}
/**
* Represents an option item in the header dropdown menu.
*
* @public
*/
export interface HeaderOption {
label: string;
value: string;
onClick?: () => void;
}
/**
* Represents a breadcrumb item in the header.
*
* @public
*/
export interface HeaderBreadcrumb {
label: string;
href: string;
}
/**
* Props for individual header tab components.
*
* @public
*/
export interface HeaderTabProps {
tab: HeaderTab;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
setHoveredKey: (key: string | null) => void;
}
export interface HeaderIndicatorsProps {
tabRefs: MutableRefObject<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;
hoveredKey: string | null;
prevHoveredKey: MutableRefObject<string | null>;
}
@@ -0,0 +1,144 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { HeaderPage } from './HeaderPage';
import { HeaderPageOption, HeaderPageTab } from './types';
const meta = {
title: 'Components/HeaderPage',
component: HeaderPage,
} satisfies Meta<typeof HeaderPage>;
export default meta;
type Story = StoryObj<typeof meta>;
const tabs: HeaderPageTab[] = [
{
label: 'Overview',
},
{
label: 'Checks',
},
{
label: 'Tracks',
},
{
label: 'Campaigns',
},
{
label: 'Integrations',
},
];
const options: HeaderPageOption[] = [
{
label: 'Settings',
value: 'settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
},
];
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
<>
<div
style={{
width: '250px',
position: 'fixed',
left: 8,
top: 8,
bottom: 8,
backgroundColor: 'var(--canon-bg-surface-1',
borderRadius: 'var(--canon-radius-2)',
border: '1px solid var(--canon-border)',
zIndex: 1,
}}
/>
<div
style={{
paddingInline: '266px',
minHeight: '200vh',
}}
>
<Story />
</div>
<div
style={{
width: '250px',
position: 'fixed',
right: 8,
top: 8,
bottom: 8,
backgroundColor: 'var(--canon-bg-surface-1',
borderRadius: 'var(--canon-radius-2)',
border: '1px solid var(--canon-border)',
zIndex: 1,
}}
/>
</>
),
];
export const Default: Story = {
args: {
title: 'Header Page',
},
};
export const WithDescription: Story = {
args: {
...Default.args,
description: 'Header Page Description',
},
};
export const WithTabs: Story = {
args: {
...Default.args,
tabs,
},
};
export const WithOptions: Story = {
args: {
...Default.args,
options,
},
};
export const WithEverything: Story = {
args: {
...Default.args,
description: 'Header Page Description',
tabs,
options,
},
};
export const WithLayout: Story = {
args: {
...WithEverything.args,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
};
@@ -0,0 +1,15 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@@ -0,0 +1,71 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { HeaderPageProps } from './types';
import { Heading } from '../Heading';
import { Text } from '../Text';
import { Flex } from '../Flex';
import { Menu } from '../Menu';
import { ButtonIcon } from '../ButtonIcon';
import { RiMore2Line } from '@remixicon/react';
/**
* A component that renders a header page.
*
* @public
*/
export const HeaderPage = (props: HeaderPageProps) => {
const { title, description, options } = props;
return (
<Flex pl="4" pr="2" mt="6" justify="between">
<Flex direction="column" gap="2">
<Heading variant="title4">{title}</Heading>
<Text>{description}</Text>
</Flex>
<div>
{options && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="secondary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{options.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</Flex>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { HeaderPage } from './HeaderPage';
export type { HeaderPageProps, HeaderPageTab, HeaderPageOption } from './types';
@@ -0,0 +1,48 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Props for the main HeaderPage component.
*
* @public
*/
export interface HeaderPageProps {
title?: string;
description?: string;
tabs?: HeaderPageTab[];
options?: HeaderPageOption[];
}
/**
* Represents a tab item in the header page.
*
* @public
*/
export interface HeaderPageTab {
label: string;
href?: string;
}
/**
* Represents an option item in the header page.
*
* @public
*/
export interface HeaderPageOption {
label: string;
value: string;
onClick?: () => void;
}
@@ -1,5 +1,6 @@
.canon-MenuPositioner {
outline: 0;
z-index: 100;
}
.canon-MenuPopup {
+2
View File
@@ -27,6 +27,8 @@
@import '../components/FieldLabel/FieldLabel.styles.css';
@import '../components/Flex/styles.css';
@import '../components/Grid/styles.css';
@import '../components/Header/Header.styles.css';
@import '../components/HeaderPage/HeaderPage.styles.css';
@import '../components/Heading/styles.css';
@import '../components/Icon/styles.css';
@import '../components/Link/styles.css';
+2
View File
@@ -37,6 +37,8 @@ export * from './components/Button';
export * from './components/Collapsible';
export * from './components/DataTable';
export * from './components/FieldLabel';
export * from './components/Header';
export * from './components/HeaderPage';
export * from './components/Icon';
export * from './components/ButtonIcon';
export * from './components/ButtonLink';
@@ -235,6 +235,26 @@ export const componentDefinitions = {
disabled: [true, false] as const,
},
},
Header: {
classNames: {
toolbar: 'canon-HeaderToolbar',
toolbarWrapper: 'canon-HeaderToolbarWrapper',
toolbarContent: 'canon-HeaderToolbarContent',
toolbarOptions: 'canon-HeaderToolbarOptions',
toolbarIcon: 'canon-HeaderToolbarIcon',
toolbarName: 'canon-HeaderToolbarName',
breadcrumbs: 'canon-HeaderBreadcrumbs',
breadcrumb: 'canon-HeaderBreadcrumb',
breadcrumbLink: 'canon-HeaderBreadcrumbLink',
breadcrumbSeparator: 'canon-HeaderBreadcrumbSeparator',
tabs: 'canon-HeaderTabs',
tabList: 'canon-HeaderTabList',
tab: 'canon-HeaderTab',
tabActive: 'canon-HeaderTabActive',
tabHovered: 'canon-HeaderTabHovered',
subNav: 'canon-HeaderSubNav',
},
},
Tooltip: {
classNames: {
trigger: 'canon-TooltipTrigger',
+60
View File
@@ -3836,6 +3836,7 @@ __metadata:
globals: "npm:^15.11.0"
lightningcss: "npm:^1.29.1"
mini-css-extract-plugin: "npm:^2.9.2"
motion: "npm:^12.20.1"
react: "npm:^18.0.2"
react-aria-components: "npm:^1.10.1"
react-dom: "npm:^18.0.2"
@@ -32985,6 +32986,28 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^12.20.1":
version: 12.20.1
resolution: "framer-motion@npm:12.20.1"
dependencies:
motion-dom: "npm:^12.20.1"
motion-utils: "npm:^12.19.0"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10/ba36e76dbf66c856f0c5d3cfb80973bb0ef95ca9169fc16b22560a7bc55fbedcf63205266dba7beecac5152c4cd140097ff504c5d529beffca86cd85f97bf5e3
languageName: node
linkType: hard
"framer-motion@npm:^6.5.1":
version: 6.5.1
resolution: "framer-motion@npm:6.5.1"
@@ -40409,6 +40432,43 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^12.20.1":
version: 12.20.1
resolution: "motion-dom@npm:12.20.1"
dependencies:
motion-utils: "npm:^12.19.0"
checksum: 10/b92d733fdfe3f66511f73c7f84f9483a0529eadf6ad2ab633cef584d161bcd1af786d257776e1fe7cd2b4e2c9c10912773f96ce9e2bd5b45c6c33ba6d9edfe86
languageName: node
linkType: hard
"motion-utils@npm:^12.19.0":
version: 12.19.0
resolution: "motion-utils@npm:12.19.0"
checksum: 10/a6fe6b329bf4e2070bb95788218f36f0d7ea6cc3098e6ea2646ed68d5be88d25fa30e55284463ace78d19a5e19714875a7b70055bd86ae6e52a5fdee08438470
languageName: node
linkType: hard
"motion@npm:^12.20.1":
version: 12.20.1
resolution: "motion@npm:12.20.1"
dependencies:
framer-motion: "npm:^12.20.1"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10/49057c8d5812207209ca078a67ee7467d2dca7abacdcf2db486cd28c3ca105a7fe2504c34407df201c85035f6df1c806954af94914417ee89d721133fc4e92ce
languageName: node
linkType: hard
"mri@npm:1.1.4":
version: 1.1.4
resolution: "mri@npm:1.1.4"