laif-ds 1.0.0 → 1.0.2

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.
@@ -19,14 +19,18 @@ High-level app sidebar built on top of `Sidebar` primitives. Renders navigation
19
19
  | `footerContent` | `React.ReactNode` | `undefined` | Custom content at the bottom. |
20
20
  | `showRail` | `boolean` | `true` | Show the draggable sidebar rail. |
21
21
  | `collapsible` | `"offcanvas" \| "icon" \| "none"` | `"offcanvas"` | Collapse behavior, forwarded to `Sidebar`. |
22
+ | `expandOnHover` | `boolean` | `false` | Expand as an overlay on hover/focus, with a Pin to make it permanent. Requires `collapsible="icon"`. |
23
+ | `pinned` | `boolean` | `undefined` | Controlled pin. Alias of the primitive's `open`, not a second state. |
24
+ | `defaultPinned` | `boolean` | `undefined` | Uncontrolled initial pin, applied once at mount. |
25
+ | `onPinnedChange` | `(pinned: boolean) => void` | `undefined` | Fired on every pin change, from any affordance. Never by a peek. |
22
26
  | `linkComponent` | `React.ComponentType<any>` | `undefined` | Custom link component for items. |
23
27
  | `linkProps` | `Record<string, any>` | `{}` | Extra props passed to the link component. |
24
28
 
25
- `NavGroup` → `{ title: string; url?: string; items: NavItem[]; iconName?: IconName; icon?: NavIcon }`
29
+ `NavGroup` → `{ title: string; url?: string; items: NavItem[]; iconName?: IconName; icon?: NavIcon; badge?: number | string | true; badgeLabel?: string }`
26
30
 
27
- `NavItem` → `{ title: string; url: string; isActive?: boolean; iconName?: IconName; icon?: NavIcon; subItems?: NavSubItem[] }`
31
+ `NavItem` → `{ title: string; url: string; isActive?: boolean; iconName?: IconName; icon?: NavIcon; subItems?: NavSubItem[]; badge?: number | string | true; badgeLabel?: string }`
28
32
 
29
- `NavSubItem` → `{ title: string; url: string; isActive?: boolean; iconName?: IconName; icon?: NavIcon }`
33
+ `NavSubItem` → `{ title: string; url: string; isActive?: boolean; iconName?: IconName; icon?: NavIcon; badge?: number | string | true; badgeLabel?: string }`
30
34
 
31
35
  `NavIcon` → `React.ComponentType<{ className?: string }>`
32
36
 
@@ -47,8 +51,9 @@ Every row takes its icon from one of two channels:
47
51
 
48
52
  `icon` **takes precedence** when both are set on the same row.
49
53
 
50
- The size is imposed by the sidebar, not by the consumer: it passes a size
51
- _class_ (`size-5` in a row, `size-4` in a collapsed dropdown or a tab), which is
54
+ The size is imposed by the sidebar, not by the consumer: the row asks for
55
+ `size="sm"` and the **host** decides **20px** in the 3rem icon rail, **16px**
56
+ everywhere else (expanded row, sub-item, icon-mode dropdown, tab bar) — which is
52
57
  why the contract is `className` and not a `size` prop. A CSS width/height
53
58
  overrides the `width`/`height` attributes libraries like `react-icons` put on
54
59
  their `<svg>`, so the same component lands at the right size in every context.
@@ -82,6 +87,13 @@ Inherits other props from `Sidebar` via `React.ComponentProps<typeof Sidebar>`.
82
87
  ## Behavior
83
88
 
84
89
  - **Collapsible groups**: Clicking items with `subItems` toggles nested lists.
90
+ The submenu animates open and shut on a `grid-template-rows: 0fr -> 1fr`
91
+ transition (house duration and curve), which resolves to exactly the
92
+ sub-items' height without a JS measurement, and the chevron rotates 90deg in
93
+ step rather than swapping glyph. The rows stay mounted and are hidden with
94
+ `visibility`, so they leave the tab order and the accessibility tree when
95
+ shut while `aria-controls` on the toggle keeps pointing at a real element.
96
+ `prefers-reduced-motion` drops the transition and the toggle is instant.
85
97
  - **Active state**: `isActive` highlights the item. An item whose own `isActive`
86
98
  or one of whose `subItems` is active starts already expanded at mount, in
87
99
  `navigation` and `navigationFooter` alike. Auto-expansion applies once per
@@ -99,6 +111,176 @@ Inherits other props from `Sidebar` via `React.ComponentProps<typeof Sidebar>`.
99
111
  - **Rail**: `showRail` displays a thin draggable handle to collapse/expand.
100
112
  - **No interactive nesting**: the menu button _is_ the link (`asChild`), so there
101
113
  is no `<a>` inside a `<button>`.
114
+ - **Hover expansion**: `expandOnHover` adds a third collapse mode on top of
115
+ `collapsible="icon"` — see the dedicated section.
116
+ - **Badges**: `badge` marks a row with a count, a short text or a dot, and
117
+ degrades per surface instead of disappearing — see the dedicated section.
118
+
119
+ ---
120
+
121
+ ## Badges (`badge` / `badgeLabel`)
122
+
123
+ An optional quantitative mark on a navigation row: unread documents, open
124
+ tickets, a `BETA` section. Two scalar fields, on `NavItem`, `NavSubItem`,
125
+ `NavGroup` and `AppBottomNav`'s `BottomNavItem` alike:
126
+
127
+ ```ts
128
+ badge?: number | string | true;
129
+ badgeLabel?: string;
130
+ ```
131
+
132
+ ### The three forms
133
+
134
+ | Value | Renders | Notes |
135
+ | -------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
136
+ | `number` | a count, `99+` past 99 | Integer >= 1. `0` renders **nothing** — a count of zero is the absence of the thing counted; pass `"0"` to really mean the character. Negative or non-integer warns and is ignored. |
137
+ | `string` | short text (`NEW`, `BETA`) | Keep it to 8 characters; over that it warns and is clipped at the pill's `max-w-16`. |
138
+ | `true` | a dot | For state with no number to it. The sentinel is `true` and **not** the string `"dot"`, which is itself a legal short text. |
139
+
140
+ `badgeLabel` is the spoken form (`"3 unread documents"`). It is effectively
141
+ required for `badge: true`, which has no text of its own: without it the dot
142
+ stays `aria-hidden` and the component warns — the design system does not invent
143
+ copy for an app it cannot read.
144
+
145
+ ### Degradation per surface
146
+
147
+ A `NavItem` feeds **four** rendering surfaces, so the badge is a declared
148
+ degradation rather than one component used four times. **Nothing is ever
149
+ dropped silently: at the minimum a badged row shows a dot.**
150
+
151
+ | Capacity | Surfaces | Behaviour |
152
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
153
+ | `full` | expanded desktop row, inline sub-item, mobile panel row (both levels), group label (list mode and mobile heading), selected tab, icon-mode sub-item dropdown, group-selector radio items | count · text · dot |
154
+ | `compact` | bottom nav cell | numbers (`99+` included); text → dot |
155
+ | `dot` | 3rem icon rail, collapsed group selector, **un**selected tab, Menu cell | everything → dot |
156
+
157
+ In the rail the value is not lost, it moves: the tooltip becomes
158
+ `title · value` (`Utenti · 5`) — punctuation, not copy, because the tooltip is
159
+ the only channel at 3rem that can carry a number. The collapsed form is keyed on
160
+ the **peek-aware** state, so during an `expandOnHover` peek the row shows the
161
+ expanded pill, not the dot.
162
+
163
+ ### Counts never aggregate
164
+
165
+ A parent shows **its own** badge; failing that, and **only while its children
166
+ are not visible**, a dot saying there is something in there. Nothing while the
167
+ submenu is open — the children are right there with their own badges. One row,
168
+ one marker.
169
+
170
+ The aggregations that exist are all dots, and each covers a surface where the
171
+ state would otherwise be invisible rather than merely smaller:
172
+
173
+ - **Unselected tab** — the group's own badge, else a dot if any item in that
174
+ group is badged. The strongest case: the items of an unselected tab are not
175
+ rendered at all.
176
+ - **Collapsed group selector** — a dot for badged state in a group **other**
177
+ than the selected one; the selected group's items are listed right below with
178
+ their own dots, and aggregating them here would mark the same thing twice.
179
+ - **Menu cell** of `AppBottomNav` — `menuBadge` OR the items dropped past the
180
+ cell budget.
181
+ - **Collapsed parent row**, on every surface, including a mobile level-0 row
182
+ (whose children live behind the drill).
183
+
184
+ A sum would be unverifiable arithmetic — overlapping sets,
185
+ permission-filtered children — presented as fact.
186
+
187
+ ### Colour and geometry
188
+
189
+ One family for every form and every surface: `bg-d-primary` +
190
+ `text-d-primary-foreground`, **measured 7.99:1 in both the light and the dark
191
+ theme**, so it clears 4.5:1 even at the 10px rung the bottom-nav overlay uses.
192
+ (In `claymorphism` the same pair measures 5.75:1; in `tangerine` 3.62:1, which
193
+ is a property of that theme's primary pair — `Button variant="default"` and
194
+ `Badge variant="default"` are the same two tokens — and not something the badge
195
+ introduces.)
196
+
197
+ - **Inline pill** (`full`): `h-5 min-w-5`, `rounded-md`, `text-xs tabular-nums`,
198
+ `shrink-0 max-w-16` with an inner `truncate`, so it can never break a row.
199
+ - **Overlay** (`compact`): 14px on the 20px glyph, `rounded-full`,
200
+ `text-[10px]`, `ring-2 ring-d-card`. Not 16px — that is 80% of the glyph,
201
+ where 14px is iOS's ratio.
202
+ - **Dot** (`dot`): 10px — 8px of fill inside a 1px `border-d-primary-foreground`.
203
+ The border is a requirement, not decoration: the fill measures 1.89:1 against
204
+ the sidebar in the light theme and a **non-text** indicator owes 3:1
205
+ (WCAG 1.4.11), while the ink measures 15.12:1 there — so the indicator's
206
+ boundary is what conforms. In the dark theme it is the other way round (fill
207
+ 8.93:1), which is why both are always drawn.
208
+
209
+ The class strings are private to `nav-badge.tsx` (`pillClassName` /
210
+ `dotClassName` / `overlayClassName`). They
211
+ introduce **no new `--d-*` variable**, so there is nothing to mirror into the
212
+ Tailwind v3 entry.
213
+
214
+ ### Accessibility
215
+
216
+ - `aria-hidden="true"` on the visual badge, plus an in-flow `sr-only` span with
217
+ `badgeLabel` **inside the row element**. The name composes as
218
+ "Documenti, oltre 99 documenti non letti, current page" and never
219
+ "Documenti 99+".
220
+ - **Not** an `aria-label` on the row: it would override the visible label (WCAG
221
+ 2.5.3), fight an `aria-label` a consumer puts on its own `LinkComponent`, and
222
+ destroy the text node the truncated label rests on.
223
+ - **Not** `aria-describedby`: descriptions are announced late or suppressed in
224
+ browse mode by several AT, and a count is the row's identity, not its
225
+ description.
226
+ - `aria-current` is **untouched** on every surface.
227
+ - Aggregate dots are visual only. They have no text of their own, and what they
228
+ stand for is announced on the row that owns it, one level in.
229
+
230
+ ### Live counts vs static ones, and who announces them
231
+
232
+ The design system assumes **neither**, and it never renders a live region. A
233
+ count may be a static fact of the route table or a number that ticks in the
234
+ background; the badge renders whatever it is given, and:
235
+
236
+ 1. The navigation is not where the user's attention is. Announcing "3"
237
+ interrupts a task they chose, for information they did not ask for.
238
+ 2. WCAG 4.1.3 covers status messages that follow a **user action**. A
239
+ background polling tick is not one.
240
+ 3. The value is already in the item's accessible name, one landmark jump away.
241
+ 4. With several badged items and any polling, a `role="status"` becomes a
242
+ stream.
243
+
244
+ **The exception, and it belongs to the app**: when a count changes as the direct
245
+ result of an action on the current screen, the _app_ announces it in its own
246
+ status region next to that action — not the navigation. `AppBottomNav`'s badge
247
+ follows the same contract.
248
+
249
+ ### What this deliberately does not do
250
+
251
+ The answers to the requests that come back, so they are on the record:
252
+
253
+ 1. **No per-item colour or `variant`** (`destructive`, `success`, `warning`).
254
+ Colour in a navigation is already the vocabulary of "you are here", and
255
+ per-item severity turns navigation into a dashboard. The measurement decides
256
+ it too: the red badge that gets asked for is `bg-d-destructive` +
257
+ `text-d-destructive-foreground` at **3.30:1**, failing 4.5:1 on day one.
258
+ 2. **No `ReactNode` or render-function badge.** The node escape hatch is
259
+ deliberately `AppBottomNav`'s Menu cell alone; a node badge puts avatars and
260
+ spinners in nav rows and breaks both the ~8-character label budget and the
261
+ rail's geometry.
262
+ 3. **No reuse of `<Badge>`** from `badge.tsx` in nav rows, and none of its 16
263
+ variants exposed there: it is `shrink-0 whitespace-nowrap w-fit` with no
264
+ `truncate`, so it does not give way.
265
+ 4. **No aggregated counts** anywhere — parent, tab, group selector, Menu cell.
266
+ Hidden state surfaces as a dot.
267
+ 5. **No counts past 99**, no `1.2k`, no `1,203`. The overflow is `99+`; four
268
+ characters do not fit the compact surfaces.
269
+ 6. **No motion at all**: no pulse, no ping, no animated counter, no enter/exit
270
+ transition. The only place a badge moves is inside the selected tab's
271
+ existing clip-and-fade, which it inherits by living in the label's grid.
272
+ 7. **No interactive badge** ("mark as read"). An interactive element nested in a
273
+ link is a violation; `SidebarMenuAction` is the row-action slot that already
274
+ exists.
275
+ 8. **No badge as a row's only content**, no badge in place of a label, no badge
276
+ on a disabled row.
277
+ 9. **No `badgeTooltip`** — the rail's tooltip composition already covers it.
278
+ 10. **No `maxCells` / `showBadges`** to make the drop or the degradation
279
+ configurable: the slot budget is documentation, not configuration.
280
+
281
+ `SidebarMenuBadge`, the primitive, is **deprecated** in favour of this: it is
282
+ `group-data-[collapsible=icon]:hidden`, so its value disappears in the rail, and
283
+ it has no spoken form. It still works, unchanged, for a hand-built `Sidebar`.
102
284
 
103
285
  ---
104
286
 
@@ -208,7 +390,15 @@ Behavior when collapsed on desktop:
208
390
 
209
391
  - **Labels hide**, they are never squashed or half-clipped: icon and label are
210
392
  direct children of the menu button, which the `Sidebar` primitive shrinks to
211
- `size-8` / `p-2`.
393
+ `size-8` / `p-1.5`.
394
+ - **The glyph is 20px in the rail**, against 16px in an expanded row: at 3rem it
395
+ is the row's only visible content, so it carries what the label carried. The
396
+ 32px pill keeps 6px of padding around it and nothing more —
397
+ `32 - 2*6 = 20` makes the button's content box exactly the glyph, and that
398
+ equality is what puts every glyph (icons and initial-letter fallbacks alike)
399
+ on the panel's centre line, 24px from its edge. See
400
+ `navTokens.rail` (`src/components/nav-tokens.ts`) and `Sidebar.md` → "Icon-mode geometry": changing
401
+ either number without the other pushes the whole column off-centre.
212
402
  - **Tooltips**: every top-level item shows a tooltip with its title on the right.
213
403
  Tooltips are suppressed while the sidebar is expanded and on mobile.
214
404
  - **Items with `subItems`** open a dropdown on **click**, anchored to the right of
@@ -229,8 +419,12 @@ opening a menu of the other groups to the right. It replaces the vertical strip
229
419
  of 32px squares an earlier version used — those squares were indistinguishable
230
420
  from the nav rows right below them, so selector and navigation read as one list.
231
421
 
232
- - **The icon does not move.** Its centre is at (24, 24) from the sidebar edge in
233
- _both_ states — measured, expanded and collapsed. That is what makes the swap
422
+ - **The icon does not move, it grows.** Its centre is on the same point in
423
+ _both_ states — measured, expanded and collapsed while the glyph itself goes
424
+ from 16px to the rail's 20px. Paying for those 4px is why the collapsed
425
+ button is `pt-1.5 gap-0 pb-0.5` and not `pt-2 gap-0.5 pb-0.5`: the top padding
426
+ holds the centre, the gap covers the remainder, and the column still measures
427
+ exactly `h-10` (6 + 20 + 0 + 12 + 2). That is what makes the swap
234
428
  read as "the label closed and a chevron appeared" rather than a jump, which is
235
429
  why there is no transition animating between the two forms: they are different
236
430
  DOM trees, and animating that would need FLIP or a shared-layout primitive for
@@ -275,6 +469,228 @@ from the nav rows right below them, so selector and navigation read as one list.
275
469
 
276
470
  ---
277
471
 
472
+ ## Hover expansion (`expandOnHover`)
473
+
474
+ ```tsx
475
+ <AppSidebar
476
+ collapsible="icon"
477
+ expandOnHover
478
+ navigation={navigation}
479
+ onPinnedChange={(pinned) => savePreference(pinned)}
480
+ />
481
+ ```
482
+
483
+ A third collapse mode on top of `collapsible="icon"`: the pointer (or the focus)
484
+ expands the sidebar **temporarily and as an overlay**, and a **Pin** in the
485
+ header promotes that expansion to permanent. Four states —
486
+
487
+ | State | What it is |
488
+ | --------- | --------------------------------------------------------------------------- |
489
+ | `mobile` | Below 768px: the full-screen Sheet, unchanged. No peek, ever. |
490
+ | `rail` | The 3rem icon rail of today. |
491
+ | `peek` | Temporarily expanded **over** the page. The layout does not move. |
492
+ | `pinned` | Permanently expanded and **pushing** the page — the primitive's `open`. |
493
+
494
+ ### The peek is an overlay, the pin is a push
495
+
496
+ This is the whole point of the mode, and the one thing to check first: during a
497
+ peek only the `fixed` panel grows to `--sidebar-width`. The gap element the page
498
+ is laid out against stays at `--sidebar-width-icon`, so **no page content moves**
499
+ — the panel floats over it, with a shadow and a raised z-index. Pinning is the
500
+ ordinary expansion: gap and panel grow together and the main area gives up the
501
+ space.
502
+
503
+ Implemented in the `Sidebar` primitive, not by widening the panel from outside:
504
+ the children read `state` and the `group-data-[collapsible=icon]:*` rules, not
505
+ the panel's width, so nothing short of a peek-aware `state` gives them their
506
+ expanded rendering. See the `Sidebar` doc for `peek` / `setPeek` and `data-peek`.
507
+
508
+ Two consequences worth knowing:
509
+
510
+ - **Tooltips switch off during a peek** on their own — `SidebarMenuButton` hides
511
+ them whenever `state !== "collapsed"`, and the labels they were standing in for
512
+ are real text again.
513
+ - **The icon-mode dropdown for sub-items is no longer reachable with a fine
514
+ pointer** when `expandOnHover` is on: by the time you can click an icon the
515
+ peek has already swapped in the inline submenu. The dropdown branch is not dead
516
+ code — it is still what coarse pointers get — but it is not what this mode
517
+ exercises.
518
+
519
+ ### Preconditions
520
+
521
+ `expandOnHover` is inert unless **all** of these hold: `collapsible="icon"`,
522
+ viewport ≥ 768px, and a pointer that satisfies
523
+ `(hover: hover) and (pointer: fine)` (read with `matchMedia`, `false` during SSR
524
+ so hydration does not mismatch). Anywhere else the component behaves exactly as
525
+ it does today, and the Pin is not rendered at all — on a touch device it would
526
+ only duplicate `SidebarTrigger`. With a `collapsible` other than `"icon"` the
527
+ prop is ignored **and a warning is logged**.
528
+
529
+ ### Timing
530
+
531
+ | Delay | Value | Why |
532
+ | -------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------- |
533
+ | hover in | 300ms | A pointer crossing the rail at 800–1200px/s is over it for 50–80ms. 300ms is ~4× an accidental transit — deliberately past the 200–250ms where waiting reads as lag, so the opening feels intentional rather than reactive. Matches the hover out, making the mode symmetric. |
534
+ | hover out | 300ms | Grace period: covers the overshoot, the immediate re-entry and the diagonal run to a submenu. Past ~400ms an intentional exit feels sticky. |
535
+ | focus in | 0ms | Focus is already deliberate; there is nothing to filter. |
536
+ | focus out | 120ms | Debounce — **not** courtesy. See "focus and the `<body>` frame" below. |
537
+ | width | 200ms `ease-linear` | The primitive's own, deliberately not forked: gap and panel share one declaration and a second curve would desync them during a pin. |
538
+ | overlay shadow | 160ms, house curve | Not a layout property, so it can take `designTokens.motion.ease` without dragging the width along. |
539
+ | suppression after unpin | none | Event-based: it lasts until the pointer leaves. A timer would always guess wrong — the user may rest on the Pin for seconds. |
540
+
541
+ Re-entering inside the grace period resumes the peek **without** paying the
542
+ 300ms again: the intent was established on the way in.
543
+
544
+ Under `prefers-reduced-motion: reduce` the width snaps and the shadow does not
545
+ fade, but **the delays are unchanged**: they filter intention, not movement, and
546
+ removing them would make the sidebar hair-trigger for exactly the users who
547
+ asked for fewer surprises.
548
+
549
+ ### The Pin
550
+
551
+ - **Where**: its own place at the right end of the header row, sharing the line
552
+ with `headerContent` (`[headerContent flex-1] [pin]`). With `side="right"` it
553
+ moves to the left of the row — the panel's inner edge — **and moves with it in
554
+ the DOM**, so reading order and focus order agree (WCAG 1.3.2). If there is no
555
+ `headerContent`, the header is mounted for the Pin alone.
556
+ - **When**: visible in `peek` and in `pinned` only. Never in the rail — at 3rem
557
+ there is no room, and there is nothing yet to keep open.
558
+ - **Hidden, not unmounted.** In the rail the button stays in the tree with
559
+ `opacity-0 pointer-events-none`, `aria-hidden` and out of the tab order. Two
560
+ reasons, both second-order: unmounting the control the user has just activated
561
+ drops the focus on `<body>` — the one state the blur debounce has to ignore, so
562
+ a keyboard user would be left with the focus nowhere; and mounting a header on
563
+ the way into a peek would shift every nav row down at the moment the panel is
564
+ already moving. Reserving the row costs a consumer with no `headerContent` an
565
+ empty header strip in the rail, which is the trade being made.
566
+ - **After unpinning from the keyboard** the focus moves to the first navigation
567
+ row, because the button it was on has just gone invisible and `aria-hidden` —
568
+ no focus ring to see, no name to hear. The rail carries the labels in tooltips
569
+ from there.
570
+ - **Labels**: `Keep sidebar open` (icon `Pin`) when unpinned, `Collapse sidebar
571
+ to icons` (icon `PinOff`) when pinned, as both the tooltip and the `aria-label`.
572
+ The icon follows the **action**, not the state: `PinOff` means "click to
573
+ unpin".
574
+ - **No `aria-pressed`, on purpose.** For an icon button the tooltip _is_ the
575
+ visible label, so WCAG 2.5.3 (Label in Name) requires the accessible name to
576
+ contain it. `aria-pressed` would force a name that stays "Keep sidebar open"
577
+ while pinned, and the state would then be announced twice and in contradiction
578
+ ("Keep sidebar open, pressed", beside an unpin icon). Two labels say state and
579
+ outcome in one phrase.
580
+ - **No live region** for expand/collapse. Announcing every pass of the mouse is
581
+ noise, and for a keyboard user the expansion is already perceivable because the
582
+ focus is moving through content with accessible names.
583
+
584
+ ### `pinned` is `open`
585
+
586
+ `pinned` / `defaultPinned` / `onPinnedChange` are a **semantic alias** of the
587
+ provider's `open`, not a parallel state. The Pin button, `Cmd/Ctrl+B`,
588
+ `SidebarTrigger` and the rail all move the same variable, and `onPinnedChange`
589
+ fires for all four. A peek never emits it and **never calls `setOpen`**, so
590
+ hovering does not write the `sidebar_state` cookie — otherwise the consumer's
591
+ persisted preference would be overwritten with noise on every pass of the mouse.
592
+
593
+ Persistence is the host app's: the library stores nothing beyond the primitive's
594
+ own cookie.
595
+
596
+ > **Do not control both.** A consumer that drives `SidebarProvider.open` _and_
597
+ > passes `pinned` has two handles on one value. Pick one. Reconciliation keeps
598
+ > them from looping (the component remembers the last value it emitted and does
599
+ > not push back a change the consumer has not yet acknowledged), but two owners
600
+ > of one state is still two owners.
601
+
602
+ **After an unpin the peek is suppressed** until the pointer leaves the sidebar.
603
+ Without it, unpinning with the cursor still on the panel would reopen a peek
604
+ 300ms later and the control would look broken. This holds for the shortcut and
605
+ the rail too. Nothing is suppressed when the pointer is elsewhere — a keyboard
606
+ unpin must not lock the focus route behind a `mouseleave` that will never come.
607
+
608
+ ### Keyboard and focus
609
+
610
+ - **`focusin` anywhere in the panel expands it immediately**, and the peek stays
611
+ open while the focus is inside. Closing requires `!hover && !focus &&
612
+ !openMenus`: hover and focus are in **OR**, never in AND.
613
+ - The peek is **not modal**: no focus trap, no `aria-hidden` on the page, no
614
+ scroll lock, no click-catching overlay.
615
+ - **`Escape` with the focus outside** the sidebar closes the peek immediately.
616
+ With the focus inside it does nothing of its own — Radix's layers consume it
617
+ first, and closing would tear the focus out of the panel the user is in.
618
+ - "Focus is inside" means **`:focus-visible`** inside, not merely focused. Radix
619
+ hands focus back to the trigger when a menu closes, so after a pointer-driven
620
+ open-and-close the focus would sit inside the panel with the user's attention
621
+ long gone, holding the peek open until they clicked elsewhere. The browser's
622
+ own modality heuristic draws that line.
623
+ - **Focus and the `<body>` frame.** Expanding replaces the element that holds
624
+ focus (the group selector becomes a tab, a dropdown trigger becomes an inline
625
+ toggle): React unmounts it, `document.activeElement` falls to `<body>` for a
626
+ frame, and a naive `focusout` handler reads that as "focus left", closes the
627
+ peek, swaps the tree back and re-fires itself. The 120ms debounce **and**
628
+ treating `<body>` as "unknown, do not decide" are what break that loop. The
629
+ existing focus-restoration layout effect puts the focus back on the equivalent
630
+ control.
631
+ - The peek also closes immediately on `window` blur, on the tab being hidden and
632
+ on the pointer leaving the document.
633
+
634
+ ### Menus that live in a portal
635
+
636
+ Radix portals popper content to `document.body`, so a menu opened from inside the
637
+ sidebar is **not** a descendant of the panel: moving the pointer onto it reads as
638
+ having left, and the naive implementation collapses the sidebar out from under
639
+ the open menu. Two defences:
640
+
641
+ 1. While a peek is open, "is the pointer inside?" is answered at the document
642
+ level and counts any `[data-radix-popper-content-wrapper]` as part of the peek
643
+ surface.
644
+ 2. The peek will not close while a trigger **inside the panel** still has an open
645
+ popper (`[aria-expanded="true"][data-state="open"]`) — including menus the
646
+ consumer put in `headerContent` / `footerContent`, whose `onOpenChange` the
647
+ component cannot hook. When the last one closes, the grace period runs.
648
+ 3. **The same latch blocks the peek from opening**, which is the half that is
649
+ easy to miss. A menu opened from the *rail* is anchored on a trigger that
650
+ expanding replaces — the icon-mode `DropdownMenuTrigger` becomes an inline
651
+ toggle — so engaging a peek would tear the open menu down mid-interaction.
652
+ Reachable two ways: clicking inside the 300ms the hover timer is already
653
+ counting, and a rail whose peek is suppressed after an unpin. The delay is
654
+ waited out rather than cancelled, and re-evaluated on the same interval, so
655
+ the menu closing with the pointer still inside opens the peek — and with the
656
+ pointer gone, nothing happens.
657
+
658
+ ### Other things worth knowing
659
+
660
+ - **`SidebarRail` during a peek** rides out to 16rem with the panel and floats
661
+ over the main area. That is correct: clicking it pins. The resize cursor flips
662
+ direction with it, which is also correct.
663
+ - **`variant="inset"` / `"floating"`** need the peek override in two places, and
664
+ both are done: the gap keeps the per-variant
665
+ `calc(var(--sidebar-width-icon) + 1rem)` formula, and `SidebarInset`'s left
666
+ margin is re-anchored to `data-peek` because it keys off `data-state`, which a
667
+ peek flips to `"expanded"`.
668
+ - **z-index**: the panel is raised to `z-30` while peeking so it clears the
669
+ sticky headers the template layouts use, and dropped back afterwards. This
670
+ value is a working default, not a considered answer — the right one depends on
671
+ the host app's stacking context, which the library cannot know.
672
+ - **`side="right"`** is fully mirrored: the panel is anchored to the right edge,
673
+ so a peek grows **inward** over the page and never off the viewport, and the
674
+ Pin follows to the panel's inner (left) end. `dir="rtl"` is a different
675
+ question and is **not** supported — the primitive positions with physical
676
+ `left`/`right`, which predates this mode.
677
+ - **Scroll position** in `SidebarContent` is preserved across the rail↔expanded
678
+ swap; the content changes height as inline submenus come and go, and a long
679
+ list would otherwise lose the user's place.
680
+ - **Nothing in ARIA reflects the peek**, by design: it is pointer-bound and
681
+ temporary, and announcing it would have a screen reader narrate every pass of
682
+ the mouse. `SidebarTrigger` and `SidebarRail` carry no `aria-expanded` at all,
683
+ and the Pin's label follows `pinned`. The one measured residue is Radix's
684
+ hidden `role="tooltip"` span: suppression hides the tooltip's bubble but leaves
685
+ Radix believing it is open, so a row's `aria-describedby` can still point at a
686
+ label that is now also visible text. Closing it properly means taking ownership
687
+ of the tooltip's `open` away from the primitive, which regressed the collapsed
688
+ rail's tooltips when tried — left as is deliberately.
689
+ - **One `SidebarProvider` per sidebar.** Two would register two `Cmd/Ctrl+B`
690
+ listeners and, now, two independent peeks.
691
+
692
+ ---
693
+
278
694
  ## Mobile menu (`<768px`)
279
695
 
280
696
  Below 768px (the `useIsMobile` breakpoint) `AppSidebar` **replaces** the desktop
@@ -301,7 +717,14 @@ Anatomy, level 0:
301
717
  aligned. There is no initial-letter fallback here (unlike desktop icon mode,
302
718
  where the icon is the only affordance).
303
719
  - A chevron on the right marks rows that have `subItems` — and only those.
304
- - The active row uses `designTokens.navItem.selected`: a `bg-d-primary/10`
720
+ - A **badge** sits between the label and the chevron, at full capacity, on both
721
+ the level-0 rows and the level-1 sub-rows; a group's own badge renders on its
722
+ uppercase heading. A level-0 parent with no badge of its own shows a dot when
723
+ one of its children is badged — its children live behind the drill, so this is
724
+ the collapsed-parent rule again. The drilled level's `<h2>` never takes one:
725
+ the parent's badge is on the row the user just tapped, and the children carry
726
+ their own.
727
+ - The active row uses `navTokens.item.selected`: a `bg-d-primary/10`
305
728
  surface tint, a full-contrast `text-d-foreground` label, and a 2px left bar as
306
729
  a second, non-colour channel. A parent row is tinted when **any** of its
307
730
  `subItems` is active, so level 0 still shows where you are on a sub-page;
@@ -310,9 +733,20 @@ Anatomy, level 0:
310
733
  open** — Radix would otherwise focus the first focusable node in it, which for
311
734
  a search field means raising the keyboard over the menu. Focus goes to the
312
735
  panel itself, and returns to whatever opened the sheet when it closes.
736
+ - The sheet's close button floats over the top-right corner, so the header
737
+ reserves **48px on each side** for it — symmetrically, so that a
738
+ `headerContent` the consumer centres (a logo, as the template does) stays on
739
+ the panel's centre line instead of being pushed off it by a one-sided
740
+ reserve. Full-width header content is inset by that reserve; the back bar of
741
+ a drilled level is left-aligned and only reserves on the right.
313
742
  - `navigationFooter` is detached from the groups above it as the "system" block
314
- of a native Settings list; `footerContent` is the last block, and the panel
315
- reserves `env(safe-area-inset-bottom)` below it for the home indicator.
743
+ of a native Settings list, and **sits at the bottom of the scroll area** the
744
+ way the desktop panel's does: when `navigation` is shorter than the panel the
745
+ block drops to the bottom instead of trailing the last group; when it is
746
+ longer the block simply scrolls after it. It scrolls with the list either way
747
+ — it is not pinned over it, so it never covers a row. `footerContent` is the
748
+ last block, and the panel reserves `env(safe-area-inset-bottom)` below it for
749
+ the home indicator.
316
750
  - Above 640px the column is capped and centred: a full-bleed card would leave
317
751
  the chevron a screen-width away from its label.
318
752
 
@@ -402,6 +836,65 @@ export function Layout() {
402
836
  }
403
837
  ```
404
838
 
839
+ ### With badges
840
+
841
+ ```tsx
842
+ const navigation = [
843
+ {
844
+ title: "Generale",
845
+ // A group's own badge. Never a sum over its items.
846
+ badge: "BETA",
847
+ badgeLabel: "gruppo in beta",
848
+ items: [
849
+ { title: "Dashboard", url: "/", isActive: true, iconName: "Home" },
850
+ // A count. Over 99 renders "99+"; 0 renders nothing.
851
+ {
852
+ title: "Documenti",
853
+ url: "/docs",
854
+ iconName: "FileText",
855
+ badge: 120,
856
+ badgeLabel: "oltre 99 documenti non letti",
857
+ },
858
+ // Short text. Degrades to a dot in the rail and in the bottom bar.
859
+ {
860
+ title: "Archivio",
861
+ url: "/archive",
862
+ iconName: "Archive",
863
+ badge: "NEW",
864
+ badgeLabel: "sezione nuova",
865
+ },
866
+ // A dot. `badgeLabel` is what keeps it from being silent.
867
+ {
868
+ title: "Notifiche",
869
+ url: "/alerts",
870
+ iconName: "Bell",
871
+ badge: true,
872
+ badgeLabel: "hai notifiche non lette",
873
+ },
874
+ // No badge of its own: a dot while the submenu is shut, nothing once it
875
+ // is open, and never the sum of its children.
876
+ {
877
+ title: "Messaggi",
878
+ url: "#",
879
+ iconName: "MessageSquare",
880
+ subItems: [
881
+ {
882
+ title: "Inbox",
883
+ url: "/inbox",
884
+ iconName: "Inbox",
885
+ badge: 12,
886
+ badgeLabel: "12 messaggi non letti",
887
+ },
888
+ { title: "Inviati", url: "/sent", iconName: "Send" },
889
+ ],
890
+ },
891
+ ],
892
+ },
893
+ ];
894
+
895
+ <AppSidebar navigation={navigation} collapsible="icon" />;
896
+ ```
897
+
405
898
  ### With Header/Footer Content
406
899
 
407
900
  ```tsx