From dcc1dd9145a533b2bbce1dfb799bd3cd1c34ca83 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 9 Jul 2025 19:07:47 +0200 Subject: [PATCH] Attempt to use hooks for tabs Signed-off-by: Charles de Dreuille --- packages/canon/package.json | 8 +- .../src/components/Header/Header.stories.tsx | 67 +++++++++++++++ .../canon/src/components/Header/Header.tsx | 11 ++- .../src/components/Header/HeaderTabs.tsx | 84 +++++++++++-------- .../Header/HeaderTabsIndicators.tsx | 15 ++-- packages/canon/src/components/Header/types.ts | 15 +++- yarn.lock | 47 +++++++++-- 7 files changed, 195 insertions(+), 52 deletions(-) diff --git a/packages/canon/package.json b/packages/canon/package.json index ccc23c55ed..1ecf89f232 100644 --- a/packages/canon/package.json +++ b/packages/canon/package.json @@ -46,7 +46,9 @@ "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", "motion": "^12.20.1", - "react-aria-components": "^1.10.1" + "react-aria-components": "^1.10.1", + "react-router-dom": "^7.6.3", + "react-stately": "^3.39.0" }, "devDependencies": { "@backstage/cli": "workspace:^", @@ -68,14 +70,12 @@ "mini-css-extract-plugin": "^2.9.2", "react": "^18.0.2", "react-dom": "^18.0.2", - "react-router-dom": "^6.3.0", "storybook": "^8.6.12" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0", - "react-router-dom": "^6.3.0" + "react-dom": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { "@types/react": { diff --git a/packages/canon/src/components/Header/Header.stories.tsx b/packages/canon/src/components/Header/Header.stories.tsx index 56493a88e8..411dda14ca 100644 --- a/packages/canon/src/components/Header/Header.stories.tsx +++ b/packages/canon/src/components/Header/Header.stories.tsx @@ -19,6 +19,7 @@ import { Header } from './Header'; import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types'; import { Button } from '../Button'; import { HeaderPage } from '../HeaderPage'; +import { MemoryRouter } from 'react-router-dom'; const meta = { title: 'Components/Header', @@ -40,9 +41,11 @@ const tabs: HeaderTab[] = [ }, { label: 'Campaigns', + href: '/campaigns', }, { label: 'Integrations', + href: '/integrations', }, ]; @@ -206,3 +209,67 @@ export const WithLayoutAndHeaderPageNoTabs: Story = { ), }; + +export const WithMockedURLCampaigns: Story = { + args: { + tabs, + }, + render: args => ( + +
+
+

+ Current URL is mocked to be: /campaigns +

+

+ Notice how the "Campaigns" tab is selected (highlighted) because it + matches the current path. +

+
+ + ), +}; + +export const WithMockedURLIntegrations: Story = { + args: { + tabs, + }, + render: args => ( + +
+
+

+ Current URL is mocked to be: /integrations +

+

+ Notice how the "Integrations" tab is selected (highlighted) because it + matches the current path. +

+
+ + ), +}; + +export const WithMockedURLNoMatch: Story = { + args: { + tabs, + }, + render: args => ( + +
+
+

+ Current URL is mocked to be: /some-other-page +

+

+ No tab is selected because the current path doesn't match any tab's + href. +

+

+ Tabs without href (like "Overview", "Checks", "Tracks") fall back to + React Aria's internal state. +

+
+ + ), +}; diff --git a/packages/canon/src/components/Header/Header.tsx b/packages/canon/src/components/Header/Header.tsx index 7f2f72d438..446c84d6f7 100644 --- a/packages/canon/src/components/Header/Header.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -17,6 +17,7 @@ import type { HeaderProps } from './types'; import { HeaderToolbar } from './HeaderToolbar'; import { HeaderTabs } from './HeaderTabs'; +import { Item } from 'react-stately'; /** * A component that renders a toolbar. @@ -38,7 +39,15 @@ export const Header = (props: HeaderProps) => { customActions={customActions} hasTabs={hasTabs} /> - + {tabs && ( + + {tabs.map(tab => ( + + {tab.label} + + ))} + + )} ); }; diff --git a/packages/canon/src/components/Header/HeaderTabs.tsx b/packages/canon/src/components/Header/HeaderTabs.tsx index b83e27e83d..ac201702ac 100644 --- a/packages/canon/src/components/Header/HeaderTabs.tsx +++ b/packages/canon/src/components/Header/HeaderTabs.tsx @@ -14,25 +14,30 @@ * limitations under the License. */ -import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; +import { useTabList, useTab } from 'react-aria'; +import { useTabListState } from 'react-stately'; +import { useLocation } from 'react-router-dom'; import { useStyles } from '../../hooks/useStyles'; -import { useId, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { HeaderTabsIndicators } from './HeaderTabsIndicators'; -import type { HeaderProps, HeaderTabProps } from './types'; +import type { HeaderTabProps, HeaderTabsProps } from './types'; /** * A component that renders header tabs. * * @internal */ -export const HeaderTabs = (props: HeaderProps) => { - const { tabs } = props; +export const HeaderTabs = (props: HeaderTabsProps) => { const { classNames } = useStyles('Header'); const tabsRef = useRef(null); const tabRefs = useRef>(new Map()); const [hoveredKey, setHoveredKey] = useState(null); const prevHoveredKey = useRef(null); + let state = useTabListState(props); + let ref = useRef(null); + const { tabListProps } = useTabList({}, state, ref); + const setTabRef = (key: string, element: HTMLDivElement | null) => { if (element) { tabRefs.current.set(key, element); @@ -41,29 +46,27 @@ export const HeaderTabs = (props: HeaderProps) => { } }; - if (!tabs) return null; - return ( - - - {tabs.map((tab, index) => { - return ( - - ); - })} - +
+
+ {Array.from(state.collection).map(item => ( + + ))} +
- +
); }; @@ -72,21 +75,36 @@ export const HeaderTabs = (props: HeaderProps) => { * * @internal */ -const Tab = (props: HeaderTabProps) => { - const { tab, setTabRef, setHoveredKey } = props; - const id = useId(); +function Tab({ item, state, setTabRef, setHoveredKey }: HeaderTabProps) { + let { key, rendered } = item; const { classNames } = useStyles('Header'); + const location = useLocation(); + + let ref = useRef(null); + let { tabProps } = useTab({ key }, state, ref); + + const setRef = (el: HTMLDivElement | null) => { + // Set the ref for React Aria - use more specific type assertion + (ref as React.MutableRefObject).current = el; + // Set the ref for tracking tab elements + setTabRef(key.toString(), el); + }; + + // Check if the current path matches the tab's href + const isSelected = item.props?.href + ? location.pathname === item.props.href + : state.selectedKey === key; return ( - setTabRef(id, el as HTMLDivElement)} - onHoverStart={() => setHoveredKey(id)} - onHoverEnd={() => setHoveredKey(null)} - href={tab.href} + ref={setRef} + data-selected={isSelected} + onMouseEnter={() => setHoveredKey(key.toString())} + onMouseLeave={() => setHoveredKey(null)} + {...tabProps} > - {tab.label} - + {rendered} + ); -}; +} diff --git a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx index 41de56574e..92e8cf92c3 100644 --- a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx +++ b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx @@ -14,27 +14,26 @@ * limitations under the License. */ -import { TabListStateContext } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; -import { useContext, useEffect, useCallback } from 'react'; +import { useEffect, useCallback } from 'react'; import type { HeaderIndicatorsProps } from './types'; /** * A component that renders the indicators for the toolbar. + * Uses React Aria's TabListState passed as a prop. * * @internal */ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { - const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; + const { tabRefs, tabsRef, hoveredKey, prevHoveredKey, state } = props; const { classNames } = useStyles('Header'); - const state = useContext(TabListStateContext); const updateCSSVariables = useCallback(() => { if (!tabsRef.current) return; const tabsRect = tabsRef.current.getBoundingClientRect(); - // Set active tab variables + // Set active tab variables using React Aria's selectedKey from state if (state?.selectedKey) { const activeTab = tabRefs.current.get(state.selectedKey.toString()); @@ -43,6 +42,7 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { const relativeLeft = activeRect.left - tabsRect.left; const relativeTop = activeRect.top - tabsRect.top; + // Set CSS variables for the active tab indicator position tabsRef.current.style.setProperty( '--active-tab-left', `${relativeLeft}px`, @@ -70,7 +70,7 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { } } - // Set hovered tab variables + // Set hovered tab variables (separate from React Aria state) if (hoveredKey) { const hoveredTab = tabRefs.current.get(hoveredKey); if (hoveredTab) { @@ -102,6 +102,7 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { '--hovered-tab-height', `${hoveredRect.height}px`, ); + // Control transition timing based on whether this is a new hover session const isNewHoverSession = prevHoveredKey.current === null; @@ -140,7 +141,7 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { // Reset previous hover key so next hover is treated as new session prevHoveredKey.current = null; } - }, [state?.selectedKey, hoveredKey]); + }, [state?.selectedKey, hoveredKey]); // React Aria's selectedKey drives active tab updates useEffect(() => { updateCSSVariables(); diff --git a/packages/canon/src/components/Header/types.ts b/packages/canon/src/components/Header/types.ts index a76fd560dc..79f1579d08 100644 --- a/packages/canon/src/components/Header/types.ts +++ b/packages/canon/src/components/Header/types.ts @@ -15,6 +15,8 @@ */ import { MutableRefObject } from 'react'; +import { TabListProps, TabListState } from '@react-stately/tabs'; +import { CollectionChildren, Node } from '@react-types/shared'; /** * Props for the main Header component. @@ -67,11 +69,13 @@ export interface HeaderBreadcrumb { * @public */ export interface HeaderTabProps { - tab: HeaderTab; + item: Node; + state: TabListState; setTabRef: (key: string, element: HTMLDivElement | null) => void; setHoveredKey: (key: string | null) => void; } +/** @internal */ export interface HeaderToolbarProps { icon?: HeaderProps['icon']; title?: HeaderProps['title']; @@ -81,9 +85,18 @@ export interface HeaderToolbarProps { hasTabs?: boolean; } +/** @internal */ +export interface HeaderTabsProps + extends Omit, 'children'> { + children?: CollectionChildren; + keyboardActivation?: 'automatic' | 'manual'; +} + +/** @internal */ export interface HeaderIndicatorsProps { tabRefs: MutableRefObject>; tabsRef: MutableRefObject; hoveredKey: string | null; prevHoveredKey: MutableRefObject; + state: TabListState; } diff --git a/yarn.lock b/yarn.lock index 53b3bfcacf..e067769ad4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3840,13 +3840,13 @@ __metadata: react: "npm:^18.0.2" react-aria-components: "npm:^1.10.1" react-dom: "npm:^18.0.2" - react-router-dom: "npm:^6.3.0" + react-router-dom: "npm:^7.6.3" + react-stately: "npm:^3.39.0" storybook: "npm:^8.6.12" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 - react-router-dom: ^6.3.0 peerDependenciesMeta: "@types/react": optional: true @@ -28632,6 +28632,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^1.0.1": + version: 1.0.2 + resolution: "cookie@npm:1.0.2" + checksum: 10/f5817cdc84d8977761b12549eba29435e675e65c7fef172bc31737788cd8adc83796bf8abe6d950554e7987325ad2d9ac2971c5bd8ff0c4f81c145f82e4ab1be + languageName: node + linkType: hard + "cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" @@ -45111,6 +45118,18 @@ __metadata: languageName: node linkType: hard +"react-router-dom@npm:^7.6.3": + version: 7.6.3 + resolution: "react-router-dom@npm:7.6.3" + dependencies: + react-router: "npm:7.6.3" + peerDependencies: + react: ">=18" + react-dom: ">=18" + checksum: 10/b6e6a4dd696c7c0cf9f1f6ce88d388013a7f71d8fd6267968979ad473f222796f75506068786e47c6911f576f8946ad37a324642310fc8987e3cd898875aac3e + languageName: node + linkType: hard + "react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.8.1": version: 6.8.1 resolution: "react-router@npm:6.8.1" @@ -45133,6 +45152,22 @@ __metadata: languageName: node linkType: hard +"react-router@npm:7.6.3": + version: 7.6.3 + resolution: "react-router@npm:7.6.3" + dependencies: + cookie: "npm:^1.0.1" + set-cookie-parser: "npm:^2.6.0" + peerDependencies: + react: ">=18" + react-dom: ">=18" + peerDependenciesMeta: + react-dom: + optional: true + checksum: 10/c970729d17e705d793ffe602cc034274c6064f514fe401ee03e37c4c27baa70be04367bbf6242f9a1c7777a19e4cb67f5a024954224622ce6bee2cefc52e0b90 + languageName: node + linkType: hard + "react-side-effect@npm:^2.1.0": version: 2.1.0 resolution: "react-side-effect@npm:2.1.0" @@ -46980,10 +47015,10 @@ __metadata: languageName: node linkType: hard -"set-cookie-parser@npm:^2.4.6": - version: 2.6.0 - resolution: "set-cookie-parser@npm:2.6.0" - checksum: 10/8d451ebadb760989f93b634942c79de3c925ca7a986d133d08a80c40b5ae713ce12e354f0d5245c49f288c52daa7bd6554d5dc52f8a4eecaaf5e192881cf2b1f +"set-cookie-parser@npm:^2.4.6, set-cookie-parser@npm:^2.6.0": + version: 2.7.1 + resolution: "set-cookie-parser@npm:2.7.1" + checksum: 10/c92b1130032693342bca13ea1b1bc93967ab37deec4387fcd8c2a843c0ef2fd9a9f3df25aea5bb3976cd05a91c2cf4632dd6164d6e1814208fb7d7e14edd42b4 languageName: node linkType: hard