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