From f2ac6d363129e78ee20ebd01a54b5a9152e115b0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 22 Jul 2025 10:26:20 +0100 Subject: [PATCH] Add support for tab matching strategy Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 11 +- .../src/components/Header/Header.stories.tsx | 151 ++++++++++ packages/ui/src/components/Header/Header.tsx | 7 +- .../src/components/Header/HeaderToolbar.tsx | 1 - packages/ui/src/components/Header/types.ts | 7 + .../HeaderPage/HeaderPage.stories.tsx | 168 ++++++++++- .../src/components/HeaderPage/HeaderPage.tsx | 7 +- .../ui/src/components/Tabs/Tabs.stories.tsx | 279 ++++++++++++++++++ packages/ui/src/components/Tabs/Tabs.tsx | 39 ++- packages/ui/src/components/Tabs/index.ts | 8 +- packages/ui/src/components/Tabs/types.ts | 36 ++- 11 files changed, 680 insertions(+), 34 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 5f08a8ce84..1be97620d2 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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, '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; diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index f7aa47e64b..1a255c0b4c 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -323,3 +323,154 @@ export const WithMockedURLNoMatch: Story = { ), }; + +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 => ( + +
+ + + Current URL: /mentorship/events + +
+ + Notice how the "Mentorship" tab is active even though we're on a + nested route. This is because it uses{' '} + matchStrategy="prefix". + +
+ + • Home: exact matching (default) - not active + + + • Mentorship: prefix matching - IS active (URL starts + with /mentorship) + + + • Catalog: prefix matching - not active + + + • Settings: exact matching (default) - not active + +
+ + ), +}; + +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 => ( + +
+ + + Current URL: /mentorship/events + +
+ + 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. + +
+ + ), +}; + +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 => ( + +
+ + + Current URL: /catalog/users/john/details + +
+ Both "Catalog" and "Users" tabs are active because: + + • Catalog: URL starts with /catalog + + + • Users: URL starts with /catalog/users + + + • Components: not active (URL doesn't start with + /catalog/components) + +
+ + This demonstrates how prefix matching works with deeply nested routes. + +
+ + ), +}; diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 7fd93c4154..745a524e57 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -63,7 +63,12 @@ export const Header = (props: HeaderProps) => { {tabs?.map(tab => ( - + {tab.label} ))} diff --git a/packages/ui/src/components/Header/HeaderToolbar.tsx b/packages/ui/src/components/Header/HeaderToolbar.tsx index 9236ad3fe7..9335a3d673 100644 --- a/packages/ui/src/components/Header/HeaderToolbar.tsx +++ b/packages/ui/src/components/Header/HeaderToolbar.tsx @@ -105,7 +105,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => { return ( - {' '}
diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index 5f3eceec5b..53f36a2f01 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -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; } /** diff --git a/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx b/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx index 3fe733c1bb..b8e7cf89e4 100644 --- a/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx +++ b/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx @@ -28,13 +28,6 @@ const meta = { parameters: { layout: 'fullscreen', }, - decorators: [ - (Story: StoryFn) => ( - - - - ), - ], } satisfies Meta; export default meta; @@ -74,6 +67,12 @@ const menuItems: HeaderMenuItem[] = [ }, ]; +const withRouter = (Story: StoryFn) => ( + + + +); + // 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: () => ( ( + + + + + Current URL: /mentorship/events + +
+ + Notice how the "Mentorship" tab is active even though we're on a + nested route. This is because it uses{' '} + matchStrategy="prefix". + +
+ + • Home: exact matching (default) - not active + + + • Mentorship: prefix matching - IS active (URL starts + with /mentorship) + + + • Catalog: prefix matching - not active + + + • Settings: exact matching (default) - not active + +
+
+ ), +}; + +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 => ( + + + + + Current URL: /mentorship/events + +
+ + 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. + +
+
+ ), +}; + +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 => ( + + + + + Current URL: /catalog/users/john/details + +
+ Both "Catalog" and "Users" tabs are active because: + + • Catalog: URL starts with /catalog + + + • Users: URL starts with /catalog/users + + + • Components: not active (URL doesn't start with + /catalog/components) + +
+ + This demonstrates how prefix matching works with deeply nested routes. + +
+
+ ), +}; diff --git a/packages/ui/src/components/HeaderPage/HeaderPage.tsx b/packages/ui/src/components/HeaderPage/HeaderPage.tsx index eae6144056..bde4689675 100644 --- a/packages/ui/src/components/HeaderPage/HeaderPage.tsx +++ b/packages/ui/src/components/HeaderPage/HeaderPage.tsx @@ -72,7 +72,12 @@ export const HeaderPage = (props: HeaderPageProps) => { {tabs.map(tab => ( - + {tab.label} ))} diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx index 6cfcd5570f..9f6e05e289 100644 --- a/packages/ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx @@ -174,3 +174,282 @@ export const WithMockedURLNoMatch: Story = { ), }; + +// New stories for testing match strategies + +export const ExactMatchingDefault: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Mentorship + + + Events + + + Catalog + + + + + + Current URL: /mentorship/events + + + Using default exact matching, only the "Events" tab is active because + it exactly matches the URL. + + + The "Mentorship" tab is NOT active even though the URL contains + "/mentorship". + + + + ), +}; + +export const PrefixMatchingForNestedRoutes: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Mentorship + + + Events + + + Catalog + + + + + + Current URL: /mentorship/events + + + The "Mentorship" tab uses prefix matching and IS active because + "/mentorship/events" starts with "/mentorship". + + + The "Events" tab uses exact matching and is also active because it + exactly matches. + + + The "Catalog" tab uses prefix matching but is NOT active because the + URL doesn't start with "/catalog". + + + + ), +}; + +export const PrefixMatchingDeepNesting: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Home + + + Catalog + + + Mentorship + + + + + + Current URL: /catalog/users/john/details + + + The "Catalog" tab is active because it uses prefix matching and the + URL starts with "/catalog". + + This works for any level of nesting under "/catalog". + + + ), +}; + +export const MixedMatchingStrategies: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Overview + + + Analytics + + + Settings + + + Help + + + + + + Current URL: /dashboard/analytics/reports + + + • "Overview" tab: exact matching, NOT active (doesn't exactly match + "/dashboard") + + + • "Analytics" tab: prefix matching, IS active (URL starts with + "/dashboard/analytics") + + + • "Settings" tab: prefix matching, NOT active (URL doesn't start with + "/dashboard/settings") + + + • "Help" tab: exact matching, NOT active (doesn't exactly match + "/help") + + + + ), +}; + +export const PrefixMatchingEdgeCases: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Foo + + + Foobar + + + Foo (exact) + + + + + + Current URL: /foobar + + + • "Foo" tab (prefix): NOT active - prevents "/foo" from matching + "/foobar" + + + • "Foobar" tab (exact): IS active - exactly matches "/foobar" + + + • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar" + + + This shows that prefix matching properly requires a "/" separator to + prevent false matches. + + + + ), +}; + +export const PrefixMatchingWithSlash: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Foo + + + Foobar + + + Bar + + + + + + Current URL: /foo/bar + + + • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/" + + + • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar" + + + • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with + "/bar" + + + This demonstrates proper prefix matching with the "/" separator. + + + + ), +}; + +export const RootPathMatching: Story = { + args: { + children: '', + }, + render: () => ( + + + + + Home + + + Home (prefix) + + + Catalog + + + + + + Current URL: / + + • "Home" tab (exact): IS active - exactly matches "/" + • "Home (prefix)" tab: IS active - "/" matches "/" + + • "Catalog" tab (prefix): NOT active - "/" doesn't start with + "/catalog" + + + + ), +}; diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx index f77338d34e..d4c820e529 100644 --- a/packages/ui/src/components/Tabs/Tabs.tsx +++ b/packages/ui/src/components/Tabs/Tabs.tsx @@ -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(); diff --git a/packages/ui/src/components/Tabs/index.ts b/packages/ui/src/components/Tabs/index.ts index ac65c77479..38a6f20ea7 100644 --- a/packages/ui/src/components/Tabs/index.ts +++ b/packages/ui/src/components/Tabs/index.ts @@ -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'; diff --git a/packages/ui/src/components/Tabs/types.ts b/packages/ui/src/components/Tabs/types.ts index 38eaa65b74..05d080441b 100644 --- a/packages/ui/src/components/Tabs/types.ts +++ b/packages/ui/src/components/Tabs/types.ts @@ -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, '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; } - -/** - * Context value for sharing refs and state between Tabs and TabList components. - * - * @internal - */ -export interface TabsContextValue { - tabsRef: React.RefObject; - tabRefs: React.MutableRefObject>; - hoveredKey: string | null; - prevHoveredKey: React.MutableRefObject; - setHoveredKey: (key: string | null) => void; - setTabRef: (key: string, element: HTMLDivElement | null) => void; -}