Skip to content

Commit 72b2ff5

Browse files
authored
Add SegmentedNav primitive for route-based navigation (#123)
## Summary - add a new `SegmentedNav` primitive for route-based section navigation - include grouped composition support, Storybook coverage, and component tests - wire the demo page and package exports so consumers can adopt it directly ## Test plan - [x] `npm run lint` - [x] `npm test -- src/components/SegmentedNav/SegmentedNav.test.tsx` Made with [Cursor](https://cursor.com)
1 parent 5547402 commit 72b2ff5

8 files changed

Lines changed: 763 additions & 0 deletions

File tree

src/app/page.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { PhoneInput } from '@/components/PhoneInput';
3737
import { Progress } from '@/components/Progress';
3838
import { Radio } from '@/components/Radio';
3939
import { Select } from '@/components/Select';
40+
import { SegmentedNav } from '@/components/SegmentedNav';
4041
import { Separator } from '@/components/Separator';
4142
import { Sidebar } from '@/components/Sidebar';
4243
import { Skeleton } from '@/components/Skeleton';
@@ -1708,6 +1709,78 @@ function DatePickerDemo() {
17081709
);
17091710
}
17101711

1712+
function SegmentedNavDemo({
1713+
ariaLabel,
1714+
items,
1715+
initialActive,
1716+
}: {
1717+
ariaLabel: string;
1718+
items: string[];
1719+
initialActive: string;
1720+
}) {
1721+
const [activeItem, setActiveItem] = React.useState(initialActive);
1722+
1723+
return (
1724+
<SegmentedNav aria-label={ariaLabel}>
1725+
{items.map((item) => (
1726+
<SegmentedNav.Link
1727+
key={item}
1728+
active={activeItem === item}
1729+
render={
1730+
<a
1731+
href="/"
1732+
onClick={(event) => {
1733+
event.preventDefault();
1734+
setActiveItem(item);
1735+
}}
1736+
/>
1737+
}
1738+
>
1739+
{item}
1740+
</SegmentedNav.Link>
1741+
))}
1742+
</SegmentedNav>
1743+
);
1744+
}
1745+
1746+
function GroupedSegmentedNavDemo({
1747+
ariaLabel,
1748+
groups,
1749+
initialActive,
1750+
}: {
1751+
ariaLabel: string;
1752+
groups: string[][];
1753+
initialActive: string;
1754+
}) {
1755+
const [activeItem, setActiveItem] = React.useState(initialActive);
1756+
1757+
return (
1758+
<SegmentedNav aria-label={ariaLabel}>
1759+
{groups.map((group, index) => (
1760+
<SegmentedNav.Group key={`group-${index}`}>
1761+
{group.map((item) => (
1762+
<SegmentedNav.Link
1763+
key={item}
1764+
active={activeItem === item}
1765+
render={
1766+
<a
1767+
href="/"
1768+
onClick={(event) => {
1769+
event.preventDefault();
1770+
setActiveItem(item);
1771+
}}
1772+
/>
1773+
}
1774+
>
1775+
{item}
1776+
</SegmentedNav.Link>
1777+
))}
1778+
</SegmentedNav.Group>
1779+
))}
1780+
</SegmentedNav>
1781+
);
1782+
}
1783+
17111784
export default function Home() {
17121785
return (
17131786
<main style={{ padding: '2rem', maxWidth: '600px' }}>
@@ -3478,6 +3551,39 @@ export default function Home() {
34783551
<Radio.Error match>Error text goes here.</Radio.Error>
34793552
</Radio.Field>
34803553
</div>
3554+
<h2 style={{ marginBottom: '1rem' }}>SegmentedNav Component</h2>
3555+
3556+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', marginBottom: '128px' }}>
3557+
<div>
3558+
<span style={{ fontSize: '14px', color: '#7c7c7c', marginBottom: '0.5rem', display: 'block' }}>Flat links</span>
3559+
<SegmentedNavDemo
3560+
ariaLabel="Payout sections"
3561+
items={['Overview', 'Activity', 'Recipients', 'Customers']}
3562+
initialActive="Activity"
3563+
/>
3564+
</div>
3565+
3566+
<div>
3567+
<span style={{ fontSize: '14px', color: '#7c7c7c', marginBottom: '0.5rem', display: 'block' }}>Grouped links</span>
3568+
<GroupedSegmentedNavDemo
3569+
ariaLabel="Grouped payout sections"
3570+
groups={[
3571+
['Overview', 'Platform payouts', 'Recipients'],
3572+
['Customer payouts'],
3573+
]}
3574+
initialActive="Platform payouts"
3575+
/>
3576+
</div>
3577+
3578+
<div>
3579+
<span style={{ fontSize: '14px', color: '#7c7c7c', marginBottom: '0.5rem', display: 'block' }}>Longer labels</span>
3580+
<SegmentedNavDemo
3581+
ariaLabel="Customer payout sections"
3582+
items={['Customer overview', 'Platform payouts', 'Reconciliation']}
3583+
initialActive="Platform payouts"
3584+
/>
3585+
</div>
3586+
</div>
34813587
<h2 style={{ marginBottom: '1rem' }}>Select Component</h2>
34823588

34833589
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', marginBottom: '128px', maxWidth: '256px' }}>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
@use '../../tokens/text-styles' as *;
2+
@use '../../tokens/mixins' as *;
3+
4+
.root {
5+
display: inline-flex;
6+
align-items: center;
7+
}
8+
9+
.list {
10+
display: inline-flex;
11+
align-items: center;
12+
padding: var(--spacing-3xs);
13+
overflow: clip;
14+
background: var(--surface-secondary);
15+
@include smooth-corners(var(--corner-radius-sm));
16+
}
17+
18+
.list > .link + .link {
19+
margin-inline-start: var(--spacing-2xs);
20+
}
21+
22+
.group {
23+
position: relative;
24+
display: inline-flex;
25+
gap: var(--spacing-2xs);
26+
align-items: center;
27+
}
28+
29+
.group + .group {
30+
margin-inline-start: var(--spacing-xs);
31+
padding-inline-start: var(--spacing-xs);
32+
33+
&::before {
34+
content: '';
35+
position: absolute;
36+
top: calc(-1 * var(--spacing-3xs));
37+
bottom: calc(-1 * var(--spacing-3xs));
38+
inset-inline-start: 0;
39+
width: var(--stroke-xs);
40+
background: var(--border-secondary);
41+
}
42+
}
43+
44+
.link {
45+
@include label;
46+
display: inline-flex;
47+
align-items: center;
48+
justify-content: center;
49+
min-height: 32px;
50+
padding: 0 var(--spacing-md);
51+
border: var(--stroke-xs) solid transparent;
52+
@include smooth-corners(var(--corner-radius-xs));
53+
color: var(--text-primary);
54+
text-decoration: none;
55+
white-space: nowrap;
56+
outline: none;
57+
transition:
58+
background-color 150ms ease,
59+
border-color 150ms ease,
60+
box-shadow 150ms ease,
61+
color 150ms ease;
62+
63+
&[aria-current='page'] {
64+
background: var(--surface-panel);
65+
border-color: var(--border-primary);
66+
box-shadow: var(--shadow-sm);
67+
color: var(--text-primary);
68+
}
69+
70+
&:focus-visible {
71+
background: var(--surface-panel);
72+
border-color: var(--border-secondary);
73+
box-shadow: var(--input-focus);
74+
color: var(--text-primary);
75+
}
76+
}
77+
78+
@media (prefers-reduced-motion: reduce) {
79+
.link {
80+
transition: none;
81+
}
82+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { SegmentedNav } from './';
3+
4+
const meta = {
5+
title: 'Components/SegmentedNav',
6+
component: SegmentedNav,
7+
parameters: {
8+
layout: 'padded',
9+
},
10+
tags: ['autodocs'],
11+
} satisfies Meta<typeof SegmentedNav>;
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof meta>;
16+
17+
export const Default: Story = {
18+
render: () => (
19+
<SegmentedNav aria-label="Payout sections">
20+
<SegmentedNav.Link render={<a href="/payouts" />}>
21+
Overview
22+
</SegmentedNav.Link>
23+
<SegmentedNav.Link active render={<a href="/payouts/activity" />}>
24+
Activity
25+
</SegmentedNav.Link>
26+
<SegmentedNav.Link render={<a href="/payouts/recipients" />}>
27+
Recipients
28+
</SegmentedNav.Link>
29+
<SegmentedNav.Link render={<a href="/payouts/customers" />}>
30+
Customers
31+
</SegmentedNav.Link>
32+
</SegmentedNav>
33+
),
34+
};
35+
36+
export const Grouped: Story = {
37+
render: () => (
38+
<SegmentedNav aria-label="Payout sections">
39+
<SegmentedNav.Group>
40+
<SegmentedNav.Link render={<a href="/payouts" />}>
41+
Overview
42+
</SegmentedNav.Link>
43+
<SegmentedNav.Link active render={<a href="/payouts/activity" />}>
44+
Platform payouts
45+
</SegmentedNav.Link>
46+
<SegmentedNav.Link render={<a href="/payouts/recipients" />}>
47+
Recipients
48+
</SegmentedNav.Link>
49+
</SegmentedNav.Group>
50+
<SegmentedNav.Group>
51+
<SegmentedNav.Link render={<a href="/payouts/customers" />}>
52+
Customer payouts
53+
</SegmentedNav.Link>
54+
</SegmentedNav.Group>
55+
</SegmentedNav>
56+
),
57+
};
58+
59+
export const PlainAnchors: Story = {
60+
render: () => (
61+
<SegmentedNav aria-label="Balance sections">
62+
<SegmentedNav.Link active render={<a href="/balances" />}>
63+
Balances
64+
</SegmentedNav.Link>
65+
<SegmentedNav.Link render={<a href="/balances/activity" />}>
66+
Activity
67+
</SegmentedNav.Link>
68+
<SegmentedNav.Link render={<a href="/balances/reports" />}>
69+
Reports
70+
</SegmentedNav.Link>
71+
</SegmentedNav>
72+
),
73+
};
74+
75+
export const LongerLabels: Story = {
76+
render: () => (
77+
<SegmentedNav aria-label="Customer payout sections">
78+
<SegmentedNav.Link render={<a href="/customers/overview" />}>
79+
Customer overview
80+
</SegmentedNav.Link>
81+
<SegmentedNav.Link active render={<a href="/customers/platform-payouts" />}>
82+
Platform payouts
83+
</SegmentedNav.Link>
84+
<SegmentedNav.Link render={<a href="/customers/reconciliation" />}>
85+
Reconciliation
86+
</SegmentedNav.Link>
87+
</SegmentedNav>
88+
),
89+
};

0 commit comments

Comments
 (0)