From dd490a0cbba0beff264a6b7033a37b6491df50ea Mon Sep 17 00:00:00 2001 From: jaymantri Date: Fri, 6 Mar 2026 14:05:19 -0800 Subject: [PATCH 1/3] Add SegmentedNav for route-based section navigation Provide a link-first segmented navigation primitive for sibling routes so products do not have to misuse Tabs for page navigation. Made-with: Cursor --- src/app/page.tsx | 37 +++++++++ .../SegmentedNav/SegmentedNav.module.scss | 57 +++++++++++++ .../SegmentedNav/SegmentedNav.stories.tsx | 66 ++++++++++++++++ .../SegmentedNav.test-stories.tsx | 79 +++++++++++++++++++ .../SegmentedNav/SegmentedNav.test.tsx | 74 +++++++++++++++++ src/components/SegmentedNav/index.ts | 6 ++ src/components/SegmentedNav/parts.tsx | 52 ++++++++++++ src/index.ts | 2 + 8 files changed, 373 insertions(+) create mode 100644 src/components/SegmentedNav/SegmentedNav.module.scss create mode 100644 src/components/SegmentedNav/SegmentedNav.stories.tsx create mode 100644 src/components/SegmentedNav/SegmentedNav.test-stories.tsx create mode 100644 src/components/SegmentedNav/SegmentedNav.test.tsx create mode 100644 src/components/SegmentedNav/index.ts create mode 100644 src/components/SegmentedNav/parts.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 65794ff..151f9b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -37,6 +37,7 @@ import { PhoneInput } from '@/components/PhoneInput'; import { Progress } from '@/components/Progress'; import { Radio } from '@/components/Radio'; import { Select } from '@/components/Select'; +import { SegmentedNav } from '@/components/SegmentedNav'; import { Separator } from '@/components/Separator'; import { Sidebar } from '@/components/Sidebar'; import { Skeleton } from '@/components/Skeleton'; @@ -1921,6 +1922,42 @@ export default function Home() { +

SegmentedNav Component

+ +
+
+ Payout sections + + }> + Overview + + }> + Activity + + }> + Recipients + + }> + Customers + + +
+ +
+ Longer labels + + }> + Customer overview + + }> + Platform payouts + + }> + Reconciliation + + +
+

Button Component

{/* Variants */} diff --git a/src/components/SegmentedNav/SegmentedNav.module.scss b/src/components/SegmentedNav/SegmentedNav.module.scss new file mode 100644 index 0000000..ce78268 --- /dev/null +++ b/src/components/SegmentedNav/SegmentedNav.module.scss @@ -0,0 +1,57 @@ +@use '../../tokens/text-styles' as *; +@use '../../tokens/mixins' as *; + +.root { + display: inline-flex; + align-items: center; +} + +.list { + display: inline-flex; + gap: var(--spacing-2xs); + align-items: center; + padding: var(--spacing-3xs); + overflow: clip; + background: var(--surface-secondary); + @include smooth-corners(var(--corner-radius-sm)); +} + +.link { + @include label; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 var(--spacing-md); + border: var(--stroke-xs) solid transparent; + @include smooth-corners(var(--corner-radius-xs)); + color: var(--text-primary); + text-decoration: none; + white-space: nowrap; + outline: none; + transition: + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease, + color 150ms ease; + + &[aria-current='page'] { + background: var(--surface-panel); + border-color: var(--border-primary); + box-shadow: var(--shadow-sm); + color: var(--text-primary); + } + + &:focus-visible { + background: var(--surface-panel); + border-color: var(--border-secondary); + box-shadow: var(--input-focus); + color: var(--text-primary); + } +} + +@media (prefers-reduced-motion: reduce) { + .link { + transition: none; + } +} diff --git a/src/components/SegmentedNav/SegmentedNav.stories.tsx b/src/components/SegmentedNav/SegmentedNav.stories.tsx new file mode 100644 index 0000000..52069d7 --- /dev/null +++ b/src/components/SegmentedNav/SegmentedNav.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { SegmentedNav } from './'; + +const meta = { + title: 'Components/SegmentedNav', + component: SegmentedNav, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + }> + Overview + + }> + Activity + + }> + Recipients + + }> + Customers + + + ), +}; + +export const PlainAnchors: Story = { + render: () => ( + + }> + Balances + + }> + Activity + + }> + Reports + + + ), +}; + +export const LongerLabels: Story = { + render: () => ( + + }> + Customer overview + + }> + Platform payouts + + }> + Reconciliation + + + ), +}; diff --git a/src/components/SegmentedNav/SegmentedNav.test-stories.tsx b/src/components/SegmentedNav/SegmentedNav.test-stories.tsx new file mode 100644 index 0000000..bafa7df --- /dev/null +++ b/src/components/SegmentedNav/SegmentedNav.test-stories.tsx @@ -0,0 +1,79 @@ +'use client'; + +import * as React from 'react'; +import { SegmentedNav } from './'; + +export function DefaultSegmentedNav() { + return ( + + }> + Overview + + }> + Activity + + }> + Recipients + + + ); +} + +export function PlainAnchorSegmentedNav() { + return ( + + }> + Balances + + }> + Activity + + + ); +} + +export function LinkPropForwardingSegmentedNav() { + return ( + + + } + > + Forwarded + + + ); +} + +export function ClickableSegmentedNav() { + const [clicked, setClicked] = React.useState(false); + + return ( +
+ + { + event.preventDefault(); + setClicked(true); + }} + /> + } + > + Interactive + + + {clicked ? 'Clicked' : 'Not clicked'} +
+ ); +} diff --git a/src/components/SegmentedNav/SegmentedNav.test.tsx b/src/components/SegmentedNav/SegmentedNav.test.tsx new file mode 100644 index 0000000..41923ba --- /dev/null +++ b/src/components/SegmentedNav/SegmentedNav.test.tsx @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import AxeBuilder from '@axe-core/playwright'; +import { + DefaultSegmentedNav, + PlainAnchorSegmentedNav, + LinkPropForwardingSegmentedNav, + ClickableSegmentedNav, +} from './SegmentedNav.test-stories'; + +const axeConfig = { + rules: { + 'landmark-one-main': { enabled: false }, + 'page-has-heading-one': { enabled: false }, + region: { enabled: false }, + }, +}; + +test.describe('SegmentedNav', () => { + test('renders a navigation landmark with an accessible label', async ({ mount, page }) => { + await mount(); + + await expect(page.getByRole('navigation', { name: 'Payout sections' })).toBeVisible(); + }); + + test('renders links and preserves href from the rendered anchor', async ({ mount, page }) => { + await mount(); + + await expect(page.getByRole('link', { name: 'Balances' })).toHaveAttribute('href', '/balances'); + await expect(page.getByRole('link', { name: 'Activity' })).toHaveAttribute('href', '/balances/activity'); + }); + + test('marks the active link with aria-current on the real link', async ({ mount, page }) => { + await mount(); + + await expect(page.getByRole('link', { name: 'Activity' })).toHaveAttribute('aria-current', 'page'); + }); + + test('does not add redundant active attributes beyond aria-current', async ({ mount, page }) => { + await mount(); + + await expect(page.getByRole('link', { name: 'Activity' })).not.toHaveAttribute('data-active'); + }); + + test('merges child props onto the rendered link', async ({ mount, page }) => { + await mount(); + + const link = page.getByTestId('forwarded-link'); + await expect(link).toHaveAttribute('href', '/forwarded'); + await expect(link).toHaveAttribute('data-custom', 'value'); + await expect(link).toHaveClass(/custom-link/); + await expect(link).toHaveCSS('color', 'rgb(255, 0, 0)'); + }); + + test('keeps native keyboard focus behavior for links', async ({ mount, page }) => { + await mount(); + + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'Overview' })).toBeFocused(); + }); + + test('preserves custom click handling on the rendered link', async ({ mount, page }) => { + await mount(); + + await page.getByRole('link', { name: 'Interactive' }).click(); + await expect(page.getByText('Clicked')).toBeVisible(); + }); + + test('has no accessibility violations', async ({ mount, page }) => { + await mount(); + + const results = await new AxeBuilder({ page }).options(axeConfig).analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/src/components/SegmentedNav/index.ts b/src/components/SegmentedNav/index.ts new file mode 100644 index 0000000..928b393 --- /dev/null +++ b/src/components/SegmentedNav/index.ts @@ -0,0 +1,6 @@ +export { SegmentedNav } from './parts'; + +export type { + RootProps as SegmentedNavRootProps, + LinkProps as SegmentedNavLinkProps, +} from './parts'; diff --git a/src/components/SegmentedNav/parts.tsx b/src/components/SegmentedNav/parts.tsx new file mode 100644 index 0000000..ae2d93c --- /dev/null +++ b/src/components/SegmentedNav/parts.tsx @@ -0,0 +1,52 @@ +'use client'; + +import * as React from 'react'; +import { mergeProps } from '@base-ui/react/merge-props'; +import clsx from 'clsx'; +import styles from './SegmentedNav.module.scss'; + +export interface RootProps extends React.ComponentPropsWithoutRef<'nav'> {} + +export const Root = React.forwardRef(function Root( + { className, children, 'aria-label': ariaLabel = 'Segmented navigation', ...props }, + ref +) { + return ( + + ); +}); + +export interface LinkProps extends Omit, 'href'> { + active?: boolean; + render: React.ReactElement; +} + +export const Link = React.forwardRef(function Link( + { active = false, render, className, children, ...props }, + ref +) { + const linkProps = mergeProps(render.props as React.ComponentPropsWithoutRef<'a'>, { + ref, + className: clsx(styles.link, className), + 'aria-current': active ? ('page' as const) : undefined, + ...props, + }); + + return React.cloneElement(render, linkProps, children); +}); + +if (process.env.NODE_ENV !== 'production') { + Root.displayName = 'SegmentedNav'; + Link.displayName = 'SegmentedNav.Link'; +} + +export const SegmentedNav = Object.assign(Root, { + Link, +}); diff --git a/src/index.ts b/src/index.ts index c95182c..e9ad27b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,8 @@ export { PhoneInput } from './components/PhoneInput'; export { Progress } from './components/Progress'; export { Radio } from './components/Radio'; export { Select } from './components/Select'; +export { SegmentedNav } from './components/SegmentedNav'; +export type { SegmentedNavRootProps, SegmentedNavLinkProps } from './components/SegmentedNav'; export { Sidebar } from './components/Sidebar'; export { Skeleton } from './components/Skeleton'; export { Table } from './components/Table'; From 4edca2c4b4b6303d120afac99fc5931ed2cde9f8 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Sun, 8 Mar 2026 12:52:20 -0700 Subject: [PATCH 2/3] Add SegmentedNav route transition investigation coverage. Capture the repeated A-to-B-to-C navigation controls in Origin so we can separate SegmentedNav behavior from the Grid-side external accounts bug. Made-with: Cursor --- .../SegmentedNav.test-stories.tsx | 118 ++++++++++++++++++ .../SegmentedNav/SegmentedNav.test.tsx | 33 +++++ 2 files changed, 151 insertions(+) diff --git a/src/components/SegmentedNav/SegmentedNav.test-stories.tsx b/src/components/SegmentedNav/SegmentedNav.test-stories.tsx index bafa7df..291f3a4 100644 --- a/src/components/SegmentedNav/SegmentedNav.test-stories.tsx +++ b/src/components/SegmentedNav/SegmentedNav.test-stories.tsx @@ -3,6 +3,82 @@ import * as React from 'react'; import { SegmentedNav } from './'; +type RouteValue = '/route-a' | '/route-b' | '/route-c'; + +interface RouterContextValue { + route: RouteValue; + navigate: (nextRoute: RouteValue) => void; +} + +const RouterContext = React.createContext(null); + +function useRouterContext() { + const context = React.useContext(RouterContext); + + if (!context) { + throw new Error('Route test components must be used within the route harness.'); + } + + return context; +} + +const RouteLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentPropsWithoutRef<'a'> & { href: RouteValue } +>(function RouteLink({ href, onClick, children, ...props }, ref) { + const { navigate } = useRouterContext(); + + return ( + { + event.preventDefault(); + onClick?.(event); + navigate(href); + }} + {...props} + > + {children} + + ); +}); + +function RoutePanel() { + const { route } = useRouterContext(); + + return ( +
+
{route}
+
{route === '/route-a' ? 'Route A' : route === '/route-b' ? 'Route B' : 'Route C'}
+
+ ); +} + +function RouteHarness({ + children, + initialRoute = '/route-a', +}: { + children: React.ReactNode; + initialRoute?: RouteValue; +}) { + const [route, setRoute] = React.useState(initialRoute); + const navigate = React.useCallback((nextRoute: RouteValue) => { + setRoute(nextRoute); + }, []); + + const value = React.useMemo(() => ({ route, navigate }), [route, navigate]); + + return ( + +
+ {children} + +
+
+ ); +} + export function DefaultSegmentedNav() { return ( @@ -77,3 +153,45 @@ export function ClickableSegmentedNav() { ); } + +export function PlainLinksRouteHarness() { + return ( + + + Route A + Route B + Route C + + + ); +} + +export function RenderLinksRouteHarness() { + return ( + + + }> + Route A + + }> + Route B + + }> + Route C + + + + ); +} + +export function ControlRouteHarness() { + return ( + +
+ Route A + Route B + Route C +
+
+ ); +} diff --git a/src/components/SegmentedNav/SegmentedNav.test.tsx b/src/components/SegmentedNav/SegmentedNav.test.tsx index 41923ba..e83ff49 100644 --- a/src/components/SegmentedNav/SegmentedNav.test.tsx +++ b/src/components/SegmentedNav/SegmentedNav.test.tsx @@ -5,6 +5,9 @@ import { PlainAnchorSegmentedNav, LinkPropForwardingSegmentedNav, ClickableSegmentedNav, + PlainLinksRouteHarness, + RenderLinksRouteHarness, + ControlRouteHarness, } from './SegmentedNav.test-stories'; const axeConfig = { @@ -65,6 +68,36 @@ test.describe('SegmentedNav', () => { await expect(page.getByText('Clicked')).toBeVisible(); }); + test('container-only plain links commit repeated route changes', async ({ mount, page }) => { + await mount(); + + await page.getByRole('link', { name: 'Route B' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-b'); + + await page.getByRole('link', { name: 'Route C' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-c'); + }); + + test('SegmentedNav.Link commits repeated route changes', async ({ mount, page }) => { + await mount(); + + await page.getByRole('link', { name: 'Route B' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-b'); + + await page.getByRole('link', { name: 'Route C' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-c'); + }); + + test('non-SegmentedNav control also commits repeated route changes', async ({ mount, page }) => { + await mount(); + + await page.getByRole('link', { name: 'Route B' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-b'); + + await page.getByRole('link', { name: 'Route C' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-c'); + }); + test('has no accessibility violations', async ({ mount, page }) => { await mount(); From c49359eb037da241ab8f396f37691e30ef319358 Mon Sep 17 00:00:00 2001 From: jaymantri Date: Sun, 8 Mar 2026 14:08:32 -0700 Subject: [PATCH 3/3] Add grouped SegmentedNav composition. Support grouped route navigation without introducing a variant prop, and make the demo easier to validate locally while keeping Storybook aligned with real link usage. Made-with: Cursor --- src/app/page.tsx | 141 +++++++++++++----- .../SegmentedNav/SegmentedNav.module.scss | 27 +++- .../SegmentedNav/SegmentedNav.stories.tsx | 23 +++ .../SegmentedNav.test-stories.tsx | 45 ++++++ .../SegmentedNav/SegmentedNav.test.tsx | 61 ++++++++ src/components/SegmentedNav/index.ts | 1 + src/components/SegmentedNav/parts.tsx | 11 ++ src/index.ts | 6 +- 8 files changed, 277 insertions(+), 38 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 151f9b3..8e74d6c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1709,6 +1709,78 @@ function DatePickerDemo() { ); } +function SegmentedNavDemo({ + ariaLabel, + items, + initialActive, +}: { + ariaLabel: string; + items: string[]; + initialActive: string; +}) { + const [activeItem, setActiveItem] = React.useState(initialActive); + + return ( + + {items.map((item) => ( + { + event.preventDefault(); + setActiveItem(item); + }} + /> + } + > + {item} + + ))} + + ); +} + +function GroupedSegmentedNavDemo({ + ariaLabel, + groups, + initialActive, +}: { + ariaLabel: string; + groups: string[][]; + initialActive: string; +}) { + const [activeItem, setActiveItem] = React.useState(initialActive); + + return ( + + {groups.map((group, index) => ( + + {group.map((item) => ( + { + event.preventDefault(); + setActiveItem(item); + }} + /> + } + > + {item} + + ))} + + ))} + + ); +} + export default function Home() { return (
@@ -1922,42 +1994,6 @@ export default function Home() { -

SegmentedNav Component

- -
-
- Payout sections - - }> - Overview - - }> - Activity - - }> - Recipients - - }> - Customers - - -
- -
- Longer labels - - }> - Customer overview - - }> - Platform payouts - - }> - Reconciliation - - -
-

Button Component

{/* Variants */} @@ -3515,6 +3551,39 @@ export default function Home() { Error text goes here. +

SegmentedNav Component

+ +
+
+ Flat links + +
+ +
+ Grouped links + +
+ +
+ Longer labels + +
+

Select Component

diff --git a/src/components/SegmentedNav/SegmentedNav.module.scss b/src/components/SegmentedNav/SegmentedNav.module.scss index ce78268..2ac15c2 100644 --- a/src/components/SegmentedNav/SegmentedNav.module.scss +++ b/src/components/SegmentedNav/SegmentedNav.module.scss @@ -8,7 +8,6 @@ .list { display: inline-flex; - gap: var(--spacing-2xs); align-items: center; padding: var(--spacing-3xs); overflow: clip; @@ -16,6 +15,32 @@ @include smooth-corners(var(--corner-radius-sm)); } +.list > .link + .link { + margin-inline-start: var(--spacing-2xs); +} + +.group { + position: relative; + display: inline-flex; + gap: var(--spacing-2xs); + align-items: center; +} + +.group + .group { + margin-inline-start: var(--spacing-xs); + padding-inline-start: var(--spacing-xs); + + &::before { + content: ''; + position: absolute; + top: calc(-1 * var(--spacing-3xs)); + bottom: calc(-1 * var(--spacing-3xs)); + inset-inline-start: 0; + width: var(--stroke-xs); + background: var(--border-secondary); + } +} + .link { @include label; display: inline-flex; diff --git a/src/components/SegmentedNav/SegmentedNav.stories.tsx b/src/components/SegmentedNav/SegmentedNav.stories.tsx index 52069d7..3602bac 100644 --- a/src/components/SegmentedNav/SegmentedNav.stories.tsx +++ b/src/components/SegmentedNav/SegmentedNav.stories.tsx @@ -33,6 +33,29 @@ export const Default: Story = { ), }; +export const Grouped: Story = { + render: () => ( + + + }> + Overview + + }> + Platform payouts + + }> + Recipients + + + + }> + Customer payouts + + + + ), +}; + export const PlainAnchors: Story = { render: () => ( diff --git a/src/components/SegmentedNav/SegmentedNav.test-stories.tsx b/src/components/SegmentedNav/SegmentedNav.test-stories.tsx index 291f3a4..4e90ddc 100644 --- a/src/components/SegmentedNav/SegmentedNav.test-stories.tsx +++ b/src/components/SegmentedNav/SegmentedNav.test-stories.tsx @@ -108,6 +108,29 @@ export function PlainAnchorSegmentedNav() { ); } +export function GroupedSegmentedNav() { + return ( + + + }> + Overview + + }> + Platform payouts + + }> + Recipients + + + + }> + Customer payouts + + + + ); +} + export function LinkPropForwardingSegmentedNav() { return ( @@ -184,6 +207,28 @@ export function RenderLinksRouteHarness() { ); } +export function GroupedLinksRouteHarness() { + return ( + + + + }> + Route A + + }> + Route B + + + + }> + Route C + + + + + ); +} + export function ControlRouteHarness() { return ( diff --git a/src/components/SegmentedNav/SegmentedNav.test.tsx b/src/components/SegmentedNav/SegmentedNav.test.tsx index e83ff49..1f2d54e 100644 --- a/src/components/SegmentedNav/SegmentedNav.test.tsx +++ b/src/components/SegmentedNav/SegmentedNav.test.tsx @@ -3,10 +3,12 @@ import AxeBuilder from '@axe-core/playwright'; import { DefaultSegmentedNav, PlainAnchorSegmentedNav, + GroupedSegmentedNav, LinkPropForwardingSegmentedNav, ClickableSegmentedNav, PlainLinksRouteHarness, RenderLinksRouteHarness, + GroupedLinksRouteHarness, ControlRouteHarness, } from './SegmentedNav.test-stories'; @@ -44,6 +46,22 @@ test.describe('SegmentedNav', () => { await expect(page.getByRole('link', { name: 'Activity' })).not.toHaveAttribute('data-active'); }); + test('renders grouped links as one navigation landmark', async ({ mount, page }) => { + await mount(); + + await expect(page.getByRole('navigation', { name: 'Payout sections' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Customer payouts' })).toBeVisible(); + }); + + test('marks the active grouped link with aria-current on the real link', async ({ mount, page }) => { + await mount(); + + await expect(page.getByRole('link', { name: 'Platform payouts' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + test('merges child props onto the rendered link', async ({ mount, page }) => { await mount(); @@ -68,6 +86,39 @@ test.describe('SegmentedNav', () => { await expect(page.getByText('Clicked')).toBeVisible(); }); + test('keeps native keyboard focus behavior for grouped links', async ({ mount, page }) => { + await mount(); + + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'Overview' })).toBeFocused(); + }); + + test('renders the grouped separator edge to edge within the segmented row', async ({ + mount, + page, + }) => { + await mount(); + + const separatorMetrics = await page.getByTestId('grouped-secondary-group').evaluate((element) => { + const beforeStyles = window.getComputedStyle(element, '::before'); + const groupStyles = window.getComputedStyle(element); + const listStyles = element.parentElement ? window.getComputedStyle(element.parentElement) : null; + const listHeight = element.parentElement?.getBoundingClientRect().height ?? 0; + + return { + beforeHeight: Number.parseFloat(beforeStyles.height), + listHeight, + groupMarginInlineStart: Number.parseFloat(groupStyles.marginInlineStart), + groupPaddingInlineStart: Number.parseFloat(groupStyles.paddingInlineStart), + listPaddingInline: listStyles ? Number.parseFloat(listStyles.paddingInlineStart) : 0, + }; + }); + + expect(separatorMetrics.beforeHeight).toBe(separatorMetrics.listHeight); + expect(separatorMetrics.groupMarginInlineStart).toBeGreaterThan(separatorMetrics.listPaddingInline); + expect(separatorMetrics.groupPaddingInlineStart).toBeGreaterThan(separatorMetrics.listPaddingInline); + }); + test('container-only plain links commit repeated route changes', async ({ mount, page }) => { await mount(); @@ -88,6 +139,16 @@ test.describe('SegmentedNav', () => { await expect(page.getByTestId('current-route')).toHaveText('/route-c'); }); + test('grouped SegmentedNav links commit repeated route changes', async ({ mount, page }) => { + await mount(); + + await page.getByRole('link', { name: 'Route B' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-b'); + + await page.getByRole('link', { name: 'Route C' }).click(); + await expect(page.getByTestId('current-route')).toHaveText('/route-c'); + }); + test('non-SegmentedNav control also commits repeated route changes', async ({ mount, page }) => { await mount(); diff --git a/src/components/SegmentedNav/index.ts b/src/components/SegmentedNav/index.ts index 928b393..e27dec9 100644 --- a/src/components/SegmentedNav/index.ts +++ b/src/components/SegmentedNav/index.ts @@ -2,5 +2,6 @@ export { SegmentedNav } from './parts'; export type { RootProps as SegmentedNavRootProps, + GroupProps as SegmentedNavGroupProps, LinkProps as SegmentedNavLinkProps, } from './parts'; diff --git a/src/components/SegmentedNav/parts.tsx b/src/components/SegmentedNav/parts.tsx index ae2d93c..43c03d5 100644 --- a/src/components/SegmentedNav/parts.tsx +++ b/src/components/SegmentedNav/parts.tsx @@ -23,6 +23,15 @@ export const Root = React.forwardRef(function Root( ); }); +export interface GroupProps extends React.ComponentPropsWithoutRef<'div'> {} + +export const Group = React.forwardRef(function Group( + { className, ...props }, + ref +) { + return
; +}); + export interface LinkProps extends Omit, 'href'> { active?: boolean; render: React.ReactElement; @@ -44,9 +53,11 @@ export const Link = React.forwardRef(function Link if (process.env.NODE_ENV !== 'production') { Root.displayName = 'SegmentedNav'; + Group.displayName = 'SegmentedNav.Group'; Link.displayName = 'SegmentedNav.Link'; } export const SegmentedNav = Object.assign(Root, { + Group, Link, }); diff --git a/src/index.ts b/src/index.ts index e9ad27b..282bb56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,7 +79,11 @@ export { Progress } from './components/Progress'; export { Radio } from './components/Radio'; export { Select } from './components/Select'; export { SegmentedNav } from './components/SegmentedNav'; -export type { SegmentedNavRootProps, SegmentedNavLinkProps } from './components/SegmentedNav'; +export type { + SegmentedNavRootProps, + SegmentedNavGroupProps, + SegmentedNavLinkProps, +} from './components/SegmentedNav'; export { Sidebar } from './components/Sidebar'; export { Skeleton } from './components/Skeleton'; export { Table } from './components/Table';