@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/index.d.ts +46 -2
- package/dist/index.js +179 -6
- package/dist/index.js.map +1 -1
- package/dist/styles.css +23 -0
- package/package.json +1 -1
- package/src/__tests__/sidebar-nav.test.tsx +162 -0
- package/src/index.ts +11 -0
- package/src/sidebar-nav.tsx +360 -0
- package/src/styles.css +23 -0
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
|
@@ -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
|
+
}
|