diff --git a/packages/canon/package.json b/packages/canon/package.json index eb6165aa8c..984e8d8f50 100644 --- a/packages/canon/package.json +++ b/packages/canon/package.json @@ -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": { diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 49c647f9ea..ed471bab5a 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -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: { diff --git a/packages/canon/src/components/Header/Header.stories.tsx b/packages/canon/src/components/Header/Header.stories.tsx index 0554648bc9..047b7f0c94 100644 --- a/packages/canon/src/components/Header/Header.stories.tsx +++ b/packages/canon/src/components/Header/Header.stories.tsx @@ -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', + }, +}; diff --git a/packages/canon/src/components/Header/Header.tsx b/packages/canon/src/components/Header/Header.tsx index e8a42104a5..195c7a59b1 100644 --- a/packages/canon/src/components/Header/Header.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -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(null); - const tabRefs = useRef>(new Map()); - const [hoveredKey, setHoveredKey] = useState(null); - const prevHoveredKey = useRef(null); - const { scrollY } = useScroll(); - const breadcrumbOpacity = useTransform(scrollY, [80, 120], [0, 1]); - - // Refs for collision detection - const toolbarWrapperRef = useRef(null); - const toolbarContentRef = useRef(null); - const toolbarOptionsRef = useRef(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 ( <> -
-
-
-
-
- {icon || } -
- {name || 'Your plugin'} -
- {breadcrumbs && ( - - - {breadcrumbs.map((breadcrumb, index) => ( -
- - {breadcrumb.label} - - {index < breadcrumbs.length - 1 && ( - - )} -
- ))} -
- )} -
-
- {options && ( - - ( - } - variant="secondary" - /> - )} - /> - - - - {options.map(option => ( - option.onClick?.()} - > - {option.label} - - ))} - - - - - )} -
-
-
- {tabs && ( - - - {tabs.map((tab, index) => { - return ( - - ); - })} - - - - )} + + + ); }; - -/** - * 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 ( - setTabRef(id, el as HTMLDivElement)} - onHoverStart={() => setHoveredKey(id)} - onHoverEnd={() => setHoveredKey(null)} - href={tab.href} - > - {tab.label} - - ); -}; diff --git a/packages/canon/src/components/Header/HeaderSubNav.tsx b/packages/canon/src/components/Header/HeaderSubNav.tsx new file mode 100644 index 0000000000..90f2afb73a --- /dev/null +++ b/packages/canon/src/components/Header/HeaderSubNav.tsx @@ -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) => { + const { subNavigation } = props; + + if (!subNavigation) return null; + + return ( + + + {subNavigation.name} + {subNavigation.description} + +
+ {subNavigation.options && ( + + ( + } + variant="secondary" + /> + )} + /> + + + + {subNavigation.options.map(option => ( + option.onClick?.()} + > + {option.label} + + ))} + + + + + )} +
+
+ ); +}; diff --git a/packages/canon/src/components/Header/HeaderTabs.tsx b/packages/canon/src/components/Header/HeaderTabs.tsx new file mode 100644 index 0000000000..90c2aab33e --- /dev/null +++ b/packages/canon/src/components/Header/HeaderTabs.tsx @@ -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(null); + const tabRefs = useRef>(new Map()); + const [hoveredKey, setHoveredKey] = useState(null); + const prevHoveredKey = useRef(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.map((tab, index) => { + return ( + + ); + })} + + + + ); +}; + +/** + * 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 ( + setTabRef(id, el as HTMLDivElement)} + onHoverStart={() => setHoveredKey(id)} + onHoverEnd={() => setHoveredKey(null)} + href={tab.href} + > + {tab.label} + + ); +}; diff --git a/packages/canon/src/components/Header/Indicators.tsx b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx similarity index 100% rename from packages/canon/src/components/Header/Indicators.tsx rename to packages/canon/src/components/Header/HeaderTabsIndicators.tsx diff --git a/packages/canon/src/components/Header/HeaderToolbar.tsx b/packages/canon/src/components/Header/HeaderToolbar.tsx new file mode 100644 index 0000000000..aa31387f57 --- /dev/null +++ b/packages/canon/src/components/Header/HeaderToolbar.tsx @@ -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(null); + const toolbarContentRef = useRef(null); + const toolbarOptionsRef = useRef(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 ( +
+
+
+
+
+ {icon || } +
+ {name || 'Your plugin'} +
+ {breadcrumbs && ( + + + {breadcrumbs.map((breadcrumb, index) => ( +
+ + {breadcrumb.label} + + {index < breadcrumbs.length - 1 && ( + + )} +
+ ))} +
+ )} +
+
+ {options && ( + + ( + } + variant="secondary" + /> + )} + /> + + + + {options.map(option => ( + option.onClick?.()} + > + {option.label} + + ))} + + + + + )} +
+
+
+ ); +}; diff --git a/packages/canon/src/components/Header/index.tsx b/packages/canon/src/components/Header/index.tsx index 40935319bf..9d62b8eca0 100644 --- a/packages/canon/src/components/Header/index.tsx +++ b/packages/canon/src/components/Header/index.tsx @@ -15,5 +15,5 @@ */ export * from './Header'; -export * from './Indicators'; +export * from './HeaderTabsIndicators'; export * from './types'; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 807961a33b..29f6ecb7d1 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -252,6 +252,7 @@ export const componentDefinitions = { tab: 'canon-HeaderTab', tabActive: 'canon-HeaderTabActive', tabHovered: 'canon-HeaderTabHovered', + subNav: 'canon-HeaderSubNav', }, }, Tooltip: { diff --git a/yarn.lock b/yarn.lock index a27446148e..4230fe30bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"