@lovett/ui 0.2.9 → 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 +76 -62
- 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 +138 -69
- 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
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
* continuous hairline drops from the header's icon column and the child rows
|
|
25
25
|
* stand to its right, so a child's fill never crosses the line.
|
|
26
26
|
*
|
|
27
|
-
* Collapsed rail (`collapsed` on the root):
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
31
33
|
*
|
|
32
34
|
* Token discipline: reads the INTERNAL `--sidebar-*` set (sidebar surfaces,
|
|
33
35
|
* text, rail) — allowed in packages/ui, ESLint-enforced elsewhere — plus
|
|
@@ -56,11 +58,14 @@
|
|
|
56
58
|
import {
|
|
57
59
|
cloneElement,
|
|
58
60
|
createContext,
|
|
61
|
+
forwardRef,
|
|
59
62
|
isValidElement,
|
|
60
63
|
useContext,
|
|
64
|
+
useEffect,
|
|
61
65
|
useId,
|
|
62
66
|
useState,
|
|
63
67
|
type AnchorHTMLAttributes,
|
|
68
|
+
type ButtonHTMLAttributes,
|
|
64
69
|
type ComponentType,
|
|
65
70
|
type HTMLAttributes,
|
|
66
71
|
type ReactElement,
|
|
@@ -104,10 +109,24 @@ function SidebarNavRoot({ collapsed = false, className, children, ...rest }: Sid
|
|
|
104
109
|
// ---------------------------------------------------------------------------
|
|
105
110
|
// Row chrome shared by Item and Group header
|
|
106
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
|
+
*/
|
|
107
118
|
const ROW_BASE =
|
|
108
|
-
'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 ' +
|
|
109
120
|
'focus-visible:[box-shadow:var(--ring-focus)]'
|
|
110
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
|
+
|
|
111
130
|
const ROW_REST =
|
|
112
131
|
'text-[rgb(var(--sidebar-text))] hover:bg-[rgb(var(--sidebar-surface-hover))] hover:text-[rgb(var(--sidebar-text-bright))]'
|
|
113
132
|
|
|
@@ -175,22 +194,26 @@ function SidebarNavItem({
|
|
|
175
194
|
const rowClass = cn(
|
|
176
195
|
ROW_BASE,
|
|
177
196
|
active ? ROW_ACTIVE : ROW_REST,
|
|
178
|
-
collapsed && 'justify-center px-0',
|
|
179
197
|
// A nested row is a step shorter and a step smaller; it keeps its icon.
|
|
180
|
-
nested &&
|
|
198
|
+
nested && 'h-8 gap-2.5 px-2 text-[13px]',
|
|
181
199
|
className,
|
|
182
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.
|
|
183
204
|
const content = (
|
|
184
205
|
<>
|
|
185
206
|
{active && !nested && <ActiveBar />}
|
|
186
207
|
<RowIcon icon={icon} />
|
|
187
|
-
|
|
208
|
+
<span className={cn('flex-1 truncate', fadeClass(collapsed))}>{label}</span>
|
|
188
209
|
{/* The space is for the accessible name: "New audit Beta", not "New auditBeta".
|
|
189
210
|
A whitespace-only text node is invisible in a flex row. */}
|
|
190
|
-
{
|
|
211
|
+
{badge !== undefined && badge !== null && (
|
|
191
212
|
<>
|
|
192
213
|
{' '}
|
|
193
|
-
<
|
|
214
|
+
<span className={fadeClass(collapsed)}>
|
|
215
|
+
<RowBadge>{badge}</RowBadge>
|
|
216
|
+
</span>
|
|
194
217
|
</>
|
|
195
218
|
)}
|
|
196
219
|
</>
|
|
@@ -223,6 +246,35 @@ function SidebarNavItem({
|
|
|
223
246
|
// ---------------------------------------------------------------------------
|
|
224
247
|
// Group
|
|
225
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
|
+
|
|
226
278
|
export interface SidebarNavGroupProps {
|
|
227
279
|
/** Stable id — the body's DOM id derives from it. */
|
|
228
280
|
id: string
|
|
@@ -260,68 +312,85 @@ function SidebarNavGroup({
|
|
|
260
312
|
const bodyId = `sidebar-group-${id}-${reactId}`
|
|
261
313
|
const labelId = `${bodyId}-label`
|
|
262
314
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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. */}
|
|
278
380
|
<Ctx.Provider value={{ collapsed: false, nested: false }}>
|
|
279
|
-
<p
|
|
280
|
-
id={labelId}
|
|
281
|
-
className="px-2.5 pb-1 pt-1 text-[11px] font-semibold text-[rgb(var(--text-tertiary))]"
|
|
282
|
-
>
|
|
381
|
+
<p className="px-2.5 pb-1 pt-1 text-[11px] font-semibold text-[rgb(var(--text-tertiary))]">
|
|
283
382
|
{label}
|
|
284
383
|
</p>
|
|
285
|
-
<div role="group" aria-
|
|
384
|
+
<div role="group" aria-label={label} className="flex flex-col gap-0.5">
|
|
286
385
|
{children}
|
|
287
386
|
</div>
|
|
288
387
|
</Ctx.Provider>
|
|
289
388
|
</Popover.Content>
|
|
290
389
|
</Popover>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<div className="flex flex-col">
|
|
296
|
-
<button
|
|
297
|
-
type="button"
|
|
298
|
-
onClick={() => setOpen(!open)}
|
|
299
|
-
aria-expanded={open}
|
|
300
|
-
aria-controls={bodyId}
|
|
301
|
-
id={labelId}
|
|
302
|
-
// Closed and containing the current route: the header carries the
|
|
303
|
-
// active look so the location is never hidden. Open, the child row
|
|
304
|
-
// carries it and the header stays quiet.
|
|
305
|
-
className={cn(ROW_BASE, active && !open ? ROW_ACTIVE : ROW_REST)}
|
|
306
|
-
>
|
|
307
|
-
{active && !open && <ActiveBar />}
|
|
308
|
-
<RowIcon icon={icon} />
|
|
309
|
-
<span id={`${labelId}-text`} className="flex-1 truncate">
|
|
310
|
-
{label}
|
|
311
|
-
</span>
|
|
312
|
-
{badge !== undefined && badge !== null && (
|
|
313
|
-
<>
|
|
314
|
-
{' '}
|
|
315
|
-
<RowBadge>{badge}</RowBadge>
|
|
316
|
-
</>
|
|
317
|
-
)}
|
|
318
|
-
<ChevronDown
|
|
319
|
-
aria-hidden="true"
|
|
320
|
-
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"
|
|
321
|
-
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
322
|
-
/>
|
|
323
|
-
</button>
|
|
324
|
-
<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'}>
|
|
325
394
|
<div className="min-h-0 overflow-hidden">
|
|
326
395
|
<Ctx.Provider value={{ collapsed: false, nested: true }}>
|
|
327
396
|
<div
|
|
@@ -331,14 +400,14 @@ function SidebarNavGroup({
|
|
|
331
400
|
// The rail: one continuous hairline dropping from the header's
|
|
332
401
|
// icon column, with the child rows INSIDE it - a child's hover
|
|
333
402
|
// or active fill starts to the right of the line, never over it.
|
|
334
|
-
// Its x derives from the icon centre (px-
|
|
335
|
-
// 1.
|
|
336
|
-
className="ml-[calc(1.
|
|
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"
|
|
337
406
|
// Stays in the DOM while closed so the track has something to
|
|
338
407
|
// shrink over; `inert` keeps its links out of the tab ring and
|
|
339
408
|
// the accessibility tree until it is open again.
|
|
340
|
-
inert={!open}
|
|
341
|
-
aria-hidden={!open}
|
|
409
|
+
inert={!open || collapsed}
|
|
410
|
+
aria-hidden={!open || collapsed}
|
|
342
411
|
>
|
|
343
412
|
{children}
|
|
344
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;
|