Add sub navigation

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-30 22:47:46 +01:00
parent ebd42e6222
commit a92dc8e1fb
11 changed files with 450 additions and 214 deletions
+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": {
+17 -13
View File
@@ -508,20 +508,24 @@ export const componentDefinitions: {
readonly disabled: readonly [true, false];
};
};
readonly Toolbar: {
readonly Header: {
readonly classNames: {
readonly toolbar: 'canon-Toolbar';
readonly toolbarContentWrapper: 'canon-ToolbarContentWrapper';
readonly toolbarOverlay: 'canon-ToolbarOverlay';
readonly toolbarContent: 'canon-ToolbarContent';
readonly toolbarOptions: 'canon-ToolbarOptions';
readonly icon: 'canon-ToolbarIcon';
readonly name: 'canon-ToolbarName';
readonly tabs: 'canon-ToolbarTabs';
readonly tabList: 'canon-ToolbarTabList';
readonly tab: 'canon-ToolbarTab';
readonly activeIndicator: 'canon-ToolbarActiveIndicator';
readonly hoveredIndicator: 'canon-ToolbarHoveredIndicator';
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: {
@@ -16,7 +16,12 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { Header } from './Header';
import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types';
import {
HeaderBreadcrumb,
HeaderOption,
HeaderProps,
HeaderTab,
} from './types';
const meta = {
title: 'Components/Header',
@@ -70,6 +75,13 @@ const options: HeaderOption[] = [
},
];
const subNavigation: HeaderProps['subNavigation'] = {
name: 'Sub Navigation',
description: 'Sub Navigation Description',
tabs: tabs,
options: options,
};
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
@@ -134,11 +146,19 @@ export const WithBreadcrumbs: Story = {
},
};
export const WithTabsAndSubNavigation: Story = {
args: {
tabs,
subNavigation,
},
};
export const WithAllComponents: Story = {
args: {
options,
tabs,
breadcrumbs,
subNavigation,
},
};
@@ -153,3 +173,16 @@ export const WithLayout: Story = {
layout: 'fullscreen',
},
};
export const WithSubNavigation: Story = {
args: {
options,
tabs,
breadcrumbs,
subNavigation,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
};
+13 -199
View File
@@ -14,15 +14,10 @@
* limitations under the License.
*/
import { Tabs, TabList, Tab as AriaTab, Link } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useId, useRef, useState, useEffect } from 'react';
import { HeaderTabsIndicators } from './Indicators';
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
import type { HeaderProps, HeaderTabProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { motion, useScroll, useTransform } from 'framer-motion';
import type { HeaderProps } from './types';
import { HeaderToolbar } from './HeaderToolbar';
import { HeaderTabs } from './HeaderTabs';
import { HeaderSubNav } from './HeaderSubNav';
/**
* A component that renders a toolbar.
@@ -30,199 +25,18 @@ import { motion, useScroll, useTransform } from 'framer-motion';
* @public
*/
export const Header = (props: HeaderProps) => {
const { tabs, icon, name, options, breadcrumbs } = 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 { 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);
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
tabRefs.current.set(key, element);
} else {
tabRefs.current.delete(key);
}
};
// 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();
};
}, []);
const { tabs, icon, name, options, breadcrumbs, subNavigation } = props;
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>
{name || '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>
{tabs && (
<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>
)}
<HeaderToolbar
icon={icon}
name={name}
options={options}
breadcrumbs={breadcrumbs}
/>
<HeaderTabs tabs={tabs} />
<HeaderSubNav subNavigation={subNavigation} />
</>
);
};
/**
* A component that renders a toolbar tab.
*
* @public
*/
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,67 @@
/*
* 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 { HeaderProps } 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';
export const HeaderSubNav = (props: Pick<HeaderProps, 'subNavigation'>) => {
const { subNavigation } = props;
if (!subNavigation) return null;
return (
<Flex pl="4" pr="2" mt="6" justify="between">
<Flex direction="column" gap="2">
<Heading variant="title4">{subNavigation.name}</Heading>
<Text>{subNavigation.description}</Text>
</Flex>
<div>
{subNavigation.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>
{subNavigation.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,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.
*
* @public
*/
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.
*
* @public
*/
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,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.
*
* @public
*/
export const HeaderToolbar = (props: HeaderProps) => {
const { icon, name, 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>
{name || '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>
);
};
@@ -15,5 +15,5 @@
*/
export * from './Header';
export * from './Indicators';
export * from './HeaderTabsIndicators';
export * from './types';
@@ -252,6 +252,7 @@ export const componentDefinitions = {
tab: 'canon-HeaderTab',
tabActive: 'canon-HeaderTabActive',
tabHovered: 'canon-HeaderTabHovered',
subNav: 'canon-HeaderSubNav',
},
},
Tooltip: {
+60
View File
@@ -3815,6 +3815,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"
@@ -32756,6 +32757,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"
@@ -40180,6 +40203,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"