Attempt to use hooks for tabs

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-07-09 19:07:47 +02:00
parent 175ebf7e65
commit dcc1dd9145
7 changed files with 195 additions and 52 deletions
+4 -4
View File
@@ -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": {
@@ -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 => (
<MemoryRouter initialEntries={['/campaigns']}>
<Header {...args} />
<div style={{ padding: '20px' }}>
<p>
Current URL is mocked to be: <strong>/campaigns</strong>
</p>
<p>
Notice how the "Campaigns" tab is selected (highlighted) because it
matches the current path.
</p>
</div>
</MemoryRouter>
),
};
export const WithMockedURLIntegrations: Story = {
args: {
tabs,
},
render: args => (
<MemoryRouter initialEntries={['/integrations']}>
<Header {...args} />
<div style={{ padding: '20px' }}>
<p>
Current URL is mocked to be: <strong>/integrations</strong>
</p>
<p>
Notice how the "Integrations" tab is selected (highlighted) because it
matches the current path.
</p>
</div>
</MemoryRouter>
),
};
export const WithMockedURLNoMatch: Story = {
args: {
tabs,
},
render: args => (
<MemoryRouter initialEntries={['/some-other-page']}>
<Header {...args} />
<div style={{ padding: '20px' }}>
<p>
Current URL is mocked to be: <strong>/some-other-page</strong>
</p>
<p>
No tab is selected because the current path doesn't match any tab's
href.
</p>
<p>
Tabs without href (like "Overview", "Checks", "Tracks") fall back to
React Aria's internal state.
</p>
</div>
</MemoryRouter>
),
};
@@ -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}
/>
<HeaderTabs tabs={tabs} />
{tabs && (
<HeaderTabs keyboardActivation="manual">
{tabs.map(tab => (
<Item key={tab.label} title={tab.label} href={tab.href}>
{tab.label}
</Item>
))}
</HeaderTabs>
)}
</>
);
};
@@ -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<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const prevHoveredKey = useRef<string | null>(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 className={classNames.tabs} ref={tabsRef} keyboardActivation="manual">
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
{tabs.map((tab, index) => {
return (
<Tab
key={index}
tab={tab}
setTabRef={setTabRef}
setHoveredKey={setHoveredKey}
/>
);
})}
</TabList>
<div className={classNames.tabs} ref={tabsRef}>
<div className={classNames.tabList} {...tabListProps} ref={ref}>
{Array.from(state.collection).map(item => (
<Tab
key={item.key}
item={item}
state={state}
setTabRef={setTabRef}
setHoveredKey={setHoveredKey}
/>
))}
</div>
<HeaderTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
state={state}
/>
</Tabs>
</div>
);
};
@@ -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<HTMLDivElement>(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<HTMLDivElement | null>).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 (
<AriaTab
id={id}
<div
className={classNames.tab}
ref={el => 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}
</AriaTab>
{rendered}
</div>
);
};
}
@@ -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();
+14 -1
View File
@@ -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<object>;
state: TabListState<object>;
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<TabListProps<object>, 'children'> {
children?: CollectionChildren<object>;
keyboardActivation?: 'automatic' | 'manual';
}
/** @internal */
export interface HeaderIndicatorsProps {
tabRefs: MutableRefObject<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;
hoveredKey: string | null;
prevHoveredKey: MutableRefObject<string | null>;
state: TabListState<object>;
}
+41 -6
View File
@@ -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