@lovett/ui 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/styles.css CHANGED
@@ -27,6 +27,7 @@
27
27
  * .scrollbar-hide — ChipNav
28
28
  * .ds-enter-pop, .ds-enter-rise — DropdownMenu,
29
29
  * FloatingStatusBar
30
+ * .ds-sidebar-group-body — SidebarNav.Group
30
31
  * prefers-reduced-motion block — accessibility
31
32
  *
32
33
  * Out of scope for Phase 1: .empty, .skel, .field*, .pg-header, .search-bar,
@@ -2298,3 +2299,25 @@
2298
2299
  outline-offset: 2px;
2299
2300
  }
2300
2301
  }
2302
+
2303
+ /* ---- SIDEBAR GROUP COLLAPSE ---- */
2304
+ /**
2305
+ * SidebarNav.Group's body collapses through `grid-template-rows` 1fr -> 0fr,
2306
+ * NOT height: the content keeps its natural size for the whole transition
2307
+ * and is clipped by the shrinking track, so nothing inside reflows or jumps
2308
+ * while it closes. The inner element must be `min-height: 0; overflow: hidden`
2309
+ * (a grid item otherwise refuses to shrink below its content).
2310
+ */
2311
+ .ds-sidebar-group-body {
2312
+ display: grid;
2313
+ grid-template-rows: 0fr;
2314
+ transition: grid-template-rows var(--dur-fast) var(--ease-out);
2315
+ }
2316
+ .ds-sidebar-group-body[data-open='true'] {
2317
+ grid-template-rows: 1fr;
2318
+ }
2319
+ @media (prefers-reduced-motion: reduce) {
2320
+ .ds-sidebar-group-body {
2321
+ transition: none;
2322
+ }
2323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovett/ui",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
@@ -0,0 +1,162 @@
1
+ /**
2
+ * SidebarNav — the contract a router-driven rail depends on.
3
+ *
4
+ * Active is the CALLER's (aria-current lands where they say, nowhere else);
5
+ * `asChild` makes the child the row without adding a wrapper; a group is a
6
+ * real disclosure (aria-expanded + aria-controls resolve, closed items are
7
+ * inert); a closed group that contains the location carries the active look
8
+ * so the location is never hidden; the collapsed rail turns a group into a
9
+ * flyout whose items render at full width.
10
+ */
11
+ import { useState } from 'react'
12
+ import { describe, expect, it, vi } from 'vitest'
13
+ import { render, screen, within } from '@testing-library/react'
14
+ import userEvent from '@testing-library/user-event'
15
+ import { Gauge, Home, Upload } from 'lucide-react'
16
+ import { SidebarNav } from '../sidebar-nav'
17
+
18
+ function Rail({ collapsed = false, active = '/upload' }: { collapsed?: boolean; active?: string }) {
19
+ return (
20
+ <SidebarNav collapsed={collapsed} aria-label="Primary">
21
+ <SidebarNav.Item icon={Home} label="Dashboard" href="/" active={active === '/'} />
22
+ <SidebarNav.Group id="audit" icon={Gauge} label="Audit" active={active === '/upload'} defaultOpen>
23
+ <SidebarNav.Item icon={Upload} label="New audit" href="/upload" active={active === '/upload'} badge="Beta" />
24
+ </SidebarNav.Group>
25
+ </SidebarNav>
26
+ )
27
+ }
28
+
29
+ describe('SidebarNav', () => {
30
+ it('is a nav; the active item and only the active item is aria-current', () => {
31
+ render(<Rail />)
32
+ expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
33
+ expect(screen.getByRole('link', { name: /New audit/ })).toHaveAttribute('aria-current', 'page')
34
+ expect(screen.getByRole('link', { name: 'Dashboard' })).not.toHaveAttribute('aria-current')
35
+ })
36
+
37
+ it('asChild makes the child the row - no wrapper, the row class on the child', () => {
38
+ render(
39
+ <SidebarNav>
40
+ <SidebarNav.Item asChild icon={Home} label="Dashboard" active>
41
+ <a href="/" data-testid="child" />
42
+ </SidebarNav.Item>
43
+ </SidebarNav>,
44
+ )
45
+ const child = screen.getByTestId('child')
46
+ expect(child.tagName).toBe('A')
47
+ expect(child).toHaveAttribute('aria-current', 'page')
48
+ expect(child).toHaveTextContent('Dashboard')
49
+ expect(child.parentElement?.tagName).toBe('NAV')
50
+ })
51
+
52
+ it('asChild refuses anything but a single element', () => {
53
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
54
+ expect(() =>
55
+ render(
56
+ <SidebarNav>
57
+ <SidebarNav.Item asChild icon={Home} label="Dashboard">
58
+ plain text
59
+ </SidebarNav.Item>
60
+ </SidebarNav>,
61
+ ),
62
+ ).toThrow(/exactly one element child/)
63
+ spy.mockRestore()
64
+ })
65
+
66
+ describe('Group', () => {
67
+ it('is a disclosure: aria-expanded, aria-controls resolves, toggles on click', async () => {
68
+ const user = userEvent.setup()
69
+ render(<Rail />)
70
+ const header = screen.getByRole('button', { name: 'Audit' })
71
+ expect(header).toHaveAttribute('aria-expanded', 'true')
72
+ const bodyId = header.getAttribute('aria-controls')
73
+ expect(bodyId).toBeTruthy()
74
+ const body = document.getElementById(bodyId as string)
75
+ expect(body).not.toBeNull()
76
+ expect(body).toHaveAttribute('role', 'group')
77
+ expect(within(body as HTMLElement).getByRole('link', { name: /New audit/ })).toBeInTheDocument()
78
+
79
+ await user.click(header)
80
+ expect(header).toHaveAttribute('aria-expanded', 'false')
81
+ // Closed items stay mounted (the collapse animates) but are inert.
82
+ expect(body).toHaveAttribute('inert')
83
+ expect(body).toHaveAttribute('aria-hidden', 'true')
84
+ })
85
+
86
+ it('controlled: onToggle reports the next state and open is the prop', async () => {
87
+ const user = userEvent.setup()
88
+ const onToggle = vi.fn()
89
+ function Controlled() {
90
+ const [open, setOpen] = useState(false)
91
+ return (
92
+ <SidebarNav>
93
+ <SidebarNav.Group
94
+ id="g"
95
+ icon={Gauge}
96
+ label="Audit"
97
+ open={open}
98
+ onToggle={(next) => {
99
+ onToggle(next)
100
+ setOpen(next)
101
+ }}
102
+ >
103
+ <SidebarNav.Item icon={Upload} label="New audit" href="/upload" />
104
+ </SidebarNav.Group>
105
+ </SidebarNav>
106
+ )
107
+ }
108
+ render(<Controlled />)
109
+ const header = screen.getByRole('button', { name: 'Audit' })
110
+ expect(header).toHaveAttribute('aria-expanded', 'false')
111
+ await user.click(header)
112
+ expect(onToggle).toHaveBeenCalledWith(true)
113
+ expect(header).toHaveAttribute('aria-expanded', 'true')
114
+ })
115
+
116
+ it('a CLOSED group that contains the location carries the active look; an open one does not', async () => {
117
+ const user = userEvent.setup()
118
+ render(<Rail />)
119
+ const header = screen.getByRole('button', { name: 'Audit' })
120
+ // Open: the child row is active, the header is quiet.
121
+ expect(header.className).not.toMatch(/sidebar-surface-active/)
122
+ await user.click(header)
123
+ // Closed: the header takes the active surface so the location is visible.
124
+ expect(header.className).toMatch(/sidebar-surface-active/)
125
+ })
126
+
127
+ it('a badge renders on the header and on a nested item', () => {
128
+ render(
129
+ <SidebarNav>
130
+ <SidebarNav.Group id="g" icon={Gauge} label="Audit" badge="3" defaultOpen>
131
+ <SidebarNav.Item icon={Upload} label="New audit" href="/upload" badge="Beta" />
132
+ </SidebarNav.Group>
133
+ </SidebarNav>,
134
+ )
135
+ expect(screen.getByRole('button', { name: /Audit 3/ })).toBeInTheDocument()
136
+ expect(screen.getByRole('link', { name: /New audit Beta/ })).toBeInTheDocument()
137
+ })
138
+ })
139
+
140
+ describe('collapsed rail', () => {
141
+ it('items keep their name through a title and drop the label text', () => {
142
+ render(<Rail collapsed active="/" />)
143
+ const home = screen.getByRole('link', { name: 'Dashboard' })
144
+ expect(home).toHaveAttribute('title', 'Dashboard')
145
+ expect(home.querySelector('span.truncate')).toBeNull()
146
+ })
147
+
148
+ it('a group is a tile that opens a flyout holding its items at full width', async () => {
149
+ const user = userEvent.setup()
150
+ render(<Rail collapsed />)
151
+ const tile = screen.getByRole('button', { name: 'Audit' })
152
+ expect(tile).toHaveAttribute('aria-haspopup', 'dialog')
153
+ expect(screen.queryByRole('link', { name: /New audit/ })).toBeNull()
154
+ await user.click(tile)
155
+ const flyout = await screen.findByRole('dialog', { name: 'Audit' })
156
+ const item = within(flyout).getByRole('link', { name: /New audit/ })
157
+ // Full width inside the flyout: the label is text, not a title.
158
+ expect(item).toHaveTextContent('New audit')
159
+ expect(item).not.toHaveAttribute('title')
160
+ })
161
+ })
162
+ })
package/src/index.ts CHANGED
@@ -23,6 +23,17 @@ export {
23
23
  export { default as BrandLogoTile, type BrandLogoTileProps, type BrandLogoTileSize } from './brand-logo-tile'
24
24
  export { default as ProfileSection, type ProfileSectionProps } from './profile-section'
25
25
  export { default as ChipNav, type ChipNavItem } from './chip-nav'
26
+ // SidebarNav — a rail of destinations with collapsible groups (submenus) and
27
+ // an icon-only mode whose groups open a Popover flyout. Router-agnostic:
28
+ // `asChild` makes a NavLink the row. Promoted from meta-ads-audit-dashboard's
29
+ // AppSidebar; the workspace context panel is the second consumer to migrate.
30
+ export {
31
+ SidebarNav,
32
+ type SidebarNavProps,
33
+ type SidebarNavItemProps,
34
+ type SidebarNavGroupProps,
35
+ type SidebarIcon,
36
+ } from './sidebar-nav'
26
37
  export { default as CompletionRing, type CompletionRingProps } from './completion-ring'
27
38
  export { default as MicrosoftLogo, type MicrosoftLogoProps } from './microsoft-logo'
28
39
  export {
@@ -0,0 +1,360 @@
1
+ /**
2
+ * SidebarNav — a vertical rail of destinations with collapsible groups.
3
+ *
4
+ * Compound: `<SidebarNav>` (rail state) + `<SidebarNav.Item>` (one
5
+ * destination) + `<SidebarNav.Group>` (a labelled, collapsible set of items).
6
+ * Replaces the labelled-section rail — five tracked-out uppercase captions
7
+ * over flat lists — with submenus: a group is a row you open, and the rail
8
+ * reads as four or five things instead of eighteen.
9
+ *
10
+ * Router-agnostic. An Item renders an `<a href>` by default; pass `asChild`
11
+ * and the single child element (a react-router `NavLink`, a `Link`) becomes
12
+ * the row — it receives the className, the `aria-current` and the composed
13
+ * children. The ACTIVE state is the caller's, never inferred from a URL:
14
+ * the primitive does not own a router.
15
+ *
16
+ * Group is controlled (`open` + `onToggle`) or uncontrolled (`defaultOpen`).
17
+ * `active` marks a group that CONTAINS the current route, so the rail never
18
+ * loses the current location behind a closed group. Opening is a plain
19
+ * button (`aria-expanded`, `aria-controls`); the body collapses through
20
+ * `grid-template-rows` so its content keeps its size while it closes
21
+ * (`.ds-sidebar-group-body` in styles.css; honours reduced motion).
22
+ *
23
+ * Collapsed rail (`collapsed` on the root): Items become icon tiles with a
24
+ * `title`; a Group becomes an icon tile that opens a `Popover` flyout to its
25
+ * right holding the group's items at full width. Inside the flyout the rail
26
+ * is not collapsed, so the same Item markup renders both ways.
27
+ *
28
+ * Token discipline: reads the INTERNAL `--sidebar-*` set (sidebar surfaces,
29
+ * text, rail) — allowed in packages/ui, ESLint-enforced elsewhere — plus
30
+ * public `--accent`, `--radius-*`, `--ring-focus`, `--dur-fast`, `--ease-out`.
31
+ * Icons are the caller's Lucide components.
32
+ *
33
+ * Promoted from meta-ads-audit-dashboard's app-local `AppSidebar` (the
34
+ * consumer that needed groups); the workspace app's context panel is the
35
+ * second rail shaped like this and the migration target.
36
+ *
37
+ * Usage:
38
+ *
39
+ * <SidebarNav collapsed={collapsed} aria-label="Primary">
40
+ * <SidebarNav.Item asChild icon={LayoutDashboard} label="Dashboard" active={onHome}>
41
+ * <NavLink to="/" end />
42
+ * </SidebarNav.Item>
43
+ * <SidebarNav.Group id="audit" icon={ClipboardCheck} label="Audit" active={inAudit}
44
+ * open={open === 'audit'} onToggle={() => setOpen(...)}>
45
+ * <SidebarNav.Item asChild icon={Upload} label="New audit" active={…}>
46
+ * <NavLink to="/upload" end />
47
+ * </SidebarNav.Item>
48
+ * </SidebarNav.Group>
49
+ * </SidebarNav>
50
+ */
51
+
52
+ import {
53
+ cloneElement,
54
+ createContext,
55
+ isValidElement,
56
+ useContext,
57
+ useId,
58
+ useState,
59
+ type AnchorHTMLAttributes,
60
+ type ComponentType,
61
+ type HTMLAttributes,
62
+ type ReactElement,
63
+ type ReactNode,
64
+ type SVGProps,
65
+ } from 'react'
66
+ import { ChevronDown } from 'lucide-react'
67
+ import { cn } from './lib/utils'
68
+ import { Popover } from './popover'
69
+
70
+ /** A Lucide icon, or anything with the same signature. */
71
+ export type SidebarIcon = ComponentType<SVGProps<SVGSVGElement>>
72
+
73
+ interface RailContext {
74
+ collapsed: boolean
75
+ /** True inside a Group's body — items indent and draw the connector rail. */
76
+ nested: boolean
77
+ }
78
+
79
+ const Ctx = createContext<RailContext>({ collapsed: false, nested: false })
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Root
83
+
84
+ export interface SidebarNavProps extends HTMLAttributes<HTMLElement> {
85
+ /** Icon-only rail. Items show a title; groups open a flyout. */
86
+ collapsed?: boolean
87
+ children: ReactNode
88
+ }
89
+
90
+ function SidebarNavRoot({ collapsed = false, className, children, ...rest }: SidebarNavProps) {
91
+ return (
92
+ <Ctx.Provider value={{ collapsed, nested: false }}>
93
+ <nav className={cn('flex flex-col gap-0.5', className)} {...rest}>
94
+ {children}
95
+ </nav>
96
+ </Ctx.Provider>
97
+ )
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Row chrome shared by Item and Group header
102
+
103
+ const ROW_BASE =
104
+ 'relative flex h-9 w-full items-center gap-3 rounded-[var(--radius-md)] px-2.5 text-left text-sm font-medium outline-none transition-colors ' +
105
+ 'focus-visible:[box-shadow:var(--ring-focus)]'
106
+
107
+ const ROW_REST =
108
+ 'text-[rgb(var(--sidebar-text))] hover:bg-[rgb(var(--sidebar-surface-hover))] hover:text-[rgb(var(--sidebar-text-bright))]'
109
+
110
+ const ROW_ACTIVE = 'bg-[rgb(var(--sidebar-surface-active))] text-[rgb(var(--sidebar-text-bright))]'
111
+
112
+ /** The accent bar on the leading edge of the active row. */
113
+ function ActiveBar() {
114
+ return (
115
+ <span
116
+ aria-hidden="true"
117
+ className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-[rgb(var(--accent))]"
118
+ />
119
+ )
120
+ }
121
+
122
+ function RowIcon({ icon: Icon }: { icon: SidebarIcon }) {
123
+ return <Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
124
+ }
125
+
126
+ function RowBadge({ children }: { children: ReactNode }) {
127
+ return (
128
+ <span className="rounded-full bg-[rgb(var(--accent))]/15 px-1.5 py-0.5 text-[10px] font-semibold text-[rgb(var(--accent))]">
129
+ {children}
130
+ </span>
131
+ )
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Item
136
+
137
+ export interface SidebarNavItemProps
138
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'children'> {
139
+ icon: SidebarIcon
140
+ label: string
141
+ /** The current destination. Rendered as `aria-current="page"`. */
142
+ active?: boolean
143
+ /** A small trailing tag — "Beta", a count. Hidden when collapsed. */
144
+ badge?: ReactNode
145
+ /**
146
+ * Render the single child element as the row instead of an `<a>`. The child
147
+ * receives the className, `aria-current` and the composed row content. It
148
+ * must be a link or keyboard-operable on its own.
149
+ */
150
+ asChild?: boolean
151
+ children?: ReactNode
152
+ }
153
+
154
+ type ItemChildProps = HTMLAttributes<HTMLElement> & {
155
+ 'aria-current'?: 'page' | undefined
156
+ title?: string | undefined
157
+ }
158
+
159
+ function SidebarNavItem({
160
+ icon,
161
+ label,
162
+ active = false,
163
+ badge,
164
+ asChild,
165
+ className,
166
+ children,
167
+ ...rest
168
+ }: SidebarNavItemProps) {
169
+ const { collapsed, nested } = useContext(Ctx)
170
+
171
+ const rowClass = cn(
172
+ ROW_BASE,
173
+ active ? ROW_ACTIVE : ROW_REST,
174
+ collapsed && 'justify-center px-0',
175
+ nested && !collapsed && 'h-8 pl-[2.375rem] text-[13px]',
176
+ className,
177
+ )
178
+ const content = (
179
+ <>
180
+ {active && !nested && <ActiveBar />}
181
+ {(!nested || collapsed) && <RowIcon icon={icon} />}
182
+ {nested && !collapsed && (
183
+ // The connector: a dot on the group's rail, filled when active.
184
+ <span
185
+ aria-hidden="true"
186
+ className={cn(
187
+ 'absolute left-[1.0625rem] top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors',
188
+ active ? 'bg-[rgb(var(--accent))]' : 'bg-[rgb(var(--sidebar-rail))]',
189
+ )}
190
+ />
191
+ )}
192
+ {!collapsed && <span className="flex-1 truncate">{label}</span>}
193
+ {/* The space is for the accessible name: "New audit Beta", not "New auditBeta".
194
+ A whitespace-only text node is invisible in a flex row. */}
195
+ {!collapsed && badge !== undefined && badge !== null && (
196
+ <>
197
+ {' '}
198
+ <RowBadge>{badge}</RowBadge>
199
+ </>
200
+ )}
201
+ </>
202
+ )
203
+ const shared: ItemChildProps = {
204
+ className: rowClass,
205
+ 'aria-current': active ? 'page' : undefined,
206
+ title: collapsed ? label : undefined,
207
+ }
208
+
209
+ if (asChild) {
210
+ if (!isValidElement<ItemChildProps>(children)) {
211
+ throw new Error('SidebarNav.Item with `asChild` expects exactly one element child.')
212
+ }
213
+ const child = children as ReactElement<ItemChildProps>
214
+ return cloneElement(child, {
215
+ ...shared,
216
+ className: cn(child.props.className, rowClass),
217
+ children: content,
218
+ })
219
+ }
220
+
221
+ return (
222
+ <a {...rest} {...shared}>
223
+ {content}
224
+ </a>
225
+ )
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Group
230
+
231
+ export interface SidebarNavGroupProps {
232
+ /** Stable id — the body's DOM id derives from it. */
233
+ id: string
234
+ icon: SidebarIcon
235
+ label: string
236
+ /** True when a child is the current destination. */
237
+ active?: boolean
238
+ /** Controlled open state. Omit for internal state. */
239
+ open?: boolean
240
+ defaultOpen?: boolean
241
+ onToggle?: (open: boolean) => void
242
+ badge?: ReactNode
243
+ children: ReactNode
244
+ }
245
+
246
+ function SidebarNavGroup({
247
+ id,
248
+ icon,
249
+ label,
250
+ active = false,
251
+ open: controlledOpen,
252
+ defaultOpen = false,
253
+ onToggle,
254
+ badge,
255
+ children,
256
+ }: SidebarNavGroupProps) {
257
+ const { collapsed } = useContext(Ctx)
258
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
259
+ const open = controlledOpen ?? uncontrolledOpen
260
+ const setOpen = (next: boolean) => {
261
+ if (controlledOpen === undefined) setUncontrolledOpen(next)
262
+ onToggle?.(next)
263
+ }
264
+ const reactId = useId()
265
+ const bodyId = `sidebar-group-${id}-${reactId}`
266
+ const labelId = `${bodyId}-label`
267
+
268
+ if (collapsed) {
269
+ // The flyout: the group's items at full width, to the right of the tile.
270
+ return (
271
+ <Popover>
272
+ <Popover.Trigger asChild>
273
+ <button
274
+ type="button"
275
+ title={label}
276
+ className={cn(ROW_BASE, 'justify-center px-0', active ? ROW_ACTIVE : ROW_REST)}
277
+ >
278
+ {active && <ActiveBar />}
279
+ <RowIcon icon={icon} />
280
+ </button>
281
+ </Popover.Trigger>
282
+ <Popover.Content side="right" align="start" offset={8} aria-label={label} className="min-w-[13rem] p-1.5">
283
+ <Ctx.Provider value={{ collapsed: false, nested: false }}>
284
+ <p
285
+ id={labelId}
286
+ className="px-2.5 pb-1 pt-1 text-[11px] font-semibold text-[rgb(var(--text-tertiary))]"
287
+ >
288
+ {label}
289
+ </p>
290
+ <div role="group" aria-labelledby={labelId} className="flex flex-col gap-0.5">
291
+ {children}
292
+ </div>
293
+ </Ctx.Provider>
294
+ </Popover.Content>
295
+ </Popover>
296
+ )
297
+ }
298
+
299
+ return (
300
+ <div className="flex flex-col">
301
+ <button
302
+ type="button"
303
+ onClick={() => setOpen(!open)}
304
+ aria-expanded={open}
305
+ aria-controls={bodyId}
306
+ id={labelId}
307
+ // Closed and containing the current route: the header carries the
308
+ // active look so the location is never hidden. Open, the child row
309
+ // carries it and the header stays quiet.
310
+ className={cn(ROW_BASE, active && !open ? ROW_ACTIVE : ROW_REST)}
311
+ >
312
+ {active && !open && <ActiveBar />}
313
+ <RowIcon icon={icon} />
314
+ <span id={`${labelId}-text`} className="flex-1 truncate">
315
+ {label}
316
+ </span>
317
+ {badge !== undefined && badge !== null && (
318
+ <>
319
+ {' '}
320
+ <RowBadge>{badge}</RowBadge>
321
+ </>
322
+ )}
323
+ <ChevronDown
324
+ aria-hidden="true"
325
+ className="h-3.5 w-3.5 shrink-0 text-[rgb(var(--sidebar-text-dim))] transition-transform duration-150 ease-out motion-reduce:transition-none"
326
+ style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
327
+ />
328
+ </button>
329
+ <div className="ds-sidebar-group-body" data-open={open ? 'true' : 'false'}>
330
+ <div className="min-h-0 overflow-hidden">
331
+ <Ctx.Provider value={{ collapsed: false, nested: true }}>
332
+ <div
333
+ id={bodyId}
334
+ role="group"
335
+ aria-labelledby={`${labelId}-text`}
336
+ // The rail: one hairline under the icon column that the child
337
+ // connectors sit on. Its geometry derives from the icon centre
338
+ // (px-2.5 + half of h-4 = 1.0625rem).
339
+ className="relative flex flex-col gap-0.5 py-0.5 before:absolute before:bottom-1 before:left-[1.0625rem] before:top-1 before:w-px before:-translate-x-1/2 before:bg-[rgb(var(--sidebar-rail))]"
340
+ // Stays in the DOM while closed so the track has something to
341
+ // shrink over; `inert` keeps its links out of the tab ring and
342
+ // the accessibility tree until it is open again.
343
+ inert={!open}
344
+ aria-hidden={!open}
345
+ >
346
+ {children}
347
+ </div>
348
+ </Ctx.Provider>
349
+ </div>
350
+ </div>
351
+ </div>
352
+ )
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+
357
+ export const SidebarNav = Object.assign(SidebarNavRoot, {
358
+ Item: SidebarNavItem,
359
+ Group: SidebarNavGroup,
360
+ })
package/src/styles.css CHANGED
@@ -27,6 +27,7 @@
27
27
  * .scrollbar-hide — ChipNav
28
28
  * .ds-enter-pop, .ds-enter-rise — DropdownMenu,
29
29
  * FloatingStatusBar
30
+ * .ds-sidebar-group-body — SidebarNav.Group
30
31
  * prefers-reduced-motion block — accessibility
31
32
  *
32
33
  * Out of scope for Phase 1: .empty, .skel, .field*, .pg-header, .search-bar,
@@ -2298,3 +2299,25 @@
2298
2299
  outline-offset: 2px;
2299
2300
  }
2300
2301
  }
2302
+
2303
+ /* ---- SIDEBAR GROUP COLLAPSE ---- */
2304
+ /**
2305
+ * SidebarNav.Group's body collapses through `grid-template-rows` 1fr -> 0fr,
2306
+ * NOT height: the content keeps its natural size for the whole transition
2307
+ * and is clipped by the shrinking track, so nothing inside reflows or jumps
2308
+ * while it closes. The inner element must be `min-height: 0; overflow: hidden`
2309
+ * (a grid item otherwise refuses to shrink below its content).
2310
+ */
2311
+ .ds-sidebar-group-body {
2312
+ display: grid;
2313
+ grid-template-rows: 0fr;
2314
+ transition: grid-template-rows var(--dur-fast) var(--ease-out);
2315
+ }
2316
+ .ds-sidebar-group-body[data-open='true'] {
2317
+ grid-template-rows: 1fr;
2318
+ }
2319
+ @media (prefers-reduced-motion: reduce) {
2320
+ .ds-sidebar-group-body {
2321
+ transition: none;
2322
+ }
2323
+ }