@lovett/ui 0.2.8 → 0.2.10
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.js +78 -74
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/sidebar-nav.test.tsx +16 -2
- package/src/sidebar-nav.tsx +147 -81
- package/src/styles.css +1 -1
package/dist/styles.css
CHANGED
|
@@ -2311,7 +2311,7 @@
|
|
|
2311
2311
|
.ds-sidebar-group-body {
|
|
2312
2312
|
display: grid;
|
|
2313
2313
|
grid-template-rows: 0fr;
|
|
2314
|
-
transition: grid-template-rows var(--dur-
|
|
2314
|
+
transition: grid-template-rows var(--dur-base) var(--ease-out);
|
|
2315
2315
|
}
|
|
2316
2316
|
.ds-sidebar-group-body[data-open='true'] {
|
|
2317
2317
|
grid-template-rows: 1fr;
|
package/package.json
CHANGED
|
@@ -138,11 +138,25 @@ describe('SidebarNav', () => {
|
|
|
138
138
|
})
|
|
139
139
|
|
|
140
140
|
describe('collapsed rail', () => {
|
|
141
|
-
it('items keep their
|
|
141
|
+
it('items keep their label MOUNTED but faded, with a title in its place', () => {
|
|
142
|
+
// Unmounting the label snapped every row to its collapsed layout on the
|
|
143
|
+
// first frame of the width tween; it fades instead.
|
|
142
144
|
render(<Rail collapsed active="/" />)
|
|
143
145
|
const home = screen.getByRole('link', { name: 'Dashboard' })
|
|
144
146
|
expect(home).toHaveAttribute('title', 'Dashboard')
|
|
145
|
-
expect(home.querySelector('span.truncate')).
|
|
147
|
+
expect(home.querySelector('span.truncate')?.className).toMatch(/opacity-0/)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('the group header is ONE element in both modes, with the right ARIA for each', () => {
|
|
151
|
+
const { rerender } = render(<Rail collapsed={false} />)
|
|
152
|
+
const expanded = screen.getByRole('button', { name: 'Audit' })
|
|
153
|
+
expect(expanded).toHaveAttribute('aria-expanded', 'true')
|
|
154
|
+
expect(expanded).not.toHaveAttribute('aria-haspopup')
|
|
155
|
+
rerender(<Rail collapsed />)
|
|
156
|
+
const tile = screen.getByRole('button', { name: 'Audit' })
|
|
157
|
+
expect(tile).toBe(expanded)
|
|
158
|
+
expect(tile).toHaveAttribute('aria-haspopup', 'dialog')
|
|
159
|
+
expect(tile).toHaveAttribute('aria-expanded', 'false')
|
|
146
160
|
})
|
|
147
161
|
|
|
148
162
|
it('a group is a tile that opens a flyout holding its items at full width', async () => {
|
package/src/sidebar-nav.tsx
CHANGED
|
@@ -20,10 +20,16 @@
|
|
|
20
20
|
* `grid-template-rows` so its content keeps its size while it closes
|
|
21
21
|
* (`.ds-sidebar-group-body` in styles.css; honours reduced motion).
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* right
|
|
26
|
-
*
|
|
23
|
+
* A nested Item keeps its icon and sits inside the group's rail: one
|
|
24
|
+
* continuous hairline drops from the header's icon column and the child rows
|
|
25
|
+
* stand to its right, so a child's fill never crosses the line.
|
|
26
|
+
*
|
|
27
|
+
* Collapsed rail (`collapsed` on the root): the SAME rows, with their labels
|
|
28
|
+
* faded out and a `title` in their place - nothing remounts and the icon
|
|
29
|
+
* column never moves, so a collapse is one width tween. A Group's header
|
|
30
|
+
* opens a `Popover` flyout to its right holding the group's items at full
|
|
31
|
+
* width, and its body shuts in step with the rail. Inside the flyout the
|
|
32
|
+
* rail is not collapsed, so the same Item markup renders both ways.
|
|
27
33
|
*
|
|
28
34
|
* Token discipline: reads the INTERNAL `--sidebar-*` set (sidebar surfaces,
|
|
29
35
|
* text, rail) — allowed in packages/ui, ESLint-enforced elsewhere — plus
|
|
@@ -52,11 +58,14 @@
|
|
|
52
58
|
import {
|
|
53
59
|
cloneElement,
|
|
54
60
|
createContext,
|
|
61
|
+
forwardRef,
|
|
55
62
|
isValidElement,
|
|
56
63
|
useContext,
|
|
64
|
+
useEffect,
|
|
57
65
|
useId,
|
|
58
66
|
useState,
|
|
59
67
|
type AnchorHTMLAttributes,
|
|
68
|
+
type ButtonHTMLAttributes,
|
|
60
69
|
type ComponentType,
|
|
61
70
|
type HTMLAttributes,
|
|
62
71
|
type ReactElement,
|
|
@@ -100,10 +109,24 @@ function SidebarNavRoot({ collapsed = false, className, children, ...rest }: Sid
|
|
|
100
109
|
// ---------------------------------------------------------------------------
|
|
101
110
|
// Row chrome shared by Item and Group header
|
|
102
111
|
|
|
112
|
+
/**
|
|
113
|
+
* One layout in both modes. The row is `px-3` and the icon is 16px, so in a
|
|
114
|
+
* 40px collapsed row (56px rail less the nav's own 8px each side) the icon
|
|
115
|
+
* is centred WITHOUT switching to `justify-center` - the icon column never
|
|
116
|
+
* moves, and a collapse is a pure width tween with the labels fading.
|
|
117
|
+
*/
|
|
103
118
|
const ROW_BASE =
|
|
104
|
-
'relative flex h-9 w-full items-center gap-3 rounded-[var(--radius-md)] px-
|
|
119
|
+
'relative flex h-9 w-full items-center gap-3 overflow-hidden whitespace-nowrap rounded-[var(--radius-md)] px-3 text-left text-sm font-medium outline-none transition-colors ' +
|
|
105
120
|
'focus-visible:[box-shadow:var(--ring-focus)]'
|
|
106
121
|
|
|
122
|
+
/** The part of a row that fades out on a collapsed rail: the label, badge, chevron. */
|
|
123
|
+
const FADE =
|
|
124
|
+
'transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none'
|
|
125
|
+
|
|
126
|
+
function fadeClass(collapsed: boolean): string {
|
|
127
|
+
return cn(FADE, collapsed ? 'opacity-0' : 'opacity-100')
|
|
128
|
+
}
|
|
129
|
+
|
|
107
130
|
const ROW_REST =
|
|
108
131
|
'text-[rgb(var(--sidebar-text))] hover:bg-[rgb(var(--sidebar-surface-hover))] hover:text-[rgb(var(--sidebar-text-bright))]'
|
|
109
132
|
|
|
@@ -171,31 +194,26 @@ function SidebarNavItem({
|
|
|
171
194
|
const rowClass = cn(
|
|
172
195
|
ROW_BASE,
|
|
173
196
|
active ? ROW_ACTIVE : ROW_REST,
|
|
174
|
-
|
|
175
|
-
nested &&
|
|
197
|
+
// A nested row is a step shorter and a step smaller; it keeps its icon.
|
|
198
|
+
nested && 'h-8 gap-2.5 px-2 text-[13px]',
|
|
176
199
|
className,
|
|
177
200
|
)
|
|
201
|
+
// The label and badge stay MOUNTED on a collapsed rail and fade instead:
|
|
202
|
+
// unmounting them snapped every row to its collapsed layout on the first
|
|
203
|
+
// frame while the rail was still 260px wide.
|
|
178
204
|
const content = (
|
|
179
205
|
<>
|
|
180
206
|
{active && !nested && <ActiveBar />}
|
|
181
|
-
|
|
182
|
-
{
|
|
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>}
|
|
207
|
+
<RowIcon icon={icon} />
|
|
208
|
+
<span className={cn('flex-1 truncate', fadeClass(collapsed))}>{label}</span>
|
|
193
209
|
{/* The space is for the accessible name: "New audit Beta", not "New auditBeta".
|
|
194
210
|
A whitespace-only text node is invisible in a flex row. */}
|
|
195
|
-
{
|
|
211
|
+
{badge !== undefined && badge !== null && (
|
|
196
212
|
<>
|
|
197
213
|
{' '}
|
|
198
|
-
<
|
|
214
|
+
<span className={fadeClass(collapsed)}>
|
|
215
|
+
<RowBadge>{badge}</RowBadge>
|
|
216
|
+
</span>
|
|
199
217
|
</>
|
|
200
218
|
)}
|
|
201
219
|
</>
|
|
@@ -228,6 +246,35 @@ function SidebarNavItem({
|
|
|
228
246
|
// ---------------------------------------------------------------------------
|
|
229
247
|
// Group
|
|
230
248
|
|
|
249
|
+
type GroupHeaderProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
250
|
+
collapsed: boolean
|
|
251
|
+
/** The disclosure's own state, re-applied over the Trigger's flyout ARIA when expanded. */
|
|
252
|
+
disclosureOpen: boolean
|
|
253
|
+
disclosureControls: string
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The group's header button. Always the Popover.Trigger's `asChild` child, so
|
|
258
|
+
* the element is the same in both modes - but the Trigger clones its own
|
|
259
|
+
* `aria-expanded` / `aria-haspopup` over ours, which is right for the flyout
|
|
260
|
+
* and wrong for the disclosure. Expanded, this puts the disclosure's ARIA
|
|
261
|
+
* back on top; collapsed, the flyout's stands.
|
|
262
|
+
*/
|
|
263
|
+
const GroupHeader = forwardRef<HTMLButtonElement, GroupHeaderProps>(function GroupHeader(
|
|
264
|
+
{ collapsed, disclosureOpen, disclosureControls, ...props },
|
|
265
|
+
ref,
|
|
266
|
+
) {
|
|
267
|
+
const disclosure = collapsed
|
|
268
|
+
? {}
|
|
269
|
+
: {
|
|
270
|
+
'aria-haspopup': undefined,
|
|
271
|
+
'data-state': undefined,
|
|
272
|
+
'aria-expanded': disclosureOpen,
|
|
273
|
+
'aria-controls': disclosureControls,
|
|
274
|
+
}
|
|
275
|
+
return <button ref={ref} type="button" {...props} {...disclosure} />
|
|
276
|
+
})
|
|
277
|
+
|
|
231
278
|
export interface SidebarNavGroupProps {
|
|
232
279
|
/** Stable id — the body's DOM id derives from it. */
|
|
233
280
|
id: string
|
|
@@ -265,83 +312,102 @@ function SidebarNavGroup({
|
|
|
265
312
|
const bodyId = `sidebar-group-${id}-${reactId}`
|
|
266
313
|
const labelId = `${bodyId}-label`
|
|
267
314
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
315
|
+
const [flyoutOpen, setFlyoutOpen] = useState(false)
|
|
316
|
+
// Leaving the collapsed rail closes any flyout with it.
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!collapsed) setFlyoutOpen(false)
|
|
319
|
+
}, [collapsed])
|
|
320
|
+
|
|
321
|
+
// The SAME header button in both modes, so a collapse never remounts it:
|
|
322
|
+
// expanded it toggles the body, collapsed it opens the flyout. The Trigger
|
|
323
|
+
// (asChild) runs the child's onClick first and skips its own toggle when
|
|
324
|
+
// the event was defaultPrevented - which is how the expanded click stays a
|
|
325
|
+
// plain disclosure toggle.
|
|
326
|
+
const header = (
|
|
327
|
+
<GroupHeader
|
|
328
|
+
collapsed={collapsed}
|
|
329
|
+
disclosureOpen={open}
|
|
330
|
+
disclosureControls={bodyId}
|
|
331
|
+
onClick={(event) => {
|
|
332
|
+
if (collapsed) return
|
|
333
|
+
event.preventDefault()
|
|
334
|
+
setOpen(!open)
|
|
335
|
+
}}
|
|
336
|
+
id={labelId}
|
|
337
|
+
title={collapsed ? label : undefined}
|
|
338
|
+
// Closed and containing the current route: the header carries the
|
|
339
|
+
// active look so the location is never hidden. Open, the child row
|
|
340
|
+
// carries it and the header stays quiet. Collapsed, the body is shut,
|
|
341
|
+
// so an active group always shows it on the tile.
|
|
342
|
+
className={cn(ROW_BASE, active && (!open || collapsed) ? ROW_ACTIVE : ROW_REST)}
|
|
343
|
+
>
|
|
344
|
+
{active && (!open || collapsed) && <ActiveBar />}
|
|
345
|
+
<RowIcon icon={icon} />
|
|
346
|
+
<span id={`${labelId}-text`} className={cn('flex-1 truncate', fadeClass(collapsed))}>
|
|
347
|
+
{label}
|
|
348
|
+
</span>
|
|
349
|
+
{badge !== undefined && badge !== null && (
|
|
350
|
+
<>
|
|
351
|
+
{' '}
|
|
352
|
+
<span className={fadeClass(collapsed)}>
|
|
353
|
+
<RowBadge>{badge}</RowBadge>
|
|
354
|
+
</span>
|
|
355
|
+
</>
|
|
356
|
+
)}
|
|
357
|
+
<ChevronDown
|
|
358
|
+
aria-hidden="true"
|
|
359
|
+
className={cn(
|
|
360
|
+
'h-3.5 w-3.5 shrink-0 text-[rgb(var(--sidebar-text-dim))] transition-[transform,opacity] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
|
|
361
|
+
collapsed ? 'opacity-0' : 'opacity-100',
|
|
362
|
+
)}
|
|
363
|
+
style={{ transform: open && !collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
364
|
+
/>
|
|
365
|
+
</GroupHeader>
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div className="flex flex-col">
|
|
370
|
+
<Popover open={collapsed && flyoutOpen} onOpenChange={setFlyoutOpen}>
|
|
371
|
+
<Popover.Trigger asChild>{header}</Popover.Trigger>
|
|
372
|
+
<Popover.Content
|
|
373
|
+
side="right"
|
|
374
|
+
align="start"
|
|
375
|
+
offset={8}
|
|
376
|
+
aria-label={label}
|
|
377
|
+
className="min-w-[13rem] p-1.5"
|
|
378
|
+
>
|
|
379
|
+
{/* The flyout: the group's items at full width, to the right of the tile. */}
|
|
283
380
|
<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
|
-
>
|
|
381
|
+
<p className="px-2.5 pb-1 pt-1 text-[11px] font-semibold text-[rgb(var(--text-tertiary))]">
|
|
288
382
|
{label}
|
|
289
383
|
</p>
|
|
290
|
-
<div role="group" aria-
|
|
384
|
+
<div role="group" aria-label={label} className="flex flex-col gap-0.5">
|
|
291
385
|
{children}
|
|
292
386
|
</div>
|
|
293
387
|
</Ctx.Provider>
|
|
294
388
|
</Popover.Content>
|
|
295
389
|
</Popover>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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'}>
|
|
390
|
+
{/* The body closes IN STEP with a collapse (its track shrinks over the
|
|
391
|
+
same duration the rail does) and reopens when the rail comes back,
|
|
392
|
+
if the group was open. */}
|
|
393
|
+
<div className="ds-sidebar-group-body" data-open={open && !collapsed ? 'true' : 'false'}>
|
|
330
394
|
<div className="min-h-0 overflow-hidden">
|
|
331
395
|
<Ctx.Provider value={{ collapsed: false, nested: true }}>
|
|
332
396
|
<div
|
|
333
397
|
id={bodyId}
|
|
334
398
|
role="group"
|
|
335
399
|
aria-labelledby={`${labelId}-text`}
|
|
336
|
-
// The rail: one hairline
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
400
|
+
// The rail: one continuous hairline dropping from the header's
|
|
401
|
+
// icon column, with the child rows INSIDE it - a child's hover
|
|
402
|
+
// or active fill starts to the right of the line, never over it.
|
|
403
|
+
// Its x derives from the icon centre (px-3 + half of h-4 =
|
|
404
|
+
// 1.25rem, less half the line).
|
|
405
|
+
className="ml-[calc(1.25rem-0.5px)] flex flex-col gap-0.5 border-l border-[rgb(var(--sidebar-rail))] py-1 pl-2"
|
|
340
406
|
// Stays in the DOM while closed so the track has something to
|
|
341
407
|
// shrink over; `inert` keeps its links out of the tab ring and
|
|
342
408
|
// the accessibility tree until it is open again.
|
|
343
|
-
inert={!open}
|
|
344
|
-
aria-hidden={!open}
|
|
409
|
+
inert={!open || collapsed}
|
|
410
|
+
aria-hidden={!open || collapsed}
|
|
345
411
|
>
|
|
346
412
|
{children}
|
|
347
413
|
</div>
|
package/src/styles.css
CHANGED
|
@@ -2311,7 +2311,7 @@
|
|
|
2311
2311
|
.ds-sidebar-group-body {
|
|
2312
2312
|
display: grid;
|
|
2313
2313
|
grid-template-rows: 0fr;
|
|
2314
|
-
transition: grid-template-rows var(--dur-
|
|
2314
|
+
transition: grid-template-rows var(--dur-base) var(--ease-out);
|
|
2315
2315
|
}
|
|
2316
2316
|
.ds-sidebar-group-body[data-open='true'] {
|
|
2317
2317
|
grid-template-rows: 1fr;
|