laif-ds 1.0.0 → 1.0.1
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 +19 -0
- package/dist/agent-docs/adoption-report.json +26 -24
- package/dist/agent-docs/components/AppBottomNav.md +173 -41
- 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 +124 -125
- 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/styles.v3.css +1 -1
- package/package.json +1 -1
package/dist/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable technical changes to the laif-ds core will be documented in this fil
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [laif-ds@1.0.1]
|
|
9
|
+
|
|
10
|
+
### 🚀 Added
|
|
11
|
+
|
|
12
|
+
- **AppSidebar**: New `expandOnHover` prop expands the sidebar as a hover/focus overlay (page content doesn't shift) when collapsed to `collapsible="icon"`; a Pin control in the header promotes that to a permanent expansion, controllable via `pinned` / `defaultPinned` / `onPinnedChange`
|
|
13
|
+
- **AppSidebar / AppBottomNav**: `NavItem`, `NavSubItem`, `NavGroup` and `BottomNavItem` gained `badge` (`number | string | true`) and `badgeLabel` props for a count, short text or dot indicator; `AppBottomNav` also gained `menuBadge` for a dot on the Menu cell. The badge degrades per surface — a pill when the sidebar is expanded, a dot with the value in the tooltip when collapsed to icons, a superscript on `AppBottomNav`'s glyph (text degrades to a dot there) — and never aggregates child counts into a parent. `SidebarMenuBadge` is now deprecated in favor of `NavItem.badge`
|
|
14
|
+
|
|
15
|
+
### 🔧 Changed
|
|
16
|
+
|
|
17
|
+
- **AppBottomNav**: Cell height increased from 55px to 67px — icons are now `size="sm"` (20px, up from 16px) and vertical padding is `py-2.5` (up from `py-1.5`), bringing the cell further past the 44px touch-target floor
|
|
18
|
+
- **AppBottomNav**: `menuIconName` renamed to `menuIcon`, now accepting `IconName | (() => React.ReactNode)` so the Menu cell can render a custom glyph (e.g. a brand mark) in addition to a lucide icon name
|
|
19
|
+
|
|
20
|
+
### 🐛 Fixed
|
|
21
|
+
|
|
22
|
+
- **AppSidebar**: On mobile, `navigationFooter` now anchors to the bottom of the panel instead of trailing immediately after the last `navigation` group, matching its desktop placement whenever the navigation list is short enough to leave room
|
|
23
|
+
- **AppSidebar**: The mobile panel's header is no longer offset ~16px from center under the floating close button — centered `headerContent` (e.g. a logo) is now centered correctly
|
|
24
|
+
- **AppSidebar**: Collapsible submenus now animate open/closed instead of snapping instantly (skipped under `prefers-reduced-motion`), and collapsed rows are removed from the tab order and accessibility tree instead of staying mounted
|
|
25
|
+
- **Sidebar**: The panel's edge border (default, non-floating/inset variant) now resolves to the intended `border-d-sidebar-border` token instead of Tailwind v4's `currentColor` default, which rendered near-white on dark themes and near-black on light ones
|
|
26
|
+
|
|
8
27
|
## [laif-ds@1.0.0]
|
|
9
28
|
|
|
10
29
|
### 🚀 Added
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-17T14:06:59.977Z",
|
|
4
4
|
"package": {
|
|
5
5
|
"name": "laif-ds",
|
|
6
|
-
"version": "1.0.
|
|
6
|
+
"version": "1.0.1"
|
|
7
7
|
},
|
|
8
8
|
"summary": {
|
|
9
|
-
"scannedFiles":
|
|
9
|
+
"scannedFiles": 404,
|
|
10
10
|
"deprecatedComponentsCount": 2,
|
|
11
11
|
"deprecatedComponentsUsedCount": 0,
|
|
12
12
|
"componentsWithoutExamplesCount": 5,
|
|
@@ -351,7 +351,7 @@
|
|
|
351
351
|
"repeatedClassNames": [
|
|
352
352
|
{
|
|
353
353
|
"className": "flex items-center gap-2",
|
|
354
|
-
"occurrences":
|
|
354
|
+
"occurrences": 36,
|
|
355
355
|
"files": [
|
|
356
356
|
"src/components/stories/app-checkbox.stories.tsx",
|
|
357
357
|
"src/components/stories/app-dialog.stories.tsx",
|
|
@@ -367,6 +367,7 @@
|
|
|
367
367
|
"src/components/stories/truncated-cell.stories.tsx",
|
|
368
368
|
"src/components/ui/app-form.tsx",
|
|
369
369
|
"src/components/ui/app-kanban.tsx",
|
|
370
|
+
"src/components/ui/app-sidebar.tsx",
|
|
370
371
|
"src/components/ui/file-previewer.tsx",
|
|
371
372
|
"src/components/ui/weekly-calendar/calendar-header.tsx"
|
|
372
373
|
]
|
|
@@ -391,11 +392,12 @@
|
|
|
391
392
|
},
|
|
392
393
|
{
|
|
393
394
|
"className": "text-d-muted-foreground text-sm",
|
|
394
|
-
"occurrences":
|
|
395
|
+
"occurrences": 27,
|
|
395
396
|
"files": [
|
|
396
397
|
"src/components/stories/app-card.stories.tsx",
|
|
397
398
|
"src/components/stories/app-dialog.stories.tsx",
|
|
398
399
|
"src/components/stories/app-kanban.stories.tsx",
|
|
400
|
+
"src/components/stories/app-sidebar.stories.tsx",
|
|
399
401
|
"src/components/stories/changelog.stories.tsx",
|
|
400
402
|
"src/components/stories/date-picker.stories.tsx",
|
|
401
403
|
"src/components/stories/input.stories.tsx",
|
|
@@ -451,14 +453,6 @@
|
|
|
451
453
|
"src/components/stories/app-form.stories.tsx"
|
|
452
454
|
]
|
|
453
455
|
},
|
|
454
|
-
{
|
|
455
|
-
"className": "flex h-full overflow-hidden",
|
|
456
|
-
"occurrences": 14,
|
|
457
|
-
"files": [
|
|
458
|
-
"src/components/stories/app-sidebar.stories.tsx",
|
|
459
|
-
"src/components/stories/sidebar.stories.tsx"
|
|
460
|
-
]
|
|
461
|
-
},
|
|
462
456
|
{
|
|
463
457
|
"className": "text-muted-foreground mt-2 text-sm",
|
|
464
458
|
"occurrences": 13,
|
|
@@ -467,11 +461,11 @@
|
|
|
467
461
|
]
|
|
468
462
|
},
|
|
469
463
|
{
|
|
470
|
-
"className": "
|
|
471
|
-
"occurrences":
|
|
464
|
+
"className": "flex h-full overflow-hidden",
|
|
465
|
+
"occurrences": 12,
|
|
472
466
|
"files": [
|
|
473
|
-
"src/components/stories/app-
|
|
474
|
-
"src/components/stories/
|
|
467
|
+
"src/components/stories/app-sidebar.stories.tsx",
|
|
468
|
+
"src/components/stories/sidebar.stories.tsx"
|
|
475
469
|
]
|
|
476
470
|
},
|
|
477
471
|
{
|
|
@@ -482,6 +476,14 @@
|
|
|
482
476
|
"src/components/stories/app-radio-group.stories.tsx"
|
|
483
477
|
]
|
|
484
478
|
},
|
|
479
|
+
{
|
|
480
|
+
"className": "text-d-muted-foreground max-w-prose text-sm",
|
|
481
|
+
"occurrences": 9,
|
|
482
|
+
"files": [
|
|
483
|
+
"src/components/stories/app-bottom-nav.stories.tsx",
|
|
484
|
+
"src/components/stories/app-sidebar.stories.tsx"
|
|
485
|
+
]
|
|
486
|
+
},
|
|
485
487
|
{
|
|
486
488
|
"className": "border-d-border mt-2 rounded-md border p-4",
|
|
487
489
|
"occurrences": 9,
|
|
@@ -523,13 +525,6 @@
|
|
|
523
525
|
"src/components/stories/async-select.stories.tsx"
|
|
524
526
|
]
|
|
525
527
|
},
|
|
526
|
-
{
|
|
527
|
-
"className": "flex-1 space-y-3 overflow-auto p-4",
|
|
528
|
-
"occurrences": 8,
|
|
529
|
-
"files": [
|
|
530
|
-
"src/components/stories/app-sidebar.stories.tsx"
|
|
531
|
-
]
|
|
532
|
-
},
|
|
533
528
|
{
|
|
534
529
|
"className": "!text-d-foreground opacity-50",
|
|
535
530
|
"occurrences": 8,
|
|
@@ -562,6 +557,13 @@
|
|
|
562
557
|
"src/components/stories/app-select.stories.tsx"
|
|
563
558
|
]
|
|
564
559
|
},
|
|
560
|
+
{
|
|
561
|
+
"className": "flex-1 space-y-3 overflow-auto p-4",
|
|
562
|
+
"occurrences": 7,
|
|
563
|
+
"files": [
|
|
564
|
+
"src/components/stories/app-sidebar.stories.tsx"
|
|
565
|
+
]
|
|
566
|
+
},
|
|
565
567
|
{
|
|
566
568
|
"className": "border-d-border border px-3 py-1 text-center text-sm font-light",
|
|
567
569
|
"occurrences": 7,
|
|
@@ -23,20 +23,26 @@ scrolls instead of the page container the bar scrolls off-screen, and any
|
|
|
23
23
|
horizontal padding on an ancestor breaks the full bleed — that one is a layout
|
|
24
24
|
bug, not only a cosmetic one.
|
|
25
25
|
|
|
26
|
-
Total height is **
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
`
|
|
30
|
-
|
|
26
|
+
Total height is **67px** — 12px over the 55px that was measured when the cell
|
|
27
|
+
had `py-1.5` and a 16px glyph, which is exactly what those two changes add —
|
|
28
|
+
plus the safe-area reserve, which the component adds itself
|
|
29
|
+
(`pb-[env(safe-area-inset-bottom)]`, with no `max()` floor). Budget that column
|
|
30
|
+
in the page layout. It breaks down as 1px of `border-t` + 4px of top inset + a
|
|
31
|
+
58px pill (38px of content — a 20px icon, a 2px gap and a 16px line — plus 20px
|
|
32
|
+
of vertical padding) + 4px of bottom inset.
|
|
31
33
|
The reserve is **painted bar surface**: under a home indicator you see bar
|
|
32
34
|
colour, not page.
|
|
33
35
|
|
|
34
|
-
The
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
`min-h-11`
|
|
39
|
-
|
|
36
|
+
The vertical padding is `py-2.5`, taken from `designTokens.sizes.touch` — the
|
|
37
|
+
only part of that token a six-cell row can afford (`px-4` would leave 29px for
|
|
38
|
+
the label, `text-sm` would widen it further), and the same value the mobile
|
|
39
|
+
sidebar rows get from the token entire. The 58px cell clears the 44px touch
|
|
40
|
+
floor (`min-h-11`) by **14px**, where `py-1.5` over a 16px glyph cleared it by
|
|
41
|
+
2. `min-h-11` is not engaged — the height comes from the padding and the
|
|
42
|
+
content — and the margin is now wide enough that it stays that way: at the old
|
|
43
|
+
values any further trim to the padding, the icon or the label's line-height
|
|
44
|
+
would have handed the height to `min-h-11`, at which point the written padding
|
|
45
|
+
stops describing what is rendered. **Measure the cell, not the padding.**
|
|
40
46
|
|
|
41
47
|
It must be rendered inside a `SidebarProvider` (it reads the sidebar context to
|
|
42
48
|
open the mobile panel); outside one, `useSidebar()` throws.
|
|
@@ -51,7 +57,8 @@ open the mobile panel); outside one, `useSidebar()` throws.
|
|
|
51
57
|
| `showMenu` | `boolean` | `true` | `false` drops the Menu cell and spends its slot on a sixth destination. Only for an app that opens the mobile panel elsewhere — see below. |
|
|
52
58
|
| `menuLabel` | `string` | `"Menu"` | Label of the menu cell. |
|
|
53
59
|
| `ariaLabel` | `string` | `"Main navigation"` | Accessible name of the `<nav>` landmark. |
|
|
54
|
-
| `
|
|
60
|
+
| `menuIcon` | `IconName \| (() => React.ReactNode)` | `"Menu"` | The menu cell's glyph: a lucide name, or a function returning the node to draw for shapes lucide lacks. Nullish back from the function falls back to the default. |
|
|
61
|
+
| `menuBadge` | `boolean` | `false` | A dot on the Menu cell — **never** a count. OR-ed with the badged items dropped past the cell budget. |
|
|
55
62
|
| `linkComponent` | `React.ComponentType<React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }>` | `undefined` | Router link component (e.g. Next.js `Link`); plain `<a>` otherwise. |
|
|
56
63
|
| `linkProps` | `Record<string, unknown>` | `{}` | Extra props forwarded to `linkComponent`. |
|
|
57
64
|
| `className` | `string` | `undefined` | Merged onto the dock wrapper. |
|
|
@@ -65,13 +72,15 @@ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">
|
|
|
65
72
|
| ----------- | -------------- | ------------ | ---------------------------------------------------------------------------------------------- |
|
|
66
73
|
| `title` | `string` | **required** | Visible label. **Must equal the sidebar `NavItem.title`** of the same destination (see below). |
|
|
67
74
|
| `url` | `string` | **required** | Ignored when `subItems` is non-empty; pass `"#"` there by convention. |
|
|
68
|
-
| `iconName` | `IconName` | **required** | No fallback — at
|
|
75
|
+
| `iconName` | `IconName` | **required** | No fallback — at 20px the glyph is what the eye lands on first, and a cell without one breaks the rhythm of the row and reads as a failed image. |
|
|
69
76
|
| `isActive` | `boolean` | `undefined` | Leaf level only — the subtree state is derived from `subItems`. |
|
|
70
77
|
| `subItems` | `NavSubItem[]` | `undefined` | Presence makes the cell open the mobile menu at this level instead of navigating. |
|
|
71
78
|
| `ariaLabel` | `string` | `undefined` | Only when the spoken name must differ from the label; it must still contain the visible text. |
|
|
79
|
+
| `badge` | `number \| string \| true` | `undefined` | Count, short text or dot — the same field as `AppSidebar`'s `NavItem.badge`. This surface is `compact`: numbers render as a superscript on the glyph, **text degrades to a dot**. |
|
|
80
|
+
| `badgeLabel`| `string` | `undefined` | The spoken form of `badge`, announced by an `sr-only` span inside the cell. Effectively required for `badge: true`. |
|
|
72
81
|
|
|
73
82
|
`NavSubItem` is the same type `AppSidebar` uses:
|
|
74
|
-
`{ title: string; url: string; isActive?: boolean; iconName?: IconName }`.
|
|
83
|
+
`{ title: string; url: string; isActive?: boolean; iconName?: IconName; badge?: number | string | true; badgeLabel?: string }`.
|
|
75
84
|
|
|
76
85
|
---
|
|
77
86
|
|
|
@@ -90,7 +99,7 @@ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">
|
|
|
90
99
|
a flex item's automatic minimum size is its min-content width. `min-w-0`
|
|
91
100
|
removes that escape hatch, so the label truncates instead of the row going
|
|
92
101
|
uneven. (Without it the row would still be even at these widths — the
|
|
93
|
-
min-content floor is
|
|
102
|
+
min-content floor is 28px, a `shrink-0` 20px icon plus `px-1`, against a
|
|
94
103
|
narrowest cell of 52px — but by arithmetic rather than by construction.)
|
|
95
104
|
- **Six is a maximum, not a count.** Nothing pads the row: with fewer items the
|
|
96
105
|
cells simply get fatter, as every native tab bar does, and Menu stays pinned
|
|
@@ -128,34 +137,36 @@ Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">
|
|
|
128
137
|
hint on some cells only breaks the even rhythm that makes a tab bar scannable.
|
|
129
138
|
The feedback is the reveal itself.
|
|
130
139
|
|
|
131
|
-
### Icon size
|
|
132
|
-
|
|
133
|
-
Cell icons are `Icon size="
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
and
|
|
140
|
+
### Icon size
|
|
141
|
+
|
|
142
|
+
Cell icons are `Icon size="sm"` — **20px** — over a 12px (`text-xs`) label, one
|
|
143
|
+
rung up from the `xs` (16px) the bar carried before. Still under both native
|
|
144
|
+
baselines: iOS HIG draws a tab glyph at ~25pt over a 10pt label, Material 3 at
|
|
145
|
+
24dp over a 12sp one, so 20px sits below MD3 rather than above it.
|
|
146
|
+
|
|
147
|
+
The measurement behind the step: 16px against 20px is −20% linear and −36% in
|
|
148
|
+
area. At 16px the glyph settled *level* with its label — a fine proportion to
|
|
149
|
+
read, a poor one to aim at from across the screen. At 20px the icon leads and
|
|
150
|
+
the label confirms, which is the order a tab bar is scanned in, and it is why
|
|
151
|
+
`iconName` is required with no fallback.
|
|
152
|
+
|
|
153
|
+
The size is a rung of the shared `Icon` scale, not a per-call `className`
|
|
154
|
+
override, and never an intermediate value: an 18px rung was considered and
|
|
146
155
|
refused, because adding a step to `sizeMap` is a scale decision for ~93
|
|
147
|
-
components taken to serve one bar.
|
|
156
|
+
components taken to serve one bar. **The next size up is `md` (24px)** — MD3
|
|
157
|
+
parity, and 4px more bar.
|
|
148
158
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
Height history, so the trade is legible: the bar was 59px with a 20px glyph and
|
|
160
|
+
`py-1.5`, came down to 55px when the glyph shrank to 16px with the padding
|
|
161
|
+
uncompensated, and is 67px now — `py-2.5` from `designTokens.sizes.touch` (+8px,
|
|
162
|
+
spent on the touch target) and the glyph back to 20px (+4px, spent on reach).
|
|
152
163
|
|
|
153
164
|
### Active state
|
|
154
165
|
|
|
155
166
|
Exactly one cell is ever marked, with `data-active="true"` and a **rounded
|
|
156
167
|
pill**: `bg-d-sidebar-accent text-d-sidebar-accent-foreground font-bold` on a
|
|
157
168
|
`rounded-md` (6px) surface, inset 4px from every edge of the dock
|
|
158
|
-
(`
|
|
169
|
+
(`navTokens.item.selectedPill`, with `navTokens.item.basePill` carrying the
|
|
159
170
|
radius on every cell). **No border on any side.**
|
|
160
171
|
|
|
161
172
|
This is the desktop sidebar row's own active state, ported verbatim — the pair
|
|
@@ -207,7 +218,7 @@ already because it reads as a selected chip rather than as the current tab.
|
|
|
207
218
|
floor in light at 1.98:1, but separating by hue, which 1.4.11 does not measure.
|
|
208
219
|
A tint of primary is not an option, as the table shows.
|
|
209
220
|
|
|
210
|
-
The sidebar's `
|
|
221
|
+
The sidebar's `navTokens.item.selected` can still afford a 10% tint because a 2px rule
|
|
211
222
|
carries the state there; here there is no rule to fall back on.
|
|
212
223
|
|
|
213
224
|
`rounded-md` and not `radius.sm`: `sm` is 4px in the Tailwind v4 bundle and 2px
|
|
@@ -255,6 +266,51 @@ no `interaction.active` squash: its scale is for buttons, and a tab-bar cell is
|
|
|
255
266
|
surface you move between, not an object you push — an anisotropic scale on a
|
|
256
267
|
12px truncating label distorts the one thing the cell exists to let you read.
|
|
257
268
|
|
|
269
|
+
### Badges
|
|
270
|
+
|
|
271
|
+
`BottomNavItem.badge` is the same field as `AppSidebar`'s `NavItem.badge` — the
|
|
272
|
+
three forms (count, short text, dot), the `99+` overflow, the `0`-renders-nothing
|
|
273
|
+
rule and the warnings are all documented once, in `AppSidebar.md`. What is
|
|
274
|
+
specific to this surface:
|
|
275
|
+
|
|
276
|
+
- The capacity is **`compact`**: a **number** renders as a 14px superscript on
|
|
277
|
+
the glyph (`99+` included), and a **short text degrades to a dot**. Four
|
|
278
|
+
characters of superscript in a 52px cell either do not read or run into the
|
|
279
|
+
neighbouring cell, so text is not shrunk, it is dropped to the smallest form
|
|
280
|
+
that still says "there is something here".
|
|
281
|
+
- The badge sits on the **glyph**, not beside the label, which is what keeps the
|
|
282
|
+
~8-character label budget documented above exactly as it was. Measured at
|
|
283
|
+
320px, the tightest row the bar renders: the label box is 44px in the badged
|
|
284
|
+
story and 44px in `Default` — the badge costs the label nothing.
|
|
285
|
+
- It **does not bleed**. Six cells at 320px give a 44px content box around a
|
|
286
|
+
20px glyph, so there are 12px of slack per side; the badge reaches 6px past
|
|
287
|
+
the glyph plus 2px of ring, and it sits ~11px below the top edge of the dock,
|
|
288
|
+
so it meets neither the `rounded-t-lg` corners nor the dock's
|
|
289
|
+
`overflow-hidden`.
|
|
290
|
+
- **No interaction with the active pill.** The pill marks the position, the
|
|
291
|
+
badge marks the quantity; inverting the badge on the current cell would tie
|
|
292
|
+
two orthogonal channels together. `ring-2 ring-d-card` is safe over the pill
|
|
293
|
+
unconditionally — pill and card measure 1.10:1 light / 1.23:1 dark against
|
|
294
|
+
each other, so one ring colour works in both states.
|
|
295
|
+
- A cell with `subItems` that has no badge of its own shows a **dot** when one
|
|
296
|
+
of its descendants is badged. Counts never aggregate.
|
|
297
|
+
- **The Menu cell takes `menuBadge`, a dot and never a count**: the Menu opens
|
|
298
|
+
the whole navigation, a superset of `items`, so any number the bar computed
|
|
299
|
+
would be a lie. It is OR-ed with the items **dropped past the cell budget**,
|
|
300
|
+
so a drop cannot hide state as well as a destination — the drop warning still
|
|
301
|
+
fires, naming them. The Menu dot carries no announcement: there is no label to
|
|
302
|
+
give it, and what it stands for is announced on the rows of the panel it
|
|
303
|
+
opens.
|
|
304
|
+
- **Live counts vs static ones** follow the contract written once in
|
|
305
|
+
`AppSidebar.md` ("Live counts vs static ones, and who announces them"): the
|
|
306
|
+
design system assumes neither and renders no live region; when a count changes
|
|
307
|
+
as the direct result of an action on the current screen, the app announces it
|
|
308
|
+
in its own status region next to that action.
|
|
309
|
+
|
|
310
|
+
The `WithBadges` and `WithBadgesSmall` stories are the same five labels as
|
|
311
|
+
`Default`, badged, at 390px and 375px; `MenuBadgeFromDroppedItem` is the drop
|
|
312
|
+
case.
|
|
313
|
+
|
|
258
314
|
### Opening the menu
|
|
259
315
|
|
|
260
316
|
Both the Menu cell and any cell with `subItems` open `AppSidebar`'s mobile Sheet
|
|
@@ -299,6 +355,13 @@ suppress is noise. Turning it off is a claim; the app has to honour it.
|
|
|
299
355
|
implement).
|
|
300
356
|
- No `title` attributes: they do nothing on touch and some screen readers read
|
|
301
357
|
them after the name.
|
|
358
|
+
- A badge is `aria-hidden="true"` and its `badgeLabel` rides an in-flow
|
|
359
|
+
`sr-only` span **inside the cell**, so the name composes as "Clienti, 12
|
|
360
|
+
clienti nuovi" and never "Clienti 12". `aria-current` is untouched, on the
|
|
361
|
+
destination cells as on the parent cells (which carry `aria-expanded` and no
|
|
362
|
+
`aria-current`, as above). An aggregate dot — a parent's, or the Menu cell's —
|
|
363
|
+
is visual only: it has no text of its own, and what it stands for is announced
|
|
364
|
+
on the row that owns it.
|
|
302
365
|
- Focus ring is `designTokens.focusRing` + `focus-visible:ring-inset` — the
|
|
303
366
|
cells touch each other, so a 3px outset ring would spill over the neighbouring
|
|
304
367
|
cell and read as if two cells were focused.
|
|
@@ -311,18 +374,24 @@ suppress is noise. Turning it off is a claim; the app has to honour it.
|
|
|
311
374
|
|
|
312
375
|
No entrance animation — the bar is chrome, present from first paint. The only
|
|
313
376
|
transition is the colour change already carried by `designTokens.focusRing`, so
|
|
314
|
-
there is nothing for `prefers-reduced-motion` to suppress.
|
|
315
|
-
|
|
316
|
-
|
|
377
|
+
there is nothing for `prefers-reduced-motion` to suppress. **Badges add none**:
|
|
378
|
+
no enter/exit transition, no pulse, no animated counter — a badge appearing is
|
|
379
|
+
not a reason to open that chapter. The absence of a shadow is equally deliberate
|
|
380
|
+
(see Layout). The sheet's own 300ms open is owned by `SheetContent`.
|
|
317
381
|
|
|
318
382
|
### Warnings (`console.warn`, deduplicated per message)
|
|
319
383
|
|
|
320
|
-
- more `items` than slots, naming the dropped titles
|
|
384
|
+
- more `items` than slots, naming the dropped titles — and if one of the dropped
|
|
385
|
+
items was badged, the Menu cell takes a dot so the state is not dropped with
|
|
386
|
+
it;
|
|
321
387
|
- `items: []` together with `showMenu={false}`, where the bar renders `null`;
|
|
322
388
|
- two or more active items;
|
|
323
389
|
- a duplicate `url` + `title` pair (duplicate React keys make React reuse DOM
|
|
324
390
|
and state across cells);
|
|
325
|
-
- an unresolved drill target (logged by `AppSidebarMobile`)
|
|
391
|
+
- an unresolved drill target (logged by `AppSidebarMobile`);
|
|
392
|
+
- an invalid `badge` (negative, non-integer, empty string) or one over 8
|
|
393
|
+
characters, and a `badge: true` with no `badgeLabel` — all logged by the shared
|
|
394
|
+
normaliser, naming the row.
|
|
326
395
|
|
|
327
396
|
### Edge cases
|
|
328
397
|
|
|
@@ -398,6 +467,69 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|
|
398
467
|
/>
|
|
399
468
|
```
|
|
400
469
|
|
|
470
|
+
### A Menu cell of the app's own
|
|
471
|
+
|
|
472
|
+
Both halves of the cell are configurable: `menuLabel` for the copy, `menuIcon`
|
|
473
|
+
for the glyph. **`menuIcon` is one prop with two forms**, told apart by
|
|
474
|
+
`typeof`:
|
|
475
|
+
|
|
476
|
+
| form | when | example |
|
|
477
|
+
| ----------------------- | ------------------------------- | ----------------------------- |
|
|
478
|
+
| `IconName` (string) | the shape is in lucide | `menuIcon="LayoutGrid"` |
|
|
479
|
+
| `() => React.ReactNode` | it is not — a brand mark, an SVG | `menuIcon={() => <AcmeMark/>}` |
|
|
480
|
+
|
|
481
|
+
`IconName` is `keyof typeof LucideIcons`, so the string form already covers the
|
|
482
|
+
whole set — the cell is not tied to the hamburger. Reach for that when the app
|
|
483
|
+
already has one in its header, where two hamburgers on one screen read as two
|
|
484
|
+
different menus.
|
|
485
|
+
|
|
486
|
+
```tsx
|
|
487
|
+
<AppBottomNav items={bottomNavItems} menuLabel="Altro" menuIcon="LayoutGrid" />
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
**Why a function and not a `ReactNode`.** A string is itself a valid
|
|
491
|
+
`ReactNode`, so `IconName | ReactNode` collapses to `ReactNode` and there is no
|
|
492
|
+
way left to tell a lucide name from literal text. The function form keeps the
|
|
493
|
+
union discriminable by `typeof menuIcon === "string"` — and one prop instead of
|
|
494
|
+
two makes "both passed" unrepresentable rather than something to warn about.
|
|
495
|
+
|
|
496
|
+
**The function is called, not rendered as a component.** Hooks inside it are
|
|
497
|
+
not supported; in exchange an inline arrow is safe, which is how this gets
|
|
498
|
+
written in practice — as an element type it would change identity on every
|
|
499
|
+
render and remount the subtree.
|
|
500
|
+
|
|
501
|
+
#### A glyph lucide does not have
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
<AppBottomNav
|
|
505
|
+
items={bottomNavItems}
|
|
506
|
+
menuLabel="Acme"
|
|
507
|
+
menuIcon={() => <AcmeMark />}
|
|
508
|
+
/>
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Returning nullish falls back to the default glyph, so a conditional icon cannot
|
|
512
|
+
leave the cell bare: the row's argument for a mandatory glyph is that a cell
|
|
513
|
+
without one reads as a failed image, and that does not stop applying here.
|
|
514
|
+
|
|
515
|
+
**The geometry stays the design system's; only the drawing is the app's.** What
|
|
516
|
+
the function returns is rendered inside a box of exactly the size
|
|
517
|
+
`Icon size="sm"` occupies — 20px, `shrink-0` — and a direct `<svg>` child is
|
|
518
|
+
stretched to fill it (`[&>svg]:size-full`), which is the case the wrapper exists
|
|
519
|
+
for: a pasted SVG carries its own `width`/`height` and would otherwise land at
|
|
520
|
+
whatever those say. `preserveAspectRatio` defaults to `meet`, so a non-square
|
|
521
|
+
viewBox is letterboxed inside the box rather than distorted. A child that is not
|
|
522
|
+
a direct `<svg>` has to size itself; centring is handled either way. Measured on
|
|
523
|
+
the `CustomMenuIconNode` story, which returns a 48×32 SVG on purpose: it renders
|
|
524
|
+
at 20×20, the same box as the lucide glyph in the first cell.
|
|
525
|
+
|
|
526
|
+
This is the one cell whose glyph the design system does not control, and so the
|
|
527
|
+
one that can break the rhythm of the other five — a drawing with a different
|
|
528
|
+
stroke weight or optical density will read as foreign next to five lucide
|
|
529
|
+
glyphs, and no wrapper can fix that. `BottomNavItem.iconName` stays name-only
|
|
530
|
+
for exactly that reason: the escape hatch is deliberately this cell's alone.
|
|
531
|
+
**Prefer the string form wherever lucide has the shape.**
|
|
532
|
+
|
|
401
533
|
### Without the Menu cell
|
|
402
534
|
|
|
403
535
|
The header trigger is not optional here — it is what keeps the mobile panel
|