Improve activeTab

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-07-10 00:37:33 +02:00
parent dcc1dd9145
commit 6864330dd8
6 changed files with 122 additions and 91 deletions
@@ -20,6 +20,8 @@ import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types';
import { Button } from '../Button';
import { HeaderPage } from '../HeaderPage';
import { MemoryRouter } from 'react-router-dom';
import { Container } from '../Container';
import { Text } from '../Text';
const meta = {
title: 'Components/Header',
@@ -29,21 +31,32 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
const tabs: HeaderTab[] = [
{
id: 'overview',
label: 'Overview',
},
{
id: 'checks',
label: 'Checks',
},
{
id: 'tracks',
label: 'Tracks',
},
{
id: 'campaigns',
label: 'Campaigns',
href: '/campaigns',
},
{
id: 'integrations',
label: 'Integrations',
href: '/integrations',
},
@@ -117,34 +130,40 @@ const layoutDecorator = [
/>
</>
),
withRouter,
];
export const Default: Story = {
args: {},
decorators: [withRouter],
};
export const WithTabs: Story = {
args: {
tabs,
},
decorators: [withRouter],
};
export const WithOptions: Story = {
args: {
menuItems,
},
decorators: [withRouter],
};
export const WithCustomActions: Story = {
args: {
customActions: <Button>Custom action</Button>,
},
decorators: [withRouter],
};
export const WithBreadcrumbs: Story = {
args: {
breadcrumbs,
},
decorators: [withRouter],
};
export const WithAllComponents: Story = {
@@ -153,6 +172,7 @@ export const WithAllComponents: Story = {
tabs,
breadcrumbs,
},
decorators: [withRouter],
};
export const WithLayout: Story = {
@@ -217,15 +237,15 @@ export const WithMockedURLCampaigns: Story = {
render: args => (
<MemoryRouter initialEntries={['/campaigns']}>
<Header {...args} />
<div style={{ padding: '20px' }}>
<p>
<Container>
<Text>
Current URL is mocked to be: <strong>/campaigns</strong>
</p>
<p>
</Text>
<Text>
Notice how the "Campaigns" tab is selected (highlighted) because it
matches the current path.
</p>
</div>
</Text>
</Container>
</MemoryRouter>
),
};
@@ -133,6 +133,7 @@
--active-tab-bottom: 0px;
--active-tab-width: 0px;
--active-tab-height: 0px;
--active-transition-duration: 0s;
--hovered-tab-left: 0px;
--hovered-tab-right: 0px;
@@ -175,7 +176,8 @@
height: 1px;
background-color: var(--canon-fg-primary);
border-radius: 4px;
transition: all 0.25s ease-out;
transition: left var(--active-transition-duration) ease-out,
opacity 0.15s ease-out;
opacity: 1;
}
+12 -12
View File
@@ -17,7 +17,14 @@
import type { HeaderProps } from './types';
import { HeaderToolbar } from './HeaderToolbar';
import { HeaderTabs } from './HeaderTabs';
import { Item } from 'react-stately';
import { RouterProvider } from 'react-aria-components';
import { type NavigateOptions, useHref, useNavigate } from 'react-router-dom';
declare module 'react-aria-components' {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}
/**
* A component that renders a toolbar.
@@ -26,11 +33,12 @@ import { Item } from 'react-stately';
*/
export const Header = (props: HeaderProps) => {
const { tabs, icon, title, menuItems, breadcrumbs, customActions } = props;
let navigate = useNavigate();
const hasTabs = tabs && tabs.length > 0;
return (
<>
<RouterProvider navigate={navigate} useHref={useHref}>
<HeaderToolbar
icon={icon}
title={title}
@@ -39,15 +47,7 @@ export const Header = (props: HeaderProps) => {
customActions={customActions}
hasTabs={hasTabs}
/>
{tabs && (
<HeaderTabs keyboardActivation="manual">
{tabs.map(tab => (
<Item key={tab.label} title={tab.label} href={tab.href}>
{tab.label}
</Item>
))}
</HeaderTabs>
)}
</>
<HeaderTabs tabs={tabs} />
</RouterProvider>
);
};
@@ -14,29 +14,28 @@
* limitations under the License.
*/
import { useTabList, useTab } from 'react-aria';
import { useTabListState } from 'react-stately';
import { useLocation } from 'react-router-dom';
import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useRef, useState } from 'react';
import { HeaderTabsIndicators } from './HeaderTabsIndicators';
import type { HeaderTabProps, HeaderTabsProps } from './types';
import type { HeaderProps, HeaderTabProps } from './types';
import { useLocation } from 'react-router-dom';
/**
* A component that renders header tabs.
*
* @internal
*/
export const HeaderTabs = (props: HeaderTabsProps) => {
export const HeaderTabs = (props: HeaderProps) => {
const { tabs } = props;
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);
const location = useLocation();
let state = useTabListState(props);
let ref = useRef(null);
const { tabListProps } = useTabList({}, state, ref);
const selectedKey = tabs?.find(tab => tab.href === location.pathname)?.id;
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
@@ -46,27 +45,35 @@ export const HeaderTabs = (props: HeaderTabsProps) => {
}
};
if (!tabs) return null;
return (
<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>
<Tabs
className={classNames.tabs}
ref={tabsRef}
keyboardActivation="manual"
selectedKey={selectedKey}
>
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
{tabs.map((tab, index) => {
return (
<Tab
id={tab.id}
key={index}
tab={tab}
setTabRef={setTabRef}
setHoveredKey={setHoveredKey}
/>
);
})}
</TabList>
<HeaderTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
state={state}
/>
</div>
</Tabs>
);
};
@@ -75,36 +82,20 @@ export const HeaderTabs = (props: HeaderTabsProps) => {
*
* @internal
*/
function Tab({ item, state, setTabRef, setHoveredKey }: HeaderTabProps) {
let { key, rendered } = item;
const Tab = (props: HeaderTabProps) => {
const { tab, setTabRef, setHoveredKey, id } = props;
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 (
<div
<AriaTab
id={id}
className={classNames.tab}
ref={setRef}
data-selected={isSelected}
onMouseEnter={() => setHoveredKey(key.toString())}
onMouseLeave={() => setHoveredKey(null)}
{...tabProps}
ref={el => setTabRef(id, el as HTMLDivElement)}
onHoverStart={() => setHoveredKey(id)}
onHoverEnd={() => setHoveredKey(null)}
href={tab.href}
>
{rendered}
</div>
{tab.label}
</AriaTab>
);
}
};
@@ -14,26 +14,28 @@
* limitations under the License.
*/
import { TabListStateContext } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useEffect, useCallback } from 'react';
import { useContext, useEffect, useCallback, useRef } 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, state } = props;
const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props;
const { classNames } = useStyles('Header');
const state = useContext(TabListStateContext);
const prevSelectedKey = useRef<string | null>(null);
const updateCSSVariables = useCallback(() => {
if (!tabsRef.current) return;
const tabsRect = tabsRef.current.getBoundingClientRect();
// Set active tab variables using React Aria's selectedKey from state
// Set active tab variables
if (state?.selectedKey) {
const activeTab = tabRefs.current.get(state.selectedKey.toString());
@@ -42,7 +44,35 @@ 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
// Control transition timing based on whether this is the first time setting active tab
const isFirstActiveTab = prevSelectedKey.current === null;
if (isFirstActiveTab) {
// First time setting active tab: no transitions for position
tabsRef.current.style.setProperty(
'--active-transition-duration',
'0s',
);
// Enable transitions on next frame for future tab switches
requestAnimationFrame(() => {
if (tabsRef.current) {
tabsRef.current.style.setProperty(
'--active-transition-duration',
'0.25s',
);
}
});
} else {
// Switching between tabs: full transitions
tabsRef.current.style.setProperty(
'--active-transition-duration',
'0.25s',
);
}
// Update previous selected key for next time
prevSelectedKey.current = state.selectedKey.toString();
tabsRef.current.style.setProperty(
'--active-tab-left',
`${relativeLeft}px`,
@@ -70,7 +100,7 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
}
}
// Set hovered tab variables (separate from React Aria state)
// Set hovered tab variables
if (hoveredKey) {
const hoveredTab = tabRefs.current.get(hoveredKey);
if (hoveredTab) {
@@ -102,7 +132,6 @@ 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;
@@ -141,11 +170,11 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => {
// Reset previous hover key so next hover is treated as new session
prevHoveredKey.current = null;
}
}, [state?.selectedKey, hoveredKey]); // React Aria's selectedKey drives active tab updates
}, [state?.selectedKey, hoveredKey, tabRefs.current]);
useEffect(() => {
updateCSSVariables();
}, [updateCSSVariables]);
}, [updateCSSVariables, tabRefs.current.size]);
useEffect(() => {
const handleResize = () => updateCSSVariables();
+3 -14
View File
@@ -15,8 +15,6 @@
*/
import { MutableRefObject } from 'react';
import { TabListProps, TabListState } from '@react-stately/tabs';
import { CollectionChildren, Node } from '@react-types/shared';
/**
* Props for the main Header component.
@@ -38,6 +36,7 @@ export interface HeaderProps {
* @public
*/
export interface HeaderTab {
id: string;
label: string;
href?: string;
}
@@ -69,13 +68,12 @@ export interface HeaderBreadcrumb {
* @public
*/
export interface HeaderTabProps {
item: Node<object>;
state: TabListState<object>;
id: string;
tab: HeaderTab;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
setHoveredKey: (key: string | null) => void;
}
/** @internal */
export interface HeaderToolbarProps {
icon?: HeaderProps['icon'];
title?: HeaderProps['title'];
@@ -85,18 +83,9 @@ 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>;
}