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.
- package/dist/CHANGELOG.md +25 -0
- package/dist/_virtual/index3.js +5 -5
- package/dist/_virtual/index4.js +5 -5
- package/dist/_virtual/index5.js +4 -4
- package/dist/agent-docs/adoption-report.json +26 -24
- package/dist/agent-docs/components/AppBottomNav.md +180 -49
- package/dist/agent-docs/components/AppSidebar.md +504 -11
- package/dist/agent-docs/components/Sidebar.md +72 -1
- package/dist/agent-docs/components-list.md +3 -2
- package/dist/agent-docs/manifest.json +52 -15
- package/dist/components/design-tokens.js +0 -64
- package/dist/components/nav-tokens.js +41 -0
- package/dist/components/ui/app-bottom-nav.js +125 -130
- package/dist/components/ui/app-sidebar-mobile.js +119 -104
- package/dist/components/ui/app-sidebar.js +516 -313
- package/dist/components/ui/nav-badge.js +66 -0
- package/dist/components/ui/sidebar.js +272 -207
- package/dist/index.d.ts +122 -29
- package/dist/node_modules/eventemitter3/index.js +1 -1
- package/dist/node_modules/hast-util-to-jsx-runtime/lib/index.js +1 -1
- package/dist/node_modules/recharts/es6/util/Events.js +1 -1
- package/dist/node_modules/unified/lib/index.js +1 -1
- package/dist/styles.v3.css +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
51
|
-
|
|
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-
|
|
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
|
|
233
|
-
_both_ states — measured, expanded and collapsed
|
|
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
|
-
-
|
|
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
|
|
315
|
-
|
|
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
|