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 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-02T16:31:01.025Z",
3
+ "generatedAt": "2026-09-17T14:06:59.977Z",
4
4
  "package": {
5
5
  "name": "laif-ds",
6
- "version": "1.0.0"
6
+ "version": "1.0.1"
7
7
  },
8
8
  "summary": {
9
- "scannedFiles": 402,
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": 35,
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": 26,
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": "text-d-muted-foreground max-w-prose text-sm",
471
- "occurrences": 10,
464
+ "className": "flex h-full overflow-hidden",
465
+ "occurrences": 12,
472
466
  "files": [
473
- "src/components/stories/app-bottom-nav.stories.tsx",
474
- "src/components/stories/app-sidebar.stories.tsx"
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 **55px** (measured) plus the safe-area reserve, which the
27
- component adds itself (`pb-[env(safe-area-inset-bottom)]`, with no `max()`
28
- floor) budget that column in the page layout. It breaks down as 1px of
29
- `border-t` + 4px of top inset + a 46px pill (34px of content a 16px icon, a
30
- 2px gap and a 16px line plus 12px of vertical padding) + 4px of bottom inset.
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 46px cell clears the 44px touch floor (`min-h-11`) by **2px**, where the
35
- 20px-icon version cleared it by 6. Measured, `min-h-11` is not engaged — the
36
- cell renders at 46px, so the height still comes from `py-1.5` but the margin
37
- is thin: trim the padding, the icon or the label's line-height any further and
38
- `min-h-11` takes the height over, at which point the written padding stops
39
- describing what is rendered. **Measure the cell, not the padding.**
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
- | `menuIconName` | `IconName` | `"Menu"` | Icon of the menu cell. |
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 16px the glyph no longer leads; what makes it required is the row, where a cell without one breaks the rhythm and reads as a failed image. |
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 24px, a `shrink-0` 16px icon plus `px-1`, against a
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: a deliberate divergence from the native baselines
132
-
133
- Cell icons are `Icon size="xs"` — **16px** — over a 12px (`text-xs`) label. That
134
- is under both native baselines on purpose: iOS HIG draws a tab glyph at ~25pt
135
- over a 10pt label, Material 3 at 24dp over a 12sp one. At the 20px this bar used
136
- before it was already below both; 16px puts it at two thirds of MD3. **This is a
137
- decision, not an oversight — do not "correct" it upwards.** The bar is
138
- label-forward.
139
-
140
- The measurement behind the call: 16px against 20px is −20% linear and −36% in
141
- area, enough that the glyph stops out-reading its label and settles level with
142
- it. The word is what identifies the destination; the icon is what keeps the row
143
- scannable and what makes a cell look intact — which is why `iconName` is still
144
- required with no fallback. The size is the `xs` rung of the shared `Icon` scale
145
- and not a per-call `className` override: an 18px rung was considered 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
- The padding was **not** compensated when the icon shrank: `py-1.5` stayed, so
150
- the bar came down from 59px to 55px. A lower bar was the point of the smaller
151
- glyph, not a side effect of it.
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
- (`designTokens.navItem.selectedPill`, with `navItem.basePill` carrying the
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 `navItem.selected` can still afford a 10% tint because a 2px rule
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. The absence of a
315
- shadow is equally deliberate (see Layout). The sheet's own 300ms open is owned
316
- by `SheetContent`.
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