@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/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-fast) var(--ease-out);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovett/ui",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
@@ -138,11 +138,25 @@ describe('SidebarNav', () => {
138
138
  })
139
139
 
140
140
  describe('collapsed rail', () => {
141
- it('items keep their name through a title and drop the label text', () => {
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')).toBeNull()
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 () => {
@@ -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): Items become icon tiles with a
28
- * `title`; a Group becomes an icon tile that opens a `Popover` flyout to its
29
- * right holding the group's items at full width. Inside the flyout the rail
30
- * is not collapsed, so the same Item markup renders both ways.
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-2.5 text-left text-sm font-medium outline-none transition-colors ' +
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 && !collapsed && 'h-8 gap-2.5 px-2 text-[13px]',
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
- {!collapsed && <span className="flex-1 truncate">{label}</span>}
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
- {!collapsed && badge !== undefined && badge !== null && (
211
+ {badge !== undefined && badge !== null && (
191
212
  <>
192
213
  {' '}
193
- <RowBadge>{badge}</RowBadge>
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
- if (collapsed) {
264
- // The flyout: the group's items at full width, to the right of the tile.
265
- return (
266
- <Popover>
267
- <Popover.Trigger asChild>
268
- <button
269
- type="button"
270
- title={label}
271
- className={cn(ROW_BASE, 'justify-center px-0', active ? ROW_ACTIVE : ROW_REST)}
272
- >
273
- {active && <ActiveBar />}
274
- <RowIcon icon={icon} />
275
- </button>
276
- </Popover.Trigger>
277
- <Popover.Content side="right" align="start" offset={8} aria-label={label} className="min-w-[13rem] p-1.5">
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-labelledby={labelId} className="flex flex-col gap-0.5">
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
- return (
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-2.5 + half of h-4 =
335
- // 1.0625rem, less half the line).
336
- className="ml-[calc(1.0625rem-0.5px)] flex flex-col gap-0.5 border-l border-[rgb(var(--sidebar-rail))] py-1 pl-2"
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-fast) var(--ease-out);
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;