Add support for tab matching strategy
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user