Add support for tab matching strategy

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-07-22 10:26:20 +01:00
parent 68d2df9084
commit f2ac6d3631
11 changed files with 680 additions and 34 deletions
+10 -1
View File
@@ -28,7 +28,7 @@ import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
import { Table as Table_2 } from '@tanstack/react-table';
import type { TabListProps as TabListProps_2 } from 'react-aria-components';
import type { TabPanelProps as TabPanelProps_2 } from 'react-aria-components';
import { TabProps } from 'react-aria-components';
import type { TabProps as TabProps_2 } from 'react-aria-components';
import { TabsProps as TabsProps_2 } from 'react-aria-components';
import { TdHTMLAttributes } from 'react';
import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
@@ -1057,6 +1057,7 @@ export interface HeaderTab {
id: string;
// (undocumented)
label: string;
matchStrategy?: TabMatchStrategy;
}
// @public (undocumented)
@@ -1721,12 +1722,20 @@ export const TabList: (props: TabListProps) => JSX_2.Element;
// @public
export interface TabListProps extends Omit<TabListProps_2<object>, 'items'> {}
// @public
export type TabMatchStrategy = 'exact' | 'prefix';
// @public
export const TabPanel: (props: TabPanelProps) => JSX_2.Element;
// @public
export interface TabPanelProps extends TabPanelProps_2 {}
// @public
export interface TabProps extends TabProps_2 {
matchStrategy?: 'exact' | 'prefix';
}
// @public
export const Tabs: (props: TabsProps) => JSX_2.Element | null;
@@ -323,3 +323,154 @@ export const WithMockedURLNoMatch: Story = {
</MemoryRouter>
),
};
export const WithTabsMatchingStrategies: Story = {
args: {
title: 'Route Matching Demo',
tabs: [
{
id: 'home',
label: 'Home',
href: '/home',
},
{
id: 'mentorship',
label: 'Mentorship',
href: '/mentorship',
matchStrategy: 'prefix',
},
{
id: 'catalog',
label: 'Catalog',
href: '/catalog',
matchStrategy: 'prefix',
},
{
id: 'settings',
label: 'Settings',
href: '/settings',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
<strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
<strong>Mentorship</strong>: prefix matching - IS active (URL starts
with /mentorship)
</Text>
<Text>
<strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
<strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
</MemoryRouter>
),
};
export const WithTabsExactMatching: Story = {
args: {
title: 'Exact Matching Demo',
tabs: [
{
id: 'mentorship',
label: 'Mentorship',
href: '/mentorship',
},
{
id: 'events',
label: 'Events',
href: '/mentorship/events',
},
{
id: 'mentors',
label: 'Mentors',
href: '/mentorship/mentors',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not active
even though the URL is under /mentorship.
</Text>
</Container>
</MemoryRouter>
),
};
export const WithTabsPrefixMatchingDeep: Story = {
args: {
title: 'Deep Nesting Demo',
tabs: [
{
id: 'catalog',
label: 'Catalog',
href: '/catalog',
matchStrategy: 'prefix',
},
{
id: 'users',
label: 'Users',
href: '/catalog/users',
matchStrategy: 'prefix',
},
{
id: 'components',
label: 'Components',
href: '/catalog/components',
matchStrategy: 'prefix',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<Header {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text>Both "Catalog" and "Users" tabs are active because:</Text>
<Text>
<strong>Catalog</strong>: URL starts with /catalog
</Text>
<Text>
<strong>Users</strong>: URL starts with /catalog/users
</Text>
<Text>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</Text>
<br />
<Text>
This demonstrates how prefix matching works with deeply nested routes.
</Text>
</Container>
</MemoryRouter>
),
};
+6 -1
View File
@@ -63,7 +63,12 @@ export const Header = (props: HeaderProps) => {
<Tabs onSelectionChange={onTabSelectionChange}>
<TabList>
{tabs?.map(tab => (
<Tab key={tab.id} id={tab.id} href={tab.href}>
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
@@ -105,7 +105,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{' '}
<div className={classNames.toolbar} data-has-tabs={hasTabs}>
<div className={classNames.toolbarWrapper} ref={toolbarWrapperRef}>
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
@@ -15,6 +15,7 @@
*/
import { TabsProps } from 'react-aria-components';
import { TabMatchStrategy } from '../Tabs';
/**
* Props for the main Header component.
@@ -41,6 +42,12 @@ export interface HeaderTab {
id: string;
label: string;
href?: string;
/**
* Strategy for matching the current route to determine if this tab should be active.
* - 'exact': Tab href must exactly match the current pathname (default)
* - 'prefix': Tab is active if current pathname starts with tab href
*/
matchStrategy?: TabMatchStrategy;
}
/**
@@ -28,13 +28,6 @@ const meta = {
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof HeaderPage>;
export default meta;
@@ -74,6 +67,12 @@ const menuItems: HeaderMenuItem[] = [
},
];
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
// Extract layout decorator as a reusable constant
const layoutDecorator = [
(Story: StoryFn) => (
@@ -121,6 +120,7 @@ export const WithTabs: Story = {
...Default.args,
tabs,
},
decorators: [withRouter],
};
export const WithMenuItems: Story = {
@@ -141,6 +141,7 @@ export const WithCustomActions: Story = {
};
export const WithEverything: Story = {
decorators: [withRouter],
render: () => (
<HeaderPage
{...Default.args}
@@ -155,6 +156,157 @@ export const WithLayout: Story = {
args: {
...WithEverything.args,
},
decorators: layoutDecorator,
decorators: [withRouter, ...layoutDecorator],
render: WithEverything.render,
};
export const WithTabsMatchingStrategies: Story = {
args: {
title: 'Route Matching Demo',
tabs: [
{
id: 'home',
label: 'Home',
href: '/home',
},
{
id: 'mentorship',
label: 'Mentorship',
href: '/mentorship',
matchStrategy: 'prefix',
},
{
id: 'catalog',
label: 'Catalog',
href: '/catalog',
matchStrategy: 'prefix',
},
{
id: 'settings',
label: 'Settings',
href: '/settings',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<HeaderPage {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
Notice how the "Mentorship" tab is active even though we're on a
nested route. This is because it uses{' '}
<code>matchStrategy="prefix"</code>.
</Text>
<br />
<Text>
<strong>Home</strong>: exact matching (default) - not active
</Text>
<Text>
<strong>Mentorship</strong>: prefix matching - IS active (URL starts
with /mentorship)
</Text>
<Text>
<strong>Catalog</strong>: prefix matching - not active
</Text>
<Text>
<strong>Settings</strong>: exact matching (default) - not active
</Text>
</Container>
</MemoryRouter>
),
};
export const WithTabsExactMatching: Story = {
args: {
title: 'Exact Matching Demo',
tabs: [
{
id: 'mentorship',
label: 'Mentorship',
href: '/mentorship',
},
{
id: 'events',
label: 'Events',
href: '/mentorship/events',
},
{
id: 'mentors',
label: 'Mentors',
href: '/mentorship/mentors',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<HeaderPage {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /mentorship/events
</Text>
<br />
<Text>
With default exact matching, only the "Events" tab is active because
it exactly matches the current URL. The "Mentorship" tab is not active
even though the URL is under /mentorship.
</Text>
</Container>
</MemoryRouter>
),
};
export const WithTabsPrefixMatchingDeep: Story = {
args: {
title: 'Deep Nesting Demo',
tabs: [
{
id: 'catalog',
label: 'Catalog',
href: '/catalog',
matchStrategy: 'prefix',
},
{
id: 'users',
label: 'Users',
href: '/catalog/users',
matchStrategy: 'prefix',
},
{
id: 'components',
label: 'Components',
href: '/catalog/components',
matchStrategy: 'prefix',
},
],
},
render: args => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<HeaderPage {...args} />
<Container>
<Text>
<strong>Current URL:</strong> /catalog/users/john/details
</Text>
<br />
<Text>Both "Catalog" and "Users" tabs are active because:</Text>
<Text>
<strong>Catalog</strong>: URL starts with /catalog
</Text>
<Text>
<strong>Users</strong>: URL starts with /catalog/users
</Text>
<Text>
<strong>Components</strong>: not active (URL doesn't start with
/catalog/components)
</Text>
<br />
<Text>
This demonstrates how prefix matching works with deeply nested routes.
</Text>
</Container>
</MemoryRouter>
),
};
@@ -72,7 +72,12 @@ export const HeaderPage = (props: HeaderPageProps) => {
<Tabs>
<TabList>
{tabs.map(tab => (
<Tab key={tab.id} id={tab.id} href={tab.href}>
<Tab
key={tab.id}
id={tab.id}
href={tab.href}
matchStrategy={tab.matchStrategy}
>
{tab.label}
</Tab>
))}
@@ -174,3 +174,282 @@ export const WithMockedURLNoMatch: Story = {
</MemoryRouter>
),
};
// New stories for testing match strategies
export const ExactMatchingDefault: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Tabs>
<TabList>
<Tab id="mentorship" href="/mentorship">
Mentorship
</Tab>
<Tab id="events" href="/mentorship/events">
Events
</Tab>
<Tab id="catalog" href="/catalog">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text>
Using default exact matching, only the "Events" tab is active because
it exactly matches the URL.
</Text>
<Text>
The "Mentorship" tab is NOT active even though the URL contains
"/mentorship".
</Text>
</Box>
</MemoryRouter>
),
};
export const PrefixMatchingForNestedRoutes: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/mentorship/events']}>
<Tabs>
<TabList>
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
Mentorship
</Tab>
<Tab id="events" href="/mentorship/events">
Events
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/mentorship/events</strong>
</Text>
<Text>
The "Mentorship" tab uses prefix matching and IS active because
"/mentorship/events" starts with "/mentorship".
</Text>
<Text>
The "Events" tab uses exact matching and is also active because it
exactly matches.
</Text>
<Text>
The "Catalog" tab uses prefix matching but is NOT active because the
URL doesn't start with "/catalog".
</Text>
</Box>
</MemoryRouter>
),
};
export const PrefixMatchingDeepNesting: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/catalog/users/john/details']}>
<Tabs>
<TabList>
<Tab id="home" href="/home">
Home
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
<Tab id="mentorship" href="/mentorship" matchStrategy="prefix">
Mentorship
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/catalog/users/john/details</strong>
</Text>
<Text>
The "Catalog" tab is active because it uses prefix matching and the
URL starts with "/catalog".
</Text>
<Text>This works for any level of nesting under "/catalog".</Text>
</Box>
</MemoryRouter>
),
};
export const MixedMatchingStrategies: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/dashboard/analytics/reports']}>
<Tabs>
<TabList>
<Tab id="overview" href="/dashboard">
Overview
</Tab>
<Tab
id="analytics"
href="/dashboard/analytics"
matchStrategy="prefix"
>
Analytics
</Tab>
<Tab id="settings" href="/dashboard/settings" matchStrategy="prefix">
Settings
</Tab>
<Tab id="help" href="/help">
Help
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/dashboard/analytics/reports</strong>
</Text>
<Text>
"Overview" tab: exact matching, NOT active (doesn't exactly match
"/dashboard")
</Text>
<Text>
"Analytics" tab: prefix matching, IS active (URL starts with
"/dashboard/analytics")
</Text>
<Text>
"Settings" tab: prefix matching, NOT active (URL doesn't start with
"/dashboard/settings")
</Text>
<Text>
"Help" tab: exact matching, NOT active (doesn't exactly match
"/help")
</Text>
</Box>
</MemoryRouter>
),
};
export const PrefixMatchingEdgeCases: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/foobar']}>
<Tabs>
<TabList>
<Tab id="foo" href="/foo" matchStrategy="prefix">
Foo
</Tab>
<Tab id="foobar" href="/foobar">
Foobar
</Tab>
<Tab id="foo-exact" href="/foo">
Foo (exact)
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/foobar</strong>
</Text>
<Text>
"Foo" tab (prefix): NOT active - prevents "/foo" from matching
"/foobar"
</Text>
<Text>
"Foobar" tab (exact): IS active - exactly matches "/foobar"
</Text>
<Text>
"Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
</Text>
<Text>
This shows that prefix matching properly requires a "/" separator to
prevent false matches.
</Text>
</Box>
</MemoryRouter>
),
};
export const PrefixMatchingWithSlash: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/foo/bar']}>
<Tabs>
<TabList>
<Tab id="foo" href="/foo" matchStrategy="prefix">
Foo
</Tab>
<Tab id="foobar" href="/foobar">
Foobar
</Tab>
<Tab id="bar" href="/bar" matchStrategy="prefix">
Bar
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/foo/bar</strong>
</Text>
<Text>
"Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
</Text>
<Text>
"Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
</Text>
<Text>
"Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
"/bar"
</Text>
<Text>
This demonstrates proper prefix matching with the "/" separator.
</Text>
</Box>
</MemoryRouter>
),
};
export const RootPathMatching: Story = {
args: {
children: '',
},
render: () => (
<MemoryRouter initialEntries={['/']}>
<Tabs>
<TabList>
<Tab id="home" href="/">
Home
</Tab>
<Tab id="home-prefix" href="/" matchStrategy="prefix">
Home (prefix)
</Tab>
<Tab id="catalog" href="/catalog" matchStrategy="prefix">
Catalog
</Tab>
</TabList>
</Tabs>
<Box mt="6" pl="2">
<Text>
Current URL: <strong>/</strong>
</Text>
<Text> "Home" tab (exact): IS active - exactly matches "/"</Text>
<Text> "Home (prefix)" tab: IS active - "/" matches "/"</Text>
<Text>
"Catalog" tab (prefix): NOT active - "/" doesn't start with
"/catalog"
</Text>
</Box>
</MemoryRouter>
),
};
+32 -7
View File
@@ -29,6 +29,7 @@ import type {
TabListProps,
TabPanelProps,
TabsContextValue,
TabProps,
} from './types';
import { useLocation, useNavigate, useHref } from 'react-router-dom';
import { TabsIndicators } from './TabsIndicators';
@@ -53,6 +54,29 @@ const useTabsContext = () => {
return context;
};
/**
* Utility function to determine if a tab should be active based on the matching strategy.
* This follows the pattern used in WorkaroundNavLink from the sidebar.
*/
const isTabActive = (
tabHref: string,
currentPathname: string,
matchStrategy: 'exact' | 'prefix',
): boolean => {
if (matchStrategy === 'exact') {
return tabHref === currentPathname;
}
// Prefix matching - similar to WorkaroundNavLink behavior
if (tabHref === currentPathname) {
return true;
}
// Check if current path starts with tab href followed by a slash
// This prevents /foo matching /foobar
return currentPathname.startsWith(`${tabHref}/`);
};
/**
* A component that renders a list of tabs.
*
@@ -83,11 +107,12 @@ export const Tabs = (props: TabsProps) => {
if (isValidElement(child) && child.type === TabList) {
const tabListChildren = Children.toArray(child.props.children);
for (const tabChild of tabListChildren) {
if (
isValidElement(tabChild) &&
tabChild.props.href === location.pathname
) {
return tabChild.props.id;
if (isValidElement(tabChild) && tabChild.props.href) {
// Use tab-specific strategy, defaulting to 'exact'
const strategy = tabChild.props.matchStrategy || 'exact';
if (isTabActive(tabChild.props.href, location.pathname, strategy)) {
return tabChild.props.id;
}
}
}
}
@@ -173,8 +198,8 @@ export const TabList = (props: TabListProps) => {
*
* @public
*/
export const Tab = (props: AriaTabProps) => {
const { href, children, id, ...rest } = props;
export const Tab = (props: TabProps) => {
const { href, children, id, matchStrategy: _matchStrategy, ...rest } = props;
const { classNames } = useStyles('Tabs');
const { setTabRef } = useTabsContext();
+7 -1
View File
@@ -15,4 +15,10 @@
*/
export { Tabs, TabList, Tab, TabPanel } from './Tabs';
export type { TabsProps, TabListProps, TabPanelProps } from './types';
export type {
TabsProps,
TabListProps,
TabPanelProps,
TabProps,
TabMatchStrategy,
} from './types';
+22 -14
View File
@@ -18,9 +18,17 @@ import type {
TabsProps as AriaTabsProps,
TabListProps as AriaTabListProps,
TabPanelProps as AriaTabPanelProps,
TabProps as AriaTabProps,
} from 'react-aria-components';
import { MutableRefObject } from 'react';
/**
* Strategies for matching the current route to determine which tab should be active.
*
* @public
*/
export type TabMatchStrategy = 'exact' | 'prefix';
/**
* Props for the Tabs component.
*
@@ -35,6 +43,20 @@ export interface TabsProps extends AriaTabsProps {}
*/
export interface TabListProps extends Omit<AriaTabListProps<object>, 'items'> {}
/**
* Props for the Tab component.
*
* @public
*/
export interface TabProps extends AriaTabProps {
/**
* Strategy for matching the current route to determine if this tab should be active.
* - 'exact': Tab href must exactly match the current pathname (default)
* - 'prefix': Tab is active if current pathname starts with tab href
*/
matchStrategy?: 'exact' | 'prefix';
}
/** Context for sharing refs between Tabs and TabList
*
* @internal
@@ -66,17 +88,3 @@ export interface TabsIndicatorsProps {
hoveredKey: string | null;
prevHoveredKey: MutableRefObject<string | null>;
}
/**
* Context value for sharing refs and state between Tabs and TabList components.
*
* @internal
*/
export interface TabsContextValue {
tabsRef: React.RefObject<HTMLDivElement>;
tabRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
hoveredKey: string | null;
prevHoveredKey: React.MutableRefObject<string | null>;
setHoveredKey: (key: string | null) => void;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
}