From a252381557f8d8ca4ea14fae8a03e46578fb53e6 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 29 Jun 2025 08:48:58 +0100 Subject: [PATCH 01/13] First pass at creating new Toolbar Signed-off-by: Charles de Dreuille --- .../src/components/Toolbar/Toolbar.styles.css | 82 +++++++ .../canon/src/components/Toolbar/Toolbar.tsx | 216 ++++++++++++++++++ .../components/Toolbar/Tooltip.stories.tsx | 42 ++++ .../canon/src/components/Toolbar/index.tsx | 17 ++ .../canon/src/components/Toolbar/types.ts | 23 ++ packages/canon/src/css/components.css | 1 + .../canon/src/utils/componentDefinitions.ts | 9 + 7 files changed, 390 insertions(+) create mode 100644 packages/canon/src/components/Toolbar/Toolbar.styles.css create mode 100644 packages/canon/src/components/Toolbar/Toolbar.tsx create mode 100644 packages/canon/src/components/Toolbar/Tooltip.stories.tsx create mode 100644 packages/canon/src/components/Toolbar/index.tsx create mode 100644 packages/canon/src/components/Toolbar/types.ts diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css new file mode 100644 index 0000000000..0c432b971c --- /dev/null +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -0,0 +1,82 @@ +/* + * 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-ToolbarTabs { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--canon-bg-surface-1); + position: relative; + + /* 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; +} + +.canon-ToolbarTabList { + display: flex; + flex-direction: row; +} + +.canon-ToolbarTab { + 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; +} + +/* Active tab indicator */ +.canon-ToolbarActiveIndicator { + content: ''; + position: absolute; + left: var(--active-tab-left); + top: var(--active-tab-top); + width: var(--active-tab-width); + height: var(--active-tab-height); + background-color: green; + border-radius: 4px; + transition: all 0.25s ease-out; + opacity: 1; +} + +/* Hovered tab indicator */ +.canon-ToolbarHoveredIndicator { + content: ''; + position: absolute; + left: var(--hovered-tab-left); + top: var(--hovered-tab-top); + width: var(--hovered-tab-width); + height: var(--hovered-tab-height); + background-color: red; + border-radius: 4px; + transition: all 0.2s ease-out; +} diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx new file mode 100644 index 0000000000..d6f5b4d7f3 --- /dev/null +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -0,0 +1,216 @@ +/* + * 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, + TabListStateContext, +} from 'react-aria-components'; +import { useStyles } from '../../hooks/useStyles'; +import { + useId, + useContext, + useRef, + useEffect, + useState, + useCallback, + MutableRefObject, +} from 'react'; +import { ToolbarProps, ToolbarTab } from './types'; + +export const Toolbar = (props: ToolbarProps) => { + const { tabs } = props; + const { classNames } = useStyles('Toolbar'); + const tabsRef = useRef(null); + const tabRefs = useRef>(new Map()); + const [hoveredKey, setHoveredKey] = useState(null); + + const setTabRef = (key: string, element: HTMLDivElement | null) => { + if (element) { + tabRefs.current.set(key, element); + } else { + tabRefs.current.delete(key); + } + }; + + return ( + <> +
Toolbar
+ {tabs && ( + + + {tabs.map((tab, index) => { + return ( + + ); + })} + + + + )} + + ); +}; + +const Tab = ({ + tab, + setTabRef, + setHoveredKey, +}: { + tab: ToolbarTab; + setTabRef: (key: string, element: HTMLDivElement | null) => void; + setHoveredKey: (key: string | null) => void; +}) => { + const id = useId(); + const { classNames } = useStyles('Toolbar'); + + return ( + setTabRef(id, el as HTMLDivElement)} + onHoverStart={() => setHoveredKey(id)} + onHoverEnd={() => setHoveredKey(null)} + > + {tab.label} + + ); +}; + +const Indicator = ({ + tabRefs, + tabsRef, + hoveredKey, +}: { + tabRefs: MutableRefObject>; + tabsRef: MutableRefObject; + hoveredKey: string | null; +}) => { + const { classNames } = useStyles('Toolbar'); + 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()); + console.log(activeTab); + 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`, + ); + } + } else { + // Clear hovered variables when no tab is hovered + tabsRef.current.style.setProperty('--hovered-tab-left', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-right', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-top', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-bottom', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-width', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-height', '0px'); + } + }, [state?.selectedKey, hoveredKey]); + + useEffect(() => { + updateCSSVariables(); + }, [updateCSSVariables]); + + useEffect(() => { + const handleResize = () => updateCSSVariables(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [updateCSSVariables]); + + return ( + <> +
+
+ + ); +}; diff --git a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx new file mode 100644 index 0000000000..e1c6d67e64 --- /dev/null +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from '@storybook/react'; +import { Toolbar } from './Toolbar'; + +const meta = { + title: 'Components/Toolbar', + component: Toolbar, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tabs: [ + { + label: 'Home', + }, + { + label: 'About', + }, + { + label: 'Contact Super long tab name', + }, + ], + }, +}; diff --git a/packages/canon/src/components/Toolbar/index.tsx b/packages/canon/src/components/Toolbar/index.tsx new file mode 100644 index 0000000000..3e26a05f5f --- /dev/null +++ b/packages/canon/src/components/Toolbar/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 * from './Toolbar'; diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Toolbar/types.ts new file mode 100644 index 0000000000..48f5d53c10 --- /dev/null +++ b/packages/canon/src/components/Toolbar/types.ts @@ -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 interface ToolbarTab { + label: string; +} + +export interface ToolbarProps { + tabs: ToolbarTab[]; +} diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 7feb065226..e2656be550 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -41,6 +41,7 @@ @import '../components/Text/styles.css'; @import '../components/TextField/TextField.styles.css'; @import '../components/SearchField/SearchField.styles.css'; +@import '../components/Toolbar/Toolbar.styles.css'; @import '../components/Tooltip/Tooltip.styles.css'; @import '../components/ScrollArea/ScrollArea.styles.css'; @import '../components/Select/Select.styles.css'; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index aa941d303c..b23757313d 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -235,6 +235,15 @@ export const componentDefinitions = { disabled: [true, false] as const, }, }, + Toolbar: { + classNames: { + tabs: 'canon-ToolbarTabs', + tabList: 'canon-ToolbarTabList', + tab: 'canon-ToolbarTab', + activeIndicator: 'canon-ToolbarActiveIndicator', + hoveredIndicator: 'canon-ToolbarHoveredIndicator', + }, + }, Tooltip: { classNames: { trigger: 'canon-TooltipTrigger', From 99bab1da3b7691b691bea769e6892f3d6f19f737 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 29 Jun 2025 14:23:21 +0100 Subject: [PATCH 02/13] Improve tabs hover transition Signed-off-by: Charles de Dreuille --- .../src/components/Toolbar/Toolbar.styles.css | 46 ++++++++-- .../canon/src/components/Toolbar/Toolbar.tsx | 83 ++++++++++++------- .../canon/src/components/Toolbar/types.ts | 15 ++++ .../canon/src/utils/componentDefinitions.ts | 2 + 4 files changed, 105 insertions(+), 41 deletions(-) diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css index 0c432b971c..edcb3b4b8d 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.styles.css +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -14,12 +14,33 @@ * limitations under the License. */ +.canon-ToolbarRoot { + display: flex; + flex-direction: column; + gap: var(--canon-space-2); +} + +.canon-Toolbar { + display: flex; + flex-direction: row; + align-items: center; + 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-ToolbarTabs { 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); /* Initialize CSS variables */ --active-tab-left: 0px; @@ -35,6 +56,8 @@ --hovered-tab-bottom: 0px; --hovered-tab-width: 0px; --hovered-tab-height: 0px; + --hovered-tab-opacity: 0; + --hovered-transition-duration: 0s; } .canon-ToolbarTabList { @@ -52,17 +75,18 @@ cursor: pointer; position: relative; z-index: 2; + padding-inline: var(--canon-space-2); } /* Active tab indicator */ .canon-ToolbarActiveIndicator { content: ''; position: absolute; - left: var(--active-tab-left); - top: var(--active-tab-top); - width: var(--active-tab-width); - height: var(--active-tab-height); - background-color: green; + 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; @@ -73,10 +97,14 @@ content: ''; position: absolute; left: var(--hovered-tab-left); - top: var(--hovered-tab-top); + top: calc(var(--hovered-tab-top) + 4px); width: var(--hovered-tab-width); - height: var(--hovered-tab-height); - background-color: red; + height: calc(var(--hovered-tab-height) - 8px); + background-color: var(--canon-gray-2); border-radius: 4px; - transition: all 0.2s ease-out; + 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; } diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx index d6f5b4d7f3..65ee408399 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -28,9 +28,12 @@ import { useEffect, useState, useCallback, - MutableRefObject, } from 'react'; -import { ToolbarProps, ToolbarTab } from './types'; +import type { + ToolbarProps, + ToolbarIndicatorsProps, + ToolbarTabProps, +} from './types'; export const Toolbar = (props: ToolbarProps) => { const { tabs } = props; @@ -38,6 +41,7 @@ export const Toolbar = (props: ToolbarProps) => { 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) { @@ -48,8 +52,8 @@ export const Toolbar = (props: ToolbarProps) => { }; return ( - <> -
Toolbar
+
+
Toolbar
{tabs && ( @@ -64,26 +68,20 @@ export const Toolbar = (props: ToolbarProps) => { ); })} - )} - +
); }; -const Tab = ({ - tab, - setTabRef, - setHoveredKey, -}: { - tab: ToolbarTab; - setTabRef: (key: string, element: HTMLDivElement | null) => void; - setHoveredKey: (key: string | null) => void; -}) => { +const Tab = (props: ToolbarTabProps) => { + const { tab, setTabRef, setHoveredKey } = props; const id = useId(); const { classNames } = useStyles('Toolbar'); @@ -100,15 +98,8 @@ const Tab = ({ ); }; -const Indicator = ({ - tabRefs, - tabsRef, - hoveredKey, -}: { - tabRefs: MutableRefObject>; - tabsRef: MutableRefObject; - hoveredKey: string | null; -}) => { +const Indicators = (props: ToolbarIndicatorsProps) => { + const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; const { classNames } = useStyles('Toolbar'); const state = useContext(TabListStateContext); @@ -120,7 +111,7 @@ const Indicator = ({ // Set active tab variables if (state?.selectedKey) { const activeTab = tabRefs.current.get(state.selectedKey.toString()); - console.log(activeTab); + if (activeTab) { const activeRect = activeTab.getBoundingClientRect(); const relativeLeft = activeRect.left - tabsRect.left; @@ -185,15 +176,43 @@ const Indicator = ({ '--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 { - // Clear hovered variables when no tab is hovered - tabsRef.current.style.setProperty('--hovered-tab-left', '0px'); - tabsRef.current.style.setProperty('--hovered-tab-right', '0px'); - tabsRef.current.style.setProperty('--hovered-tab-top', '0px'); - tabsRef.current.style.setProperty('--hovered-tab-bottom', '0px'); - tabsRef.current.style.setProperty('--hovered-tab-width', '0px'); - tabsRef.current.style.setProperty('--hovered-tab-height', '0px'); + // 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]); diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Toolbar/types.ts index 48f5d53c10..572a1beee7 100644 --- a/packages/canon/src/components/Toolbar/types.ts +++ b/packages/canon/src/components/Toolbar/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { MutableRefObject } from 'react'; + export interface ToolbarTab { label: string; } @@ -21,3 +23,16 @@ export interface ToolbarTab { export interface ToolbarProps { tabs: ToolbarTab[]; } + +export interface ToolbarTabProps { + tab: ToolbarTab; + setTabRef: (key: string, element: HTMLDivElement | null) => void; + setHoveredKey: (key: string | null) => void; +} + +export interface ToolbarIndicatorsProps { + tabRefs: MutableRefObject>; + tabsRef: MutableRefObject; + hoveredKey: string | null; + prevHoveredKey: MutableRefObject; +} diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index b23757313d..ee3e9775d2 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -237,6 +237,8 @@ export const componentDefinitions = { }, Toolbar: { classNames: { + root: 'canon-ToolbarRoot', + toolbar: 'canon-Toolbar', tabs: 'canon-ToolbarTabs', tabList: 'canon-ToolbarTabList', tab: 'canon-ToolbarTab', From 5554b13fa43bcfa81e40707454c3ca09051c1072 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 29 Jun 2025 15:12:05 +0100 Subject: [PATCH 03/13] Improve toolbar content Signed-off-by: Charles de Dreuille --- .../src/components/Toolbar/Indicators.tsx | 156 +++++++++++++++ .../src/components/Toolbar/Toolbar.styles.css | 53 ++++- .../canon/src/components/Toolbar/Toolbar.tsx | 181 +++--------------- .../components/Toolbar/Tooltip.stories.tsx | 87 +++++++-- .../canon/src/components/Toolbar/types.ts | 4 +- .../canon/src/utils/componentDefinitions.ts | 7 +- 6 files changed, 310 insertions(+), 178 deletions(-) create mode 100644 packages/canon/src/components/Toolbar/Indicators.tsx diff --git a/packages/canon/src/components/Toolbar/Indicators.tsx b/packages/canon/src/components/Toolbar/Indicators.tsx new file mode 100644 index 0000000000..cc4c92ed5f --- /dev/null +++ b/packages/canon/src/components/Toolbar/Indicators.tsx @@ -0,0 +1,156 @@ +/* + * 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 { ToolbarIndicatorsProps } from './types'; + +export const Indicators = (props: ToolbarIndicatorsProps) => { + const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; + const { classNames } = useStyles('Toolbar'); + 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 ( + <> +
+
+ + ); +}; diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css index edcb3b4b8d..780470c43c 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.styles.css +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -14,16 +14,31 @@ * limitations under the License. */ -.canon-ToolbarRoot { - display: flex; - flex-direction: column; - gap: var(--canon-space-2); +.canon-Toolbar { + 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-Toolbar { +.canon-ToolbarContentWrapper { + 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); @@ -32,6 +47,33 @@ height: 52px; } +.canon-ToolbarContent { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--canon-space-2); +} + +.canon-ToolbarName { + 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-ToolbarIcon { + width: 16px; + height: 16px; + color: var(--canon-fg-primary); + + & svg { + width: 100%; + height: 100%; + } +} + .canon-ToolbarTabs { display: flex; flex-direction: row; @@ -41,6 +83,7 @@ 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; diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx index 65ee408399..9ef4d0edbd 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -14,29 +14,16 @@ * limitations under the License. */ -import { - Tabs, - TabList, - Tab as AriaTab, - TabListStateContext, -} from 'react-aria-components'; +import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; -import { - useId, - useContext, - useRef, - useEffect, - useState, - useCallback, -} from 'react'; -import type { - ToolbarProps, - ToolbarIndicatorsProps, - ToolbarTabProps, -} from './types'; +import { useId, useRef, useState } from 'react'; +import { Indicators } from './Indicators'; +import { RiMore2Line, RiShapesLine } from '@remixicon/react'; +import type { ToolbarProps, ToolbarTabProps } from './types'; +import { ButtonIcon } from '../ButtonIcon'; export const Toolbar = (props: ToolbarProps) => { - const { tabs } = props; + const { tabs, icon, name } = props; const { classNames } = useStyles('Toolbar'); const tabsRef = useRef(null); const tabRefs = useRef>(new Map()); @@ -52,8 +39,20 @@ export const Toolbar = (props: ToolbarProps) => { }; return ( -
-
Toolbar
+ <> +
+
+
+
+
{icon || }
+ {name || 'Your plugin'} +
+
+
+ } variant="secondary" /> +
+
+
{tabs && ( @@ -76,7 +75,7 @@ export const Toolbar = (props: ToolbarProps) => { /> )} -
+ ); }; @@ -97,139 +96,3 @@ const Tab = (props: ToolbarTabProps) => { ); }; - -const Indicators = (props: ToolbarIndicatorsProps) => { - const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; - const { classNames } = useStyles('Toolbar'); - 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 ( - <> -
-
- - ); -}; diff --git a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx index e1c6d67e64..67c02b3e9e 100644 --- a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; import { Toolbar } from './Toolbar'; const meta = { @@ -25,18 +25,81 @@ const meta = { export default meta; type Story = StoryObj; +const tabs = [ + { + label: 'Home', + }, + { + label: 'About', + }, +]; + +// Extract layout decorator as a reusable constant +const withLayoutDecorator = [ + (Story: StoryFn) => ( + <> +
+
+ +
+
+ + ), +]; + export const Default: Story = { + args: {}, +}; + +export const WithTabs: Story = { args: { - tabs: [ - { - label: 'Home', - }, - { - label: 'About', - }, - { - label: 'Contact Super long tab name', - }, - ], + tabs, + }, +}; + +export const WithLayout: Story = { + args: {}, + decorators: withLayoutDecorator, + parameters: { + layout: 'fullscreen', + }, +}; + +export const WithTabsInLayout: Story = { + args: { + tabs, + }, + decorators: withLayoutDecorator, + parameters: { + layout: 'fullscreen', }, }; diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Toolbar/types.ts index 572a1beee7..fad28468f3 100644 --- a/packages/canon/src/components/Toolbar/types.ts +++ b/packages/canon/src/components/Toolbar/types.ts @@ -21,7 +21,9 @@ export interface ToolbarTab { } export interface ToolbarProps { - tabs: ToolbarTab[]; + icon?: React.ReactNode; + name?: string; + tabs?: ToolbarTab[]; } export interface ToolbarTabProps { diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index ee3e9775d2..9aa18f09d0 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -237,8 +237,13 @@ export const componentDefinitions = { }, Toolbar: { classNames: { - root: 'canon-ToolbarRoot', toolbar: 'canon-Toolbar', + toolbarContentWrapper: 'canon-ToolbarContentWrapper', + toolbarOverlay: 'canon-ToolbarOverlay', + toolbarContent: 'canon-ToolbarContent', + toolbarOptions: 'canon-ToolbarOptions', + icon: 'canon-ToolbarIcon', + name: 'canon-ToolbarName', tabs: 'canon-ToolbarTabs', tabList: 'canon-ToolbarTabList', tab: 'canon-ToolbarTab', From b0a6c8e6e2eab72a7dfd8e0bcb143c2bd8d4b0e5 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 29 Jun 2025 20:43:38 +0100 Subject: [PATCH 04/13] Improve toolbar Signed-off-by: Charles de Dreuille --- .changeset/early-trains-relax.md | 5 + packages/canon/css/styles.css | 131 ++++++++++++++++++ packages/canon/report.api.md | 16 +++ .../canon/src/components/Menu/Menu.styles.css | 1 + .../src/components/Toolbar/Indicators.tsx | 7 +- .../canon/src/components/Toolbar/Toolbar.tsx | 46 +++++- .../components/Toolbar/Tooltip.stories.tsx | 33 +++-- .../canon/src/components/Toolbar/index.tsx | 2 + .../canon/src/components/Toolbar/types.ts | 32 +++++ 9 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 .changeset/early-trains-relax.md diff --git a/.changeset/early-trains-relax.md b/.changeset/early-trains-relax.md new file mode 100644 index 0000000000..6e41593160 --- /dev/null +++ b/.changeset/early-trains-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +Add new Toolbar component to Canon. diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 4fbd0cf035..9ae1899c52 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -9644,6 +9644,7 @@ } .canon-MenuPositioner { + z-index: 100; outline: 0; } @@ -10264,6 +10265,136 @@ height: 1rem; } +.canon-Toolbar { + 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-ToolbarContentWrapper { + 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-ToolbarContent { + align-items: center; + gap: var(--canon-space-2); + flex-direction: row; + display: flex; +} + +.canon-ToolbarName { + 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-ToolbarIcon { + width: 16px; + height: 16px; + color: var(--canon-fg-primary); + + & svg { + width: 100%; + height: 100%; + } +} + +.canon-ToolbarTabs { + 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-ToolbarTabList { + flex-direction: row; + display: flex; +} + +.canon-ToolbarTab { + 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-ToolbarActiveIndicator { + 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-ToolbarHoveredIndicator { + 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-TooltipPopup { box-sizing: border-box; transform-origin: var(--transform-origin); diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 372b82436b..49c647f9ea 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -508,6 +508,22 @@ export const componentDefinitions: { readonly disabled: readonly [true, false]; }; }; + readonly Toolbar: { + 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 Tooltip: { readonly classNames: { readonly trigger: 'canon-TooltipTrigger'; diff --git a/packages/canon/src/components/Menu/Menu.styles.css b/packages/canon/src/components/Menu/Menu.styles.css index 7ccaa7a634..37fa4731e5 100644 --- a/packages/canon/src/components/Menu/Menu.styles.css +++ b/packages/canon/src/components/Menu/Menu.styles.css @@ -1,5 +1,6 @@ .canon-MenuPositioner { outline: 0; + z-index: 100; } .canon-MenuPopup { diff --git a/packages/canon/src/components/Toolbar/Indicators.tsx b/packages/canon/src/components/Toolbar/Indicators.tsx index cc4c92ed5f..b9599833ff 100644 --- a/packages/canon/src/components/Toolbar/Indicators.tsx +++ b/packages/canon/src/components/Toolbar/Indicators.tsx @@ -19,7 +19,12 @@ import { useStyles } from '../../hooks/useStyles'; import { useContext, useEffect, useCallback } from 'react'; import type { ToolbarIndicatorsProps } from './types'; -export const Indicators = (props: ToolbarIndicatorsProps) => { +/** + * A component that renders the indicators for the toolbar. + * + * @public + */ +export const ToolbarTabsIndicators = (props: ToolbarIndicatorsProps) => { const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; const { classNames } = useStyles('Toolbar'); const state = useContext(TabListStateContext); diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx index 9ef4d0edbd..e7d5fd855b 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -17,13 +17,19 @@ import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; import { useId, useRef, useState } from 'react'; -import { Indicators } from './Indicators'; +import { ToolbarTabsIndicators } from './Indicators'; import { RiMore2Line, RiShapesLine } from '@remixicon/react'; import type { ToolbarProps, ToolbarTabProps } from './types'; import { ButtonIcon } from '../ButtonIcon'; +import { Menu } from '../Menu'; +/** + * A component that renders a toolbar. + * + * @public + */ export const Toolbar = (props: ToolbarProps) => { - const { tabs, icon, name } = props; + const { tabs, icon, name, options } = props; const { classNames } = useStyles('Toolbar'); const tabsRef = useRef(null); const tabRefs = useRef>(new Map()); @@ -49,7 +55,34 @@ export const Toolbar = (props: ToolbarProps) => {
- } variant="secondary" /> + {options && ( + + ( + } + variant="secondary" + /> + )} + /> + + + + {options.map(option => ( + option.onClick?.()} + > + {option.label} + + ))} + + + + + )}
@@ -67,7 +100,7 @@ export const Toolbar = (props: ToolbarProps) => { ); })} - { ); }; +/** + * A component that renders a toolbar tab. + * + * @public + */ const Tab = (props: ToolbarTabProps) => { const { tab, setTabRef, setHoveredKey } = props; const id = useId(); diff --git a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx index 67c02b3e9e..93efd7efc8 100644 --- a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -34,8 +34,19 @@ const tabs = [ }, ]; +const options = [ + { + label: 'Settings', + value: 'settings', + }, + { + label: 'Invite new members', + value: 'invite-new-members', + }, +]; + // Extract layout decorator as a reusable constant -const withLayoutDecorator = [ +const layoutDecorator = [ (Story: StoryFn) => ( <>
void; +} + +/** + * Props for the main Toolbar component. + * + * @public + */ export interface ToolbarProps { icon?: React.ReactNode; name?: string; tabs?: ToolbarTab[]; + options?: ToolbarOption[]; } +/** + * Props for individual toolbar tab components. + * + * @public + */ export interface ToolbarTabProps { tab: ToolbarTab; setTabRef: (key: string, element: HTMLDivElement | null) => void; setHoveredKey: (key: string | null) => void; } +/** + * Props for the toolbar indicators component that handles tab highlighting and animation. + * + * @public + */ export interface ToolbarIndicatorsProps { tabRefs: MutableRefObject>; tabsRef: MutableRefObject; From f0e6f06cbf05a90b00f040b9cb3fa8c66039ee1f Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 29 Jun 2025 20:59:37 +0100 Subject: [PATCH 05/13] Improve styles for tabs Signed-off-by: Charles de Dreuille --- .../canon/src/components/Toolbar/Toolbar.styles.css | 1 + .../src/components/Toolbar/Tooltip.stories.tsx | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css index 780470c43c..a69e698c84 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.styles.css +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -84,6 +84,7 @@ border-radius: var(--canon-radius-2); border: 1px solid var(--canon-border); margin-top: 8px; + overflow: hidden; /* Initialize CSS variables */ --active-tab-left: 0px; diff --git a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx index 93efd7efc8..3e6ee1d7bb 100644 --- a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -27,10 +27,19 @@ type Story = StoryObj; const tabs = [ { - label: 'Home', + label: 'Overview', }, { - label: 'About', + label: 'Checks', + }, + { + label: 'Tracks', + }, + { + label: 'Campaigns', + }, + { + label: 'Integrations', }, ]; From 912907e28e0d3d83dff7df981eca5487700cf6f9 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 30 Jun 2025 14:22:17 +0100 Subject: [PATCH 06/13] Breadcrumb responsive improvements. Signed-off-by: Charles de Dreuille --- .../src/components/Toolbar/Toolbar.styles.css | 41 ++++++- .../canon/src/components/Toolbar/Toolbar.tsx | 105 ++++++++++++++++-- .../components/Toolbar/Tooltip.stories.tsx | 30 ++++- .../canon/src/components/Toolbar/types.ts | 12 ++ .../canon/src/utils/componentDefinitions.ts | 4 + 5 files changed, 180 insertions(+), 12 deletions(-) diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css index a69e698c84..ed48b008d0 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.styles.css +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -61,6 +61,7 @@ gap: var(--canon-space-2); font-size: var(--canon-font-size-3); font-weight: var(--canon-font-weight-regular); + flex-shrink: 0; } .canon-ToolbarIcon { @@ -74,6 +75,45 @@ } } +.canon-ToolbarBreadcrumbs { + 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-ToolbarBreadcrumb { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--canon-space-2); +} + +.canon-ToolbarBreadcrumbLink { + color: var(--canon-fg-secondary); + text-decoration: none; + cursor: pointer; + + &[data-active='true'] { + color: var(--canon-fg-primary); + } +} + +.canon-ToolbarBreadcrumbSeparator { + width: 16px; + height: 16px; + color: var(--canon-fg-secondary); +} + +.canon-ToolbarOptions { + position: absolute; + right: var(--canon-space-2); + top: 50%; + transform: translateY(-50%); +} + .canon-ToolbarTabs { display: flex; flex-direction: row; @@ -84,7 +124,6 @@ border-radius: var(--canon-radius-2); border: 1px solid var(--canon-border); margin-top: 8px; - overflow: hidden; /* Initialize CSS variables */ --active-tab-left: 0px; diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx index e7d5fd855b..4c83c64143 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -14,14 +14,15 @@ * limitations under the License. */ -import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; +import { Tabs, TabList, Tab as AriaTab, Link } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; -import { useId, useRef, useState } from 'react'; +import { useId, useRef, useState, useEffect } from 'react'; import { ToolbarTabsIndicators } from './Indicators'; -import { RiMore2Line, RiShapesLine } from '@remixicon/react'; +import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react'; import type { ToolbarProps, ToolbarTabProps } from './types'; import { ButtonIcon } from '../ButtonIcon'; import { Menu } from '../Menu'; +import { motion, useScroll, useTransform } from 'framer-motion'; /** * A component that renders a toolbar. @@ -29,12 +30,22 @@ import { Menu } from '../Menu'; * @public */ export const Toolbar = (props: ToolbarProps) => { - const { tabs, icon, name, options } = props; + const { tabs, icon, name, options, breadcrumbs } = props; const { classNames } = useStyles('Toolbar'); 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) { @@ -44,17 +55,94 @@ export const Toolbar = (props: ToolbarProps) => { } }; + // 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 && ( {
{tabs && ( - + {tabs.map((tab, index) => { return ( { ref={el => setTabRef(id, el as HTMLDivElement)} onHoverStart={() => setHoveredKey(id)} onHoverEnd={() => setHoveredKey(null)} + href={tab.href} > {tab.label} diff --git a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx index 3e6ee1d7bb..5840179926 100644 --- a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -16,6 +16,7 @@ import type { Meta, StoryObj, StoryFn } from '@storybook/react'; import { Toolbar } from './Toolbar'; +import { ToolbarBreadcrumb, ToolbarOption, ToolbarTab } from './types'; const meta = { title: 'Components/Toolbar', @@ -25,7 +26,7 @@ const meta = { export default meta; type Story = StoryObj; -const tabs = [ +const tabs: ToolbarTab[] = [ { label: 'Overview', }, @@ -43,7 +44,22 @@ const tabs = [ }, ]; -const options = [ +const breadcrumbs: ToolbarBreadcrumb[] = [ + { + label: 'Home', + href: '/', + }, + { + label: 'Dashboard', + href: '/dashboard', + }, + { + label: 'Settings', + href: '/settings', + }, +]; + +const options: ToolbarOption[] = [ { label: 'Settings', value: 'settings', @@ -112,10 +128,17 @@ export const WithOptions: Story = { }, }; -export const WithOptionsAndTabs: Story = { +export const WithBreadcrumbs: Story = { + args: { + breadcrumbs, + }, +}; + +export const WithAllComponents: Story = { args: { options, tabs, + breadcrumbs, }, }; @@ -123,6 +146,7 @@ export const WithLayout: Story = { args: { options, tabs, + breadcrumbs, }, decorators: layoutDecorator, parameters: { diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Toolbar/types.ts index 4efd9bcb30..d2e0f336a3 100644 --- a/packages/canon/src/components/Toolbar/types.ts +++ b/packages/canon/src/components/Toolbar/types.ts @@ -23,6 +23,7 @@ import { MutableRefObject } from 'react'; */ export interface ToolbarTab { label: string; + href?: string; } /** @@ -36,6 +37,16 @@ export interface ToolbarOption { onClick?: () => void; } +/** + * Represents a breadcrumb item in the toolbar. + * + * @public + */ +export interface ToolbarBreadcrumb { + label: string; + href: string; +} + /** * Props for the main Toolbar component. * @@ -46,6 +57,7 @@ export interface ToolbarProps { name?: string; tabs?: ToolbarTab[]; options?: ToolbarOption[]; + breadcrumbs?: ToolbarBreadcrumb[]; } /** diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 9aa18f09d0..6773aa5f30 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -244,6 +244,10 @@ export const componentDefinitions = { toolbarOptions: 'canon-ToolbarOptions', icon: 'canon-ToolbarIcon', name: 'canon-ToolbarName', + breadcrumbs: 'canon-ToolbarBreadcrumbs', + breadcrumb: 'canon-ToolbarBreadcrumb', + breadcrumbLink: 'canon-ToolbarBreadcrumbLink', + breadcrumbSeparator: 'canon-ToolbarBreadcrumbSeparator', tabs: 'canon-ToolbarTabs', tabList: 'canon-ToolbarTabList', tab: 'canon-ToolbarTab', From f4109f84ade11da2669222163bb76c01121e5120 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 30 Jun 2025 15:25:08 +0100 Subject: [PATCH 07/13] Update Toolbar.tsx Signed-off-by: Charles de Dreuille --- packages/canon/src/components/Toolbar/Toolbar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx index 4c83c64143..af958e348a 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -175,7 +175,11 @@ export const Toolbar = (props: ToolbarProps) => {
{tabs && ( - + {tabs.map((tab, index) => { return ( From ebd42e6222a2ca8a2f8c56df127bd63097ea9746 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 30 Jun 2025 21:06:45 +0100 Subject: [PATCH 08/13] Toolbar -> Header Signed-off-by: Charles de Dreuille --- packages/canon/css/styles.css | 300 ++++++++++-------- .../Header.stories.tsx} | 16 +- .../Header.styles.css} | 42 +-- .../Toolbar.tsx => Header/Header.tsx} | 25 +- .../{Toolbar => Header}/Indicators.tsx | 10 +- .../components/{Toolbar => Header}/index.tsx | 2 +- .../components/{Toolbar => Header}/types.ts | 54 ++-- packages/canon/src/css/components.css | 2 +- .../canon/src/utils/componentDefinitions.ts | 33 +- 9 files changed, 264 insertions(+), 220 deletions(-) rename packages/canon/src/components/{Toolbar/Tooltip.stories.tsx => Header/Header.stories.tsx} (89%) rename packages/canon/src/components/{Toolbar/Toolbar.styles.css => Header/Header.styles.css} (91%) rename packages/canon/src/components/{Toolbar/Toolbar.tsx => Header/Header.tsx} (92%) rename packages/canon/src/components/{Toolbar => Header}/Indicators.tsx (94%) rename packages/canon/src/components/{Toolbar => Header}/index.tsx (96%) rename packages/canon/src/components/{Toolbar => Header}/types.ts (62%) diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 9ae1899c52..9a5b19bff8 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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); @@ -10265,136 +10435,6 @@ height: 1rem; } -.canon-Toolbar { - 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-ToolbarContentWrapper { - 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-ToolbarContent { - align-items: center; - gap: var(--canon-space-2); - flex-direction: row; - display: flex; -} - -.canon-ToolbarName { - 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-ToolbarIcon { - width: 16px; - height: 16px; - color: var(--canon-fg-primary); - - & svg { - width: 100%; - height: 100%; - } -} - -.canon-ToolbarTabs { - 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-ToolbarTabList { - flex-direction: row; - display: flex; -} - -.canon-ToolbarTab { - 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-ToolbarActiveIndicator { - 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-ToolbarHoveredIndicator { - 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-TooltipPopup { box-sizing: border-box; transform-origin: var(--transform-origin); diff --git a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx b/packages/canon/src/components/Header/Header.stories.tsx similarity index 89% rename from packages/canon/src/components/Toolbar/Tooltip.stories.tsx rename to packages/canon/src/components/Header/Header.stories.tsx index 5840179926..0554648bc9 100644 --- a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx +++ b/packages/canon/src/components/Header/Header.stories.tsx @@ -15,18 +15,18 @@ */ import type { Meta, StoryObj, StoryFn } from '@storybook/react'; -import { Toolbar } from './Toolbar'; -import { ToolbarBreadcrumb, ToolbarOption, ToolbarTab } from './types'; +import { Header } from './Header'; +import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types'; const meta = { - title: 'Components/Toolbar', - component: Toolbar, -} satisfies Meta; + title: 'Components/Header', + component: Header, +} satisfies Meta; export default meta; type Story = StoryObj; -const tabs: ToolbarTab[] = [ +const tabs: HeaderTab[] = [ { label: 'Overview', }, @@ -44,7 +44,7 @@ const tabs: ToolbarTab[] = [ }, ]; -const breadcrumbs: ToolbarBreadcrumb[] = [ +const breadcrumbs: HeaderBreadcrumb[] = [ { label: 'Home', href: '/', @@ -59,7 +59,7 @@ const breadcrumbs: ToolbarBreadcrumb[] = [ }, ]; -const options: ToolbarOption[] = [ +const options: HeaderOption[] = [ { label: 'Settings', value: 'settings', diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Header/Header.styles.css similarity index 91% rename from packages/canon/src/components/Toolbar/Toolbar.styles.css rename to packages/canon/src/components/Header/Header.styles.css index ed48b008d0..76b7c6bcf4 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.styles.css +++ b/packages/canon/src/components/Header/Header.styles.css @@ -14,7 +14,7 @@ * limitations under the License. */ -.canon-Toolbar { +.canon-HeaderToolbar { position: sticky; top: 0; z-index: 10; @@ -32,7 +32,7 @@ } } -.canon-ToolbarContentWrapper { +.canon-HeaderToolbarWrapper { position: relative; z-index: 1; display: flex; @@ -47,14 +47,14 @@ height: 52px; } -.canon-ToolbarContent { +.canon-HeaderToolbarContent { display: flex; flex-direction: row; align-items: center; gap: var(--canon-space-2); } -.canon-ToolbarName { +.canon-HeaderToolbarName { display: flex; flex-direction: row; align-items: center; @@ -64,7 +64,7 @@ flex-shrink: 0; } -.canon-ToolbarIcon { +.canon-HeaderToolbarIcon { width: 16px; height: 16px; color: var(--canon-fg-primary); @@ -75,7 +75,14 @@ } } -.canon-ToolbarBreadcrumbs { +.canon-HeaderToolbarOptions { + position: absolute; + right: var(--canon-space-2); + top: 50%; + transform: translateY(-50%); +} + +.canon-HeaderBreadcrumbs { display: flex; flex-direction: row; align-items: center; @@ -84,14 +91,14 @@ font-weight: var(--canon-font-weight-regular); } -.canon-ToolbarBreadcrumb { +.canon-HeaderBreadcrumb { display: flex; flex-direction: row; align-items: center; gap: var(--canon-space-2); } -.canon-ToolbarBreadcrumbLink { +.canon-HeaderBreadcrumbLink { color: var(--canon-fg-secondary); text-decoration: none; cursor: pointer; @@ -101,20 +108,13 @@ } } -.canon-ToolbarBreadcrumbSeparator { +.canon-HeaderBreadcrumbSeparator { width: 16px; height: 16px; color: var(--canon-fg-secondary); } -.canon-ToolbarOptions { - position: absolute; - right: var(--canon-space-2); - top: 50%; - transform: translateY(-50%); -} - -.canon-ToolbarTabs { +.canon-HeaderTabs { display: flex; flex-direction: row; align-items: center; @@ -143,12 +143,12 @@ --hovered-transition-duration: 0s; } -.canon-ToolbarTabList { +.canon-HeaderTabList { display: flex; flex-direction: row; } -.canon-ToolbarTab { +.canon-HeaderTab { font-size: var(--canon-font-size-3); color: var(--canon-fg-primary); height: 36px; @@ -162,7 +162,7 @@ } /* Active tab indicator */ -.canon-ToolbarActiveIndicator { +.canon-HeaderTabActive { content: ''; position: absolute; left: calc(var(--active-tab-left) + var(--canon-space-2)); @@ -176,7 +176,7 @@ } /* Hovered tab indicator */ -.canon-ToolbarHoveredIndicator { +.canon-HeaderTabHovered { content: ''; position: absolute; left: var(--hovered-tab-left); diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Header/Header.tsx similarity index 92% rename from packages/canon/src/components/Toolbar/Toolbar.tsx rename to packages/canon/src/components/Header/Header.tsx index af958e348a..e8a42104a5 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -17,9 +17,9 @@ import { Tabs, TabList, Tab as AriaTab, Link } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; import { useId, useRef, useState, useEffect } from 'react'; -import { ToolbarTabsIndicators } from './Indicators'; +import { HeaderTabsIndicators } from './Indicators'; import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react'; -import type { ToolbarProps, ToolbarTabProps } from './types'; +import type { HeaderProps, HeaderTabProps } from './types'; import { ButtonIcon } from '../ButtonIcon'; import { Menu } from '../Menu'; import { motion, useScroll, useTransform } from 'framer-motion'; @@ -29,9 +29,9 @@ import { motion, useScroll, useTransform } from 'framer-motion'; * * @public */ -export const Toolbar = (props: ToolbarProps) => { +export const Header = (props: HeaderProps) => { const { tabs, icon, name, options, breadcrumbs } = props; - const { classNames } = useStyles('Toolbar'); + const { classNames } = useStyles('Header'); const tabsRef = useRef(null); const tabRefs = useRef>(new Map()); const [hoveredKey, setHoveredKey] = useState(null); @@ -101,13 +101,12 @@ export const Toolbar = (props: ToolbarProps) => { return ( <>
-
+
-
-
{icon || }
+
+
+ {icon || } +
{name || 'Your plugin'}
{breadcrumbs && ( @@ -192,7 +191,7 @@ export const Toolbar = (props: ToolbarProps) => { ); })} - { * * @public */ -const Tab = (props: ToolbarTabProps) => { +const Tab = (props: HeaderTabProps) => { const { tab, setTabRef, setHoveredKey } = props; const id = useId(); - const { classNames } = useStyles('Toolbar'); + const { classNames } = useStyles('Header'); return ( { +export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; - const { classNames } = useStyles('Toolbar'); + const { classNames } = useStyles('Header'); const state = useContext(TabListStateContext); const updateCSSVariables = useCallback(() => { @@ -154,8 +154,8 @@ export const ToolbarTabsIndicators = (props: ToolbarIndicatorsProps) => { return ( <> -
-
+
+
); }; diff --git a/packages/canon/src/components/Toolbar/index.tsx b/packages/canon/src/components/Header/index.tsx similarity index 96% rename from packages/canon/src/components/Toolbar/index.tsx rename to packages/canon/src/components/Header/index.tsx index 5cc628dadc..40935319bf 100644 --- a/packages/canon/src/components/Toolbar/index.tsx +++ b/packages/canon/src/components/Header/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ -export * from './Toolbar'; +export * from './Header'; export * from './Indicators'; export * from './types'; diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Header/types.ts similarity index 62% rename from packages/canon/src/components/Toolbar/types.ts rename to packages/canon/src/components/Header/types.ts index d2e0f336a3..b8bcf179c9 100644 --- a/packages/canon/src/components/Toolbar/types.ts +++ b/packages/canon/src/components/Header/types.ts @@ -17,66 +17,72 @@ import { MutableRefObject } from 'react'; /** - * Represents a tab item in the toolbar navigation. + * Props for the main Header component. * * @public */ -export interface ToolbarTab { +export interface HeaderProps { + icon?: React.ReactNode; + name?: string; + tabs?: HeaderTab[]; + options?: HeaderOption[]; + breadcrumbs?: HeaderBreadcrumb[]; + subNavigation?: { + name?: string; + description?: string; + tabs?: HeaderTab[]; + options?: HeaderOption[]; + }; +} + +/** + * Represents a tab item in the header navigation. + * + * @public + */ +export interface HeaderTab { label: string; href?: string; } /** - * Represents an option item in the toolbar dropdown menu. + * Represents an option item in the header dropdown menu. * * @public */ -export interface ToolbarOption { +export interface HeaderOption { label: string; value: string; onClick?: () => void; } /** - * Represents a breadcrumb item in the toolbar. + * Represents a breadcrumb item in the header. * * @public */ -export interface ToolbarBreadcrumb { +export interface HeaderBreadcrumb { label: string; href: string; } /** - * Props for the main Toolbar component. + * Props for individual header tab components. * * @public */ -export interface ToolbarProps { - icon?: React.ReactNode; - name?: string; - tabs?: ToolbarTab[]; - options?: ToolbarOption[]; - breadcrumbs?: ToolbarBreadcrumb[]; -} - -/** - * Props for individual toolbar tab components. - * - * @public - */ -export interface ToolbarTabProps { - tab: ToolbarTab; +export interface HeaderTabProps { + tab: HeaderTab; setTabRef: (key: string, element: HTMLDivElement | null) => void; setHoveredKey: (key: string | null) => void; } /** - * Props for the toolbar indicators component that handles tab highlighting and animation. + * Props for the header indicators component that handles tab highlighting and animation. * * @public */ -export interface ToolbarIndicatorsProps { +export interface HeaderIndicatorsProps { tabRefs: MutableRefObject>; tabsRef: MutableRefObject; hoveredKey: string | null; diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index e2656be550..5fbacdc6f5 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -27,6 +27,7 @@ @import '../components/FieldLabel/FieldLabel.styles.css'; @import '../components/Flex/styles.css'; @import '../components/Grid/styles.css'; +@import '../components/Header/Header.styles.css'; @import '../components/Heading/styles.css'; @import '../components/Icon/styles.css'; @import '../components/Link/styles.css'; @@ -41,7 +42,6 @@ @import '../components/Text/styles.css'; @import '../components/TextField/TextField.styles.css'; @import '../components/SearchField/SearchField.styles.css'; -@import '../components/Toolbar/Toolbar.styles.css'; @import '../components/Tooltip/Tooltip.styles.css'; @import '../components/ScrollArea/ScrollArea.styles.css'; @import '../components/Select/Select.styles.css'; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 6773aa5f30..807961a33b 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -235,24 +235,23 @@ export const componentDefinitions = { disabled: [true, false] as const, }, }, - Toolbar: { + Header: { classNames: { - toolbar: 'canon-Toolbar', - toolbarContentWrapper: 'canon-ToolbarContentWrapper', - toolbarOverlay: 'canon-ToolbarOverlay', - toolbarContent: 'canon-ToolbarContent', - toolbarOptions: 'canon-ToolbarOptions', - icon: 'canon-ToolbarIcon', - name: 'canon-ToolbarName', - breadcrumbs: 'canon-ToolbarBreadcrumbs', - breadcrumb: 'canon-ToolbarBreadcrumb', - breadcrumbLink: 'canon-ToolbarBreadcrumbLink', - breadcrumbSeparator: 'canon-ToolbarBreadcrumbSeparator', - tabs: 'canon-ToolbarTabs', - tabList: 'canon-ToolbarTabList', - tab: 'canon-ToolbarTab', - activeIndicator: 'canon-ToolbarActiveIndicator', - hoveredIndicator: 'canon-ToolbarHoveredIndicator', + 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', }, }, Tooltip: { From a92dc8e1fb93172d7cc6f85d8ef8d4e7aaf7b293 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 30 Jun 2025 22:47:46 +0100 Subject: [PATCH 09/13] Add sub navigation Signed-off-by: Charles de Dreuille --- packages/canon/package.json | 1 + packages/canon/report.api.md | 30 +-- .../src/components/Header/Header.stories.tsx | 35 ++- .../canon/src/components/Header/Header.tsx | 212 ++---------------- .../src/components/Header/HeaderSubNav.tsx | 67 ++++++ .../src/components/Header/HeaderTabs.tsx | 92 ++++++++ ...ndicators.tsx => HeaderTabsIndicators.tsx} | 0 .../src/components/Header/HeaderToolbar.tsx | 164 ++++++++++++++ .../canon/src/components/Header/index.tsx | 2 +- .../canon/src/utils/componentDefinitions.ts | 1 + yarn.lock | 60 +++++ 11 files changed, 450 insertions(+), 214 deletions(-) create mode 100644 packages/canon/src/components/Header/HeaderSubNav.tsx create mode 100644 packages/canon/src/components/Header/HeaderTabs.tsx rename packages/canon/src/components/Header/{Indicators.tsx => HeaderTabsIndicators.tsx} (100%) create mode 100644 packages/canon/src/components/Header/HeaderToolbar.tsx 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" From 1a99e7a4bf7f5033df8c7ff7ad832d81f6171909 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 1 Jul 2025 10:38:04 +0100 Subject: [PATCH 10/13] Update early-trains-relax.md Signed-off-by: Charles de Dreuille --- .changeset/early-trains-relax.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/early-trains-relax.md b/.changeset/early-trains-relax.md index 6e41593160..179e224681 100644 --- a/.changeset/early-trains-relax.md +++ b/.changeset/early-trains-relax.md @@ -2,4 +2,4 @@ '@backstage/canon': patch --- -Add new Toolbar component to Canon. +Add new Header component to Canon. From d06b52446bcd4aeedb24b80f75612f4ef36bdd88 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 2 Jul 2025 09:53:53 +0100 Subject: [PATCH 11/13] Move the HeaderPage into its own component Signed-off-by: Charles de Dreuille --- packages/canon/report.api.md | 104 +++++++++++++ .../src/components/Header/Header.stories.tsx | 35 +---- .../canon/src/components/Header/Header.tsx | 4 +- packages/canon/src/components/Header/types.ts | 6 - .../HeaderPage/HeaderPage.stories.tsx | 144 ++++++++++++++++++ .../HeaderPage/HeaderPage.styles.css | 15 ++ .../HeaderPage.tsx} | 22 +-- .../canon/src/components/HeaderPage/index.tsx | 18 +++ .../canon/src/components/HeaderPage/types.ts | 48 ++++++ packages/canon/src/css/components.css | 1 + packages/canon/src/index.ts | 2 + 11 files changed, 347 insertions(+), 52 deletions(-) create mode 100644 packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx create mode 100644 packages/canon/src/components/HeaderPage/HeaderPage.styles.css rename packages/canon/src/components/{Header/HeaderSubNav.tsx => HeaderPage/HeaderPage.tsx} (81%) create mode 100644 packages/canon/src/components/HeaderPage/index.tsx create mode 100644 packages/canon/src/components/HeaderPage/types.ts diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index ed471bab5a..0d7dec2d83 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -20,6 +20,7 @@ import { HTMLAttributes } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { LinkProps as LinkProps_2 } from 'react-aria-components'; import { Menu as Menu_2 } from '@base-ui-components/react/menu'; +import { MutableRefObject } from 'react'; import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components'; import type { RadioProps as RadioProps_2 } from 'react-aria-components'; import { ReactElement } from 'react'; @@ -936,6 +937,109 @@ 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 HeaderIndicatorsProps { + // (undocumented) + hoveredKey: string | null; + // (undocumented) + prevHoveredKey: MutableRefObject; + // (undocumented) + tabRefs: MutableRefObject>; + // (undocumented) + tabsRef: MutableRefObject; +} + +// @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) + name?: string; + // (undocumented) + options?: HeaderPageOption[]; + // (undocumented) + tabs?: HeaderPageTab[]; +} + +// @public +export interface HeaderPageTab { + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + +// @public +export interface HeaderProps { + // (undocumented) + breadcrumbs?: HeaderBreadcrumb[]; + // (undocumented) + icon?: React.ReactNode; + // (undocumented) + name?: string; + // (undocumented) + options?: HeaderOption[]; + // (undocumented) + tabs?: HeaderTab[]; +} + +// @public +export interface HeaderTab { + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + +// @public +export interface HeaderTabProps { + // (undocumented) + setHoveredKey: (key: string | null) => void; + // (undocumented) + setTabRef: (key: string, element: HTMLDivElement | null) => void; + // (undocumented) + tab: HeaderTab; +} + +// @public +export const HeaderTabsIndicators: ( + props: HeaderIndicatorsProps, +) => JSX_2.Element; + // @public (undocumented) export const Heading: ( props: HeadingProps & { diff --git a/packages/canon/src/components/Header/Header.stories.tsx b/packages/canon/src/components/Header/Header.stories.tsx index 047b7f0c94..0554648bc9 100644 --- a/packages/canon/src/components/Header/Header.stories.tsx +++ b/packages/canon/src/components/Header/Header.stories.tsx @@ -16,12 +16,7 @@ import type { Meta, StoryObj, StoryFn } from '@storybook/react'; import { Header } from './Header'; -import { - HeaderBreadcrumb, - HeaderOption, - HeaderProps, - HeaderTab, -} from './types'; +import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types'; const meta = { title: 'Components/Header', @@ -75,13 +70,6 @@ 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) => ( @@ -146,19 +134,11 @@ export const WithBreadcrumbs: Story = { }, }; -export const WithTabsAndSubNavigation: Story = { - args: { - tabs, - subNavigation, - }, -}; - export const WithAllComponents: Story = { args: { options, tabs, breadcrumbs, - subNavigation, }, }; @@ -173,16 +153,3 @@ 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 195c7a59b1..9ea06ab98d 100644 --- a/packages/canon/src/components/Header/Header.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -17,7 +17,6 @@ import type { HeaderProps } from './types'; import { HeaderToolbar } from './HeaderToolbar'; import { HeaderTabs } from './HeaderTabs'; -import { HeaderSubNav } from './HeaderSubNav'; /** * A component that renders a toolbar. @@ -25,7 +24,7 @@ import { HeaderSubNav } from './HeaderSubNav'; * @public */ export const Header = (props: HeaderProps) => { - const { tabs, icon, name, options, breadcrumbs, subNavigation } = props; + const { tabs, icon, name, options, breadcrumbs } = props; return ( <> @@ -36,7 +35,6 @@ export const Header = (props: HeaderProps) => { breadcrumbs={breadcrumbs} /> - ); }; diff --git a/packages/canon/src/components/Header/types.ts b/packages/canon/src/components/Header/types.ts index b8bcf179c9..b6a8eab779 100644 --- a/packages/canon/src/components/Header/types.ts +++ b/packages/canon/src/components/Header/types.ts @@ -27,12 +27,6 @@ export interface HeaderProps { tabs?: HeaderTab[]; options?: HeaderOption[]; breadcrumbs?: HeaderBreadcrumb[]; - subNavigation?: { - name?: string; - description?: string; - tabs?: HeaderTab[]; - options?: HeaderOption[]; - }; } /** diff --git a/packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx b/packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx new file mode 100644 index 0000000000..501eb715c4 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/HeaderPage.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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) => ( + <> +
+
+ +
+
+ + ), +]; + +export const Default: Story = { + args: { + name: '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', + }, +}; diff --git a/packages/canon/src/components/HeaderPage/HeaderPage.styles.css b/packages/canon/src/components/HeaderPage/HeaderPage.styles.css new file mode 100644 index 0000000000..94adcafa53 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/HeaderPage.styles.css @@ -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. + */ diff --git a/packages/canon/src/components/Header/HeaderSubNav.tsx b/packages/canon/src/components/HeaderPage/HeaderPage.tsx similarity index 81% rename from packages/canon/src/components/Header/HeaderSubNav.tsx rename to packages/canon/src/components/HeaderPage/HeaderPage.tsx index 90f2afb73a..987ea14783 100644 --- a/packages/canon/src/components/Header/HeaderSubNav.tsx +++ b/packages/canon/src/components/HeaderPage/HeaderPage.tsx @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { HeaderProps } from './types'; + +import type { HeaderPageProps } from './types'; import { Heading } from '../Heading'; import { Text } from '../Text'; import { Flex } from '../Flex'; @@ -21,19 +22,22 @@ 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; +/** + * A component that renders a header page. + * + * @public + */ +export const HeaderPage = (props: HeaderPageProps) => { + const { name, description, options } = props; return ( - {subNavigation.name} - {subNavigation.description} + {name} + {description}
- {subNavigation.options && ( + {options && ( ( @@ -48,7 +52,7 @@ export const HeaderSubNav = (props: Pick) => { - {subNavigation.options.map(option => ( + {options.map(option => ( option.onClick?.()} diff --git a/packages/canon/src/components/HeaderPage/index.tsx b/packages/canon/src/components/HeaderPage/index.tsx new file mode 100644 index 0000000000..3ce5e08d05 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/index.tsx @@ -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 * from './HeaderPage'; +export * from './types'; diff --git a/packages/canon/src/components/HeaderPage/types.ts b/packages/canon/src/components/HeaderPage/types.ts new file mode 100644 index 0000000000..722f069478 --- /dev/null +++ b/packages/canon/src/components/HeaderPage/types.ts @@ -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 { + name?: 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; +} diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 5fbacdc6f5..98eb4ff566 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -28,6 +28,7 @@ @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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 88063eab3c..61fdb5e8cd 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -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'; From 3a356d672bb69e0cb95f961ae760feba167b8667 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 2 Jul 2025 14:44:54 +0100 Subject: [PATCH 12/13] name -> title Signed-off-by: Charles de Dreuille --- packages/canon/report.api.md | 8 ++++---- packages/canon/src/components/Header/Header.tsx | 4 ++-- packages/canon/src/components/Header/HeaderToolbar.tsx | 4 ++-- packages/canon/src/components/Header/types.ts | 2 +- .../src/components/HeaderPage/HeaderPage.stories.tsx | 2 +- packages/canon/src/components/HeaderPage/HeaderPage.tsx | 4 ++-- packages/canon/src/components/HeaderPage/types.ts | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 0d7dec2d83..1fbc259a9e 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -988,11 +988,11 @@ export interface HeaderPageProps { // (undocumented) description?: string; // (undocumented) - name?: string; - // (undocumented) options?: HeaderPageOption[]; // (undocumented) tabs?: HeaderPageTab[]; + // (undocumented) + title?: string; } // @public @@ -1010,11 +1010,11 @@ export interface HeaderProps { // (undocumented) icon?: React.ReactNode; // (undocumented) - name?: string; - // (undocumented) options?: HeaderOption[]; // (undocumented) tabs?: HeaderTab[]; + // (undocumented) + title?: string; } // @public diff --git a/packages/canon/src/components/Header/Header.tsx b/packages/canon/src/components/Header/Header.tsx index 9ea06ab98d..9d7e65a6d1 100644 --- a/packages/canon/src/components/Header/Header.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -24,13 +24,13 @@ import { HeaderTabs } from './HeaderTabs'; * @public */ export const Header = (props: HeaderProps) => { - const { tabs, icon, name, options, breadcrumbs } = props; + const { tabs, icon, title, options, breadcrumbs } = props; return ( <> diff --git a/packages/canon/src/components/Header/HeaderToolbar.tsx b/packages/canon/src/components/Header/HeaderToolbar.tsx index aa31387f57..679c9ce489 100644 --- a/packages/canon/src/components/Header/HeaderToolbar.tsx +++ b/packages/canon/src/components/Header/HeaderToolbar.tsx @@ -29,7 +29,7 @@ import { motion, useScroll, useTransform } from 'motion/react'; * @public */ export const HeaderToolbar = (props: HeaderProps) => { - const { icon, name, options, breadcrumbs } = props; + const { icon, title, options, breadcrumbs } = props; const { classNames } = useStyles('Header'); const { scrollY } = useScroll(); @@ -94,7 +94,7 @@ export const HeaderToolbar = (props: HeaderProps) => {
{icon || }
- {name || 'Your plugin'} + {title || 'Your plugin'}
{breadcrumbs && ( { - const { name, description, options } = props; + const { title, description, options } = props; return ( - {name} + {title} {description}
diff --git a/packages/canon/src/components/HeaderPage/types.ts b/packages/canon/src/components/HeaderPage/types.ts index 722f069478..8c146b4596 100644 --- a/packages/canon/src/components/HeaderPage/types.ts +++ b/packages/canon/src/components/HeaderPage/types.ts @@ -20,7 +20,7 @@ * @public */ export interface HeaderPageProps { - name?: string; + title?: string; description?: string; tabs?: HeaderPageTab[]; options?: HeaderPageOption[]; From 32d090ec92a5b3903ecae3eb9e28766d361cdef0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 3 Jul 2025 17:28:40 +0100 Subject: [PATCH 13/13] Improve exports Signed-off-by: Charles de Dreuille --- packages/canon/report.api.md | 28 ------------------- .../src/components/Header/HeaderTabs.tsx | 4 +-- .../Header/HeaderTabsIndicators.tsx | 2 +- .../src/components/Header/HeaderToolbar.tsx | 2 +- .../canon/src/components/Header/index.tsx | 10 +++++-- packages/canon/src/components/Header/types.ts | 5 ---- .../canon/src/components/HeaderPage/index.tsx | 4 +-- 7 files changed, 13 insertions(+), 42 deletions(-) diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 1fbc259a9e..1070193649 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -20,7 +20,6 @@ import { HTMLAttributes } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { LinkProps as LinkProps_2 } from 'react-aria-components'; import { Menu as Menu_2 } from '@base-ui-components/react/menu'; -import { MutableRefObject } from 'react'; import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components'; import type { RadioProps as RadioProps_2 } from 'react-aria-components'; import { ReactElement } from 'react'; @@ -948,18 +947,6 @@ export interface HeaderBreadcrumb { label: string; } -// @public -export interface HeaderIndicatorsProps { - // (undocumented) - hoveredKey: string | null; - // (undocumented) - prevHoveredKey: MutableRefObject; - // (undocumented) - tabRefs: MutableRefObject>; - // (undocumented) - tabsRef: MutableRefObject; -} - // @public export interface HeaderOption { // (undocumented) @@ -1025,21 +1012,6 @@ export interface HeaderTab { label: string; } -// @public -export interface HeaderTabProps { - // (undocumented) - setHoveredKey: (key: string | null) => void; - // (undocumented) - setTabRef: (key: string, element: HTMLDivElement | null) => void; - // (undocumented) - tab: HeaderTab; -} - -// @public -export const HeaderTabsIndicators: ( - props: HeaderIndicatorsProps, -) => JSX_2.Element; - // @public (undocumented) export const Heading: ( props: HeadingProps & { diff --git a/packages/canon/src/components/Header/HeaderTabs.tsx b/packages/canon/src/components/Header/HeaderTabs.tsx index 90c2aab33e..b83e27e83d 100644 --- a/packages/canon/src/components/Header/HeaderTabs.tsx +++ b/packages/canon/src/components/Header/HeaderTabs.tsx @@ -23,7 +23,7 @@ import type { HeaderProps, HeaderTabProps } from './types'; /** * A component that renders header tabs. * - * @public + * @internal */ export const HeaderTabs = (props: HeaderProps) => { const { tabs } = props; @@ -70,7 +70,7 @@ export const HeaderTabs = (props: HeaderProps) => { /** * A component that renders a toolbar tab. * - * @public + * @internal */ const Tab = (props: HeaderTabProps) => { const { tab, setTabRef, setHoveredKey } = props; diff --git a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx index 8018a72e98..41de56574e 100644 --- a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx +++ b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx @@ -22,7 +22,7 @@ import type { HeaderIndicatorsProps } from './types'; /** * A component that renders the indicators for the toolbar. * - * @public + * @internal */ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; diff --git a/packages/canon/src/components/Header/HeaderToolbar.tsx b/packages/canon/src/components/Header/HeaderToolbar.tsx index 679c9ce489..95c01e1c88 100644 --- a/packages/canon/src/components/Header/HeaderToolbar.tsx +++ b/packages/canon/src/components/Header/HeaderToolbar.tsx @@ -26,7 +26,7 @@ import { motion, useScroll, useTransform } from 'motion/react'; /** * A component that renders a toolbar. * - * @public + * @internal */ export const HeaderToolbar = (props: HeaderProps) => { const { icon, title, options, breadcrumbs } = props; diff --git a/packages/canon/src/components/Header/index.tsx b/packages/canon/src/components/Header/index.tsx index 9d62b8eca0..ac55e27fa1 100644 --- a/packages/canon/src/components/Header/index.tsx +++ b/packages/canon/src/components/Header/index.tsx @@ -14,6 +14,10 @@ * limitations under the License. */ -export * from './Header'; -export * from './HeaderTabsIndicators'; -export * from './types'; +export { Header } from './Header'; +export type { + HeaderProps, + HeaderTab, + HeaderOption, + HeaderBreadcrumb, +} from './types'; diff --git a/packages/canon/src/components/Header/types.ts b/packages/canon/src/components/Header/types.ts index ade1c814db..6cdd51b7d6 100644 --- a/packages/canon/src/components/Header/types.ts +++ b/packages/canon/src/components/Header/types.ts @@ -71,11 +71,6 @@ export interface HeaderTabProps { setHoveredKey: (key: string | null) => void; } -/** - * Props for the header indicators component that handles tab highlighting and animation. - * - * @public - */ export interface HeaderIndicatorsProps { tabRefs: MutableRefObject>; tabsRef: MutableRefObject; diff --git a/packages/canon/src/components/HeaderPage/index.tsx b/packages/canon/src/components/HeaderPage/index.tsx index 3ce5e08d05..c90a0a93f4 100644 --- a/packages/canon/src/components/HeaderPage/index.tsx +++ b/packages/canon/src/components/HeaderPage/index.tsx @@ -14,5 +14,5 @@ * limitations under the License. */ -export * from './HeaderPage'; -export * from './types'; +export { HeaderPage } from './HeaderPage'; +export type { HeaderPageProps, HeaderPageTab, HeaderPageOption } from './types';