Attempt to use hooks for tabs
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user