Merge branch 'master' into bui-text

This commit is contained in:
Charles de Dreuille
2025-07-22 11:02:30 +01:00
15 changed files with 806 additions and 156 deletions
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -9191,8 +9191,8 @@
--bui-gray-6: #7b7b7b;
--bui-gray-7: #9e9e9e;
--bui-gray-8: #b4b4b4;
--bui-bg: var(--bui-black);
--bui-bg-surface-1: var(--bui-gray-1);
--bui-bg: #333;
--bui-bg-surface-1: #424242;
--bui-bg-surface-2: var(--bui-gray-2);
--bui-bg-solid: #9cc9ff;
--bui-bg-solid-hover: #83b9fd;
@@ -9217,7 +9217,7 @@
--bui-fg-danger: #e22b2b;
--bui-fg-warning: #e36d05;
--bui-fg-success: #1db954;
--bui-border: #fff3;
--bui-border: #ffffff1f;
--bui-border-hover: #fff6;
--bui-border-pressed: #ffffff80;
--bui-border-disabled: #fff3;
+12 -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';
@@ -1026,6 +1026,8 @@ export interface HeaderProps {
tabs?: HeaderTab[];
// (undocumented)
title?: string;
// (undocumented)
titleLink?: string;
}
// @public
@@ -1036,6 +1038,7 @@ export interface HeaderTab {
id: string;
// (undocumented)
label: string;
matchStrategy?: TabMatchStrategy;
}
// @public (undocumented)
@@ -1642,12 +1645,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;
@@ -123,7 +123,7 @@ const layoutDecorator = [
left: 'var(--sb-panel-left)',
top: 'var(--sb-panel-top)',
bottom: 'var(--sb-panel-bottom)',
backgroundColor: 'var(--bui-bg-surface-1)',
backgroundColor: 'var(--sb-sidebar-bg)',
borderRadius: 'var(--sb-panel-radius)',
border: 'var(--sb-sidebar-border)',
borderRight: 'var(--sb-sidebar-border-right)',
@@ -132,7 +132,7 @@ const layoutDecorator = [
/>
<div
style={{
paddingInline: 'var(--sb-content-padding-inline)',
paddingLeft: 'var(--sb-content-padding-inline)',
minHeight: '200vh',
}}
>
@@ -144,20 +144,6 @@ const layoutDecorator = [
</Text>
</Container>
</div>
<div
style={{
width: '250px',
position: 'fixed',
right: 'var(--sb-panel-right)',
top: 'var(--sb-panel-top)',
bottom: 'var(--sb-panel-bottom)',
backgroundColor: 'var(--bui-bg-surface-1)',
borderRadius: 'var(--sb-panel-radius)',
border: 'var(--sb-options-border)',
borderLeft: 'var(--sb-options-border-left)',
zIndex: 1,
}}
/>
</>
),
withRouter,
@@ -244,6 +230,7 @@ export const WithEverything: Story = {
menuItems,
breadcrumbs,
tabs,
titleLink: '/',
},
decorators: layoutDecorator,
render: args => (
@@ -336,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>
),
};
+18 -3
View File
@@ -32,7 +32,16 @@ declare module 'react-aria-components' {
* @public
*/
export const Header = (props: HeaderProps) => {
const { tabs, icon, title, menuItems, breadcrumbs, customActions } = props;
const {
tabs,
icon,
title,
titleLink,
menuItems,
breadcrumbs,
customActions,
onTabSelectionChange,
} = props;
const { classNames } = useStyles('Header');
@@ -43,6 +52,7 @@ export const Header = (props: HeaderProps) => {
<HeaderToolbar
icon={icon}
title={title}
titleLink={titleLink}
menuItems={menuItems}
breadcrumbs={breadcrumbs}
customActions={customActions}
@@ -50,10 +60,15 @@ export const Header = (props: HeaderProps) => {
/>
{tabs && (
<div className={classNames.tabsWrapper}>
<Tabs onSelectionChange={props.onTabSelectionChange}>
<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>
))}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Link } from 'react-aria-components';
import { Link, RouterProvider } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useRef, useState, useEffect } from 'react';
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
@@ -23,6 +23,7 @@ import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { Text } from '../Text';
import { motion, useScroll, useTransform } from 'motion/react';
import { useNavigate, useHref } from 'react-router-dom';
/**
* A component that renders a toolbar.
@@ -30,9 +31,17 @@ import { motion, useScroll, useTransform } from 'motion/react';
* @internal
*/
export const HeaderToolbar = (props: HeaderToolbarProps) => {
const { icon, title, menuItems, breadcrumbs, customActions, hasTabs } = props;
const {
icon,
title,
titleLink,
menuItems,
breadcrumbs,
customActions,
hasTabs,
} = props;
const { classNames } = useStyles('Header');
let navigate = useNavigate();
const { scrollY } = useScroll();
const breadcrumbOpacity = useTransform(scrollY, [80, 120], [0, 1]);
@@ -87,80 +96,90 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
};
}, []);
const titleContent = (
<>
<div className={classNames.toolbarIcon}>{icon || <RiShapesLine />}</div>
<Text variant="body">{title || 'Your plugin'}</Text>
</>
);
return (
<div className={classNames.toolbar} data-has-tabs={hasTabs}>
<div className={classNames.toolbarWrapper} ref={toolbarWrapperRef}>
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
<div className={classNames.toolbarName}>
<div className={classNames.toolbarIcon}>
{icon || <RiShapesLine />}
</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
<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}>
{titleLink ? (
<Link className={classNames.toolbarName} href={titleLink}>
{titleContent}
</Link>
) : (
<div className={classNames.toolbarName}>{titleContent}</div>
)}
{breadcrumbs && (
<motion.div
className={classNames.breadcrumbs}
style={{
opacity: breadcrumbOpacity,
visibility: showBreadcrumbs ? 'visible' : 'hidden',
}}
>
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
{breadcrumbs.map((breadcrumb, index) => (
<div key={breadcrumb.label} className={classNames.breadcrumb}>
<Link
href={breadcrumb.href}
className={classNames.breadcrumbLink}
data-active={index === breadcrumbs.length - 1}
>
{breadcrumb.label}
</Link>
{index < breadcrumbs.length - 1 && (
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
)}
</div>
))}
</motion.div>
)}
</div>
{breadcrumbs && (
<motion.div
className={classNames.breadcrumbs}
style={{
opacity: breadcrumbOpacity,
visibility: showBreadcrumbs ? 'visible' : 'hidden',
}}
>
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
{breadcrumbs.map((breadcrumb, index) => (
<div key={breadcrumb.label} className={classNames.breadcrumb}>
<Link
href={breadcrumb.href}
className={classNames.breadcrumbLink}
data-active={index === breadcrumbs.length - 1}
>
{breadcrumb.label}
</Link>
{index < breadcrumbs.length - 1 && (
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
size="small"
icon={<RiMore2Line />}
variant="tertiary"
{...props}
/>
)}
</div>
))}
</motion.div>
)}
</div>
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
size="small"
icon={<RiMore2Line />}
variant="tertiary"
{...props}
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</div>
</div>
</div>
</RouterProvider>
);
};
@@ -15,6 +15,7 @@
*/
import { TabsProps } from 'react-aria-components';
import { TabMatchStrategy } from '../Tabs';
/**
* Props for the main Header component.
@@ -24,6 +25,7 @@ import { TabsProps } from 'react-aria-components';
export interface HeaderProps {
icon?: React.ReactNode;
title?: string;
titleLink?: string;
breadcrumbs?: HeaderBreadcrumb[];
customActions?: React.ReactNode;
menuItems?: HeaderMenuItem[];
@@ -40,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;
}
/**
@@ -71,6 +79,7 @@ export interface HeaderBreadcrumb {
export interface HeaderToolbarProps {
icon?: HeaderProps['icon'];
title?: HeaderProps['title'];
titleLink?: HeaderProps['titleLink'];
breadcrumbs?: HeaderProps['breadcrumbs'];
customActions?: HeaderProps['customActions'];
menuItems?: HeaderProps['menuItems'];
@@ -14,11 +14,11 @@
* limitations under the License.
*/
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { HeaderPage } from './HeaderPage';
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
import { Button } from '../Button';
import { MemoryRouter } from 'react-router-dom';
import { Button } from '../Button';
import { Container } from '../Container';
import { Text } from '../Text';
@@ -33,12 +33,6 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
const tabs: HeaderTab[] = [
{
id: 'overview',
@@ -73,8 +67,14 @@ const menuItems: HeaderMenuItem[] = [
},
];
const withRouter = (Story: StoryFn) => (
<MemoryRouter>
<Story />
</MemoryRouter>
);
// Extract layout decorator as a reusable constant
export const layoutDecorator = [
const layoutDecorator = [
(Story: StoryFn) => (
<>
<div
@@ -84,7 +84,7 @@ export const layoutDecorator = [
left: 'var(--sb-panel-left)',
top: 'var(--sb-panel-top)',
bottom: 'var(--sb-panel-bottom)',
backgroundColor: 'var(--bui-bg-surface-1)',
backgroundColor: 'var(--sb-sidebar-bg)',
borderRadius: 'var(--sb-panel-radius)',
border: 'var(--sb-sidebar-border)',
borderRight: 'var(--sb-sidebar-border-right)',
@@ -93,7 +93,7 @@ export const layoutDecorator = [
/>
<div
style={{
paddingInline: 'var(--sb-content-padding-inline)',
paddingLeft: 'var(--sb-content-padding-inline)',
minHeight: '200vh',
}}
>
@@ -105,23 +105,8 @@ export const layoutDecorator = [
</Text>
</Container>
</div>
<div
style={{
width: '250px',
position: 'fixed',
right: 'var(--sb-panel-right)',
top: 'var(--sb-panel-top)',
bottom: 'var(--sb-panel-bottom)',
backgroundColor: 'var(--bui-bg-surface-1)',
borderRadius: 'var(--sb-panel-radius)',
border: 'var(--sb-options-border)',
borderLeft: 'var(--sb-options-border-left)',
zIndex: 1,
}}
/>
</>
),
withRouter,
];
export const Default: Story = {
@@ -171,13 +156,157 @@ export const WithLayout: Story = {
args: {
...WithEverything.args,
},
decorators: layoutDecorator,
render: () => (
<HeaderPage
{...Default.args}
menuItems={menuItems}
tabs={tabs}
customActions={<Button>Custom action</Button>}
/>
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>
),
};
@@ -74,7 +74,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;
}
+3 -3
View File
@@ -155,8 +155,8 @@
--bui-gray-8: #b4b4b4;
/* Background Colors */
--bui-bg: var(--bui-black);
--bui-bg-surface-1: var(--bui-gray-1);
--bui-bg: #333333;
--bui-bg-surface-1: #424242;
--bui-bg-surface-2: var(--bui-gray-2);
--bui-bg-solid: #9cc9ff;
--bui-bg-solid-hover: #83b9fd;
@@ -185,7 +185,7 @@
--bui-fg-success: #1db954;
/* Border Colors */
--bui-border: rgba(255, 255, 255, 0.2);
--bui-border: rgba(255, 255, 255, 0.12);
--bui-border-hover: rgba(255, 255, 255, 0.4);
--bui-border-pressed: rgba(255, 255, 255, 0.5);
--bui-border-disabled: rgba(255, 255, 255, 0.2);
+6
View File
@@ -6,11 +6,16 @@
--sb-panel-right: 0;
--sb-sidebar-border: none;
--sb-sidebar-border-right: 1px solid var(--bui-border);
--sb-sidebar-bg: #000;
--sb-options-border: none;
--sb-options-border-left: 1px solid var(--bui-border);
--sb-content-padding-inline: 250px;
}
[data-theme='dark'] {
--sb-sidebar-bg: var(--bui-bg-surface-1);
}
[data-theme-name='spotify'] {
--sb-panel-radius: var(--bui-radius-3);
--sb-panel-top: 8px;
@@ -19,6 +24,7 @@
--sb-panel-right: 8px;
--sb-sidebar-border: none;
--sb-sidebar-border-right: none;
--sb-sidebar-bg: var(--bui-bg-surface-1);
--sb-options-border: none;
--sb-options-border-left: none;
--sb-content-padding-inline: 258px;