laif-ds 0.2.92 → 1.0.0
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 +20 -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 +37 -38
- package/dist/agent-docs/components/AppBottomNav.md +426 -0
- package/dist/agent-docs/components/AppSidebar.md +302 -19
- package/dist/agent-docs/components/Sidebar.md +4 -0
- package/dist/agent-docs/components-list.md +2 -1
- package/dist/agent-docs/manifest.json +113 -14
- package/dist/components/design-tokens.js +84 -0
- package/dist/components/ui/app-bottom-nav.js +165 -0
- package/dist/components/ui/app-sidebar-mobile.js +220 -0
- package/dist/components/ui/app-sidebar.js +366 -109
- package/dist/components/ui/sheet.js +26 -24
- package/dist/components/ui/sidebar.js +190 -166
- package/dist/index.d.ts +92 -1
- package/dist/index.js +183 -181
- 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 +3 -2
package/dist/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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.0]
|
|
9
|
+
|
|
10
|
+
### 🚀 Added
|
|
11
|
+
|
|
12
|
+
- **AppSidebar**: Below 768px, the sidebar now renders a Settings-style drill-down menu instead of the desktop tree in a full-screen sheet — grouped cards with dividers, 44px rows with a leading icon and a chevron only where there are children, and items with `subItems` open a level-1 screen (with a `‹ Menu` back control) instead of expanding inline. This replaces the previous mobile behavior unconditionally; there is no prop to opt out, and `collapsible` has no effect below 768px
|
|
13
|
+
- **AppSidebar**: Added `navigationDisplay` prop (`"list" | "tab"`, default `"list"`) — in `"tab"` mode the `navigation` groups render as a tab bar at the top of the sidebar and show one group at a time, instead of stacking every group under its own label. Only the selected tab shows its label, animating open to fit it exactly; every other tab is icon-only. `navigationFooter` is never tabbed and always stays visible at the bottom. A lone `navigation` group still renders without a label (in both displays and on mobile), and with a single group the tab bar is dropped entirely. Collapsed to the icon rail, the bar becomes a single group selector — the active group's icon with a chevron — that opens a `menuitemradio` menu. `NavGroup` gained an optional `iconName`, read only in tab mode
|
|
14
|
+
- **AppBottomNav**: Added a new mobile bottom navigation bar (visible below 768px, `md:hidden`), meant to be mounted as the last child of `SidebarInset`, in the normal flow and never `fixed`. The bar is full-bleed and attached to the bottom edge — `bg-d-card` with a top border only, rounded top corners, `overflow-hidden`, no shadow — for a total height of 55px plus the safe-area inset, painted as bar surface with no minimum floor. It renders up to 5 destinations (`BottomNavItem[]`, already filtered, sorted and route-matched by the app) with 16px icons over a 12px label — deliberately below both native tab-bar baselines, label-forward — plus a Menu cell that opens `AppSidebar`'s mobile panel (`showMenu={false}` drops it and spends the freed slot on a 6th destination instead). A cell with its own `subItems` opens the panel already drilled into that level instead of navigating, via the sidebar context's new `mobileNavTarget`. The active cell is marked with a rounded pill using the sidebar's own active-row colors (`bg-d-sidebar-accent` / `text-d-sidebar-accent-foreground` / `font-bold`, no border) instead of a solid brand fill — the same low-contrast tradeoff the desktop sidebar's active row has always carried on that surface (~1.1–1.2:1, under the 3:1 WCAG 1.4.11 floor for a non-text indicator), carried here by weight rather than tint; label and icon stay at 10:1+ regardless. Hover is limited to fine-pointer, hover-capable devices so a tap can't leave a stuck hover tint next to the active cell
|
|
15
|
+
- **design-tokens**: Added `designTokens.sizes.touch` (44px), `designTokens.motion` (shared easing/duration for the sidebar's tab animation), and `designTokens.navItem` (selected-row styling built on foreground weight and a leading bar for the sidebar's own rows, plus a `basePill`/`selectedPill` variant carrying the rounded-pill treatment for `AppBottomNav`'s active cell)
|
|
16
|
+
|
|
17
|
+
### 🔧 Changed
|
|
18
|
+
|
|
19
|
+
- **Sidebar**: On mobile, the full-screen `Sheet` branch now takes precedence over `collapsible="none"` — previously, pinning `collapsible="none"` left the trigger toggling `openMobile` with no sheet listening to it, making navigation unreachable below 768px. The mobile sheet now also takes focus on open (instead of landing on whatever `headerContent` renders first, which could raise the keyboard over the menu) and restores focus to the trigger on close
|
|
20
|
+
- **Sheet**: `side="full"` no longer zooms under `prefers-reduced-motion` — the fade stays
|
|
21
|
+
|
|
22
|
+
### 🐛 Fixed
|
|
23
|
+
|
|
24
|
+
- **AppSidebar**: Mobile row labels now wrap instead of clamping to two lines, so long labels remain fully readable at 200% text zoom (WCAG 1.4.4)
|
|
25
|
+
- **AppSidebar**: Mobile group titles no longer render at a 3.94:1 contrast ratio (muted text on a muted panel)
|
|
26
|
+
- **AppSidebar**: In `navigationDisplay="tab"` mode, focus now survives collapsing and re-expanding the sidebar while a tab is focused, and the selected tab is tracked via React state instead of Radix's `data-state` — `TooltipTrigger asChild` was silently overwriting that attribute on the trigger, which had been killing every active-state rule keyed off it
|
|
27
|
+
|
|
8
28
|
## [laif-ds@0.2.92]
|
|
9
29
|
|
|
10
30
|
### 🐛 Fixed
|
package/dist/_virtual/index3.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { getDefaultExportFromCjs as
|
|
3
|
-
import { __require as
|
|
4
|
-
var
|
|
5
|
-
const
|
|
2
|
+
import { getDefaultExportFromCjs as r } from "./_commonjsHelpers.js";
|
|
3
|
+
import { __require as e } from "../node_modules/style-to-js/cjs/index.js";
|
|
4
|
+
var t = e();
|
|
5
|
+
const a = /* @__PURE__ */ r(t);
|
|
6
6
|
export {
|
|
7
|
-
|
|
7
|
+
a as default
|
|
8
8
|
};
|
package/dist/_virtual/index4.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { getDefaultExportFromCjs as
|
|
3
|
-
import { __require as
|
|
4
|
-
var t =
|
|
5
|
-
const
|
|
2
|
+
import { getDefaultExportFromCjs as e } from "./_commonjsHelpers.js";
|
|
3
|
+
import { __require as r } from "../node_modules/extend/index.js";
|
|
4
|
+
var t = r();
|
|
5
|
+
const x = /* @__PURE__ */ e(t);
|
|
6
6
|
export {
|
|
7
|
-
|
|
7
|
+
x as default
|
|
8
8
|
};
|
package/dist/_virtual/index5.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { getDefaultExportFromCjs as e } from "./_commonjsHelpers.js";
|
|
3
|
-
import { __require as
|
|
4
|
-
var
|
|
5
|
-
const
|
|
3
|
+
import { __require as t } from "../node_modules/eventemitter3/index2.js";
|
|
4
|
+
var r = t();
|
|
5
|
+
const m = /* @__PURE__ */ e(r);
|
|
6
6
|
export {
|
|
7
|
-
|
|
7
|
+
m as default
|
|
8
8
|
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
|
-
"generatedAt": "2026-
|
|
3
|
+
"generatedAt": "2026-09-02T16:31:01.025Z",
|
|
4
4
|
"package": {
|
|
5
5
|
"name": "laif-ds",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "1.0.0"
|
|
7
7
|
},
|
|
8
8
|
"summary": {
|
|
9
|
-
"scannedFiles":
|
|
9
|
+
"scannedFiles": 402,
|
|
10
10
|
"deprecatedComponentsCount": 2,
|
|
11
11
|
"deprecatedComponentsUsedCount": 0,
|
|
12
12
|
"componentsWithoutExamplesCount": 5,
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"repeatedClassPatternsCount": 25,
|
|
15
15
|
"lowAiReadinessComponentsCount": 41,
|
|
16
16
|
"componentsWithoutAccessibilityCoverageCount": 45,
|
|
17
|
-
"componentsWithWeakStateCoverageCount":
|
|
18
|
-
"averageAiReadinessScore":
|
|
17
|
+
"componentsWithWeakStateCoverageCount": 71,
|
|
18
|
+
"averageAiReadinessScore": 56.17
|
|
19
19
|
},
|
|
20
20
|
"deprecatedUsage": [],
|
|
21
21
|
"documentationGaps": {
|
|
@@ -111,7 +111,6 @@
|
|
|
111
111
|
"AlertDialog",
|
|
112
112
|
"AppDialog",
|
|
113
113
|
"AppMultipleSelectDropdown",
|
|
114
|
-
"AppSidebar",
|
|
115
114
|
"AspectRatio",
|
|
116
115
|
"AudioVisualizer",
|
|
117
116
|
"Avatar",
|
|
@@ -452,6 +451,14 @@
|
|
|
452
451
|
"src/components/stories/app-form.stories.tsx"
|
|
453
452
|
]
|
|
454
453
|
},
|
|
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
|
+
},
|
|
455
462
|
{
|
|
456
463
|
"className": "text-muted-foreground mt-2 text-sm",
|
|
457
464
|
"occurrences": 13,
|
|
@@ -459,6 +466,14 @@
|
|
|
459
466
|
"src/components/stories/tabs.stories.tsx"
|
|
460
467
|
]
|
|
461
468
|
},
|
|
469
|
+
{
|
|
470
|
+
"className": "text-d-muted-foreground max-w-prose text-sm",
|
|
471
|
+
"occurrences": 10,
|
|
472
|
+
"files": [
|
|
473
|
+
"src/components/stories/app-bottom-nav.stories.tsx",
|
|
474
|
+
"src/components/stories/app-sidebar.stories.tsx"
|
|
475
|
+
]
|
|
476
|
+
},
|
|
462
477
|
{
|
|
463
478
|
"className": "flex w-[550px] flex-col gap-6",
|
|
464
479
|
"occurrences": 10,
|
|
@@ -508,6 +523,13 @@
|
|
|
508
523
|
"src/components/stories/async-select.stories.tsx"
|
|
509
524
|
]
|
|
510
525
|
},
|
|
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
|
+
},
|
|
511
533
|
{
|
|
512
534
|
"className": "!text-d-foreground opacity-50",
|
|
513
535
|
"occurrences": 8,
|
|
@@ -569,46 +591,23 @@
|
|
|
569
591
|
]
|
|
570
592
|
},
|
|
571
593
|
{
|
|
572
|
-
"className": "text-
|
|
573
|
-
"occurrences": 6,
|
|
574
|
-
"files": [
|
|
575
|
-
"src/components/stories/app-kanban.stories.tsx",
|
|
576
|
-
"src/components/stories/changelog.stories.tsx",
|
|
577
|
-
"src/components/stories/playground.stories.tsx"
|
|
578
|
-
]
|
|
579
|
-
},
|
|
580
|
-
{
|
|
581
|
-
"className": "flex h-full overflow-hidden",
|
|
594
|
+
"className": "text-sm font-semibold",
|
|
582
595
|
"occurrences": 6,
|
|
583
596
|
"files": [
|
|
584
|
-
"src/components/stories/app-
|
|
585
|
-
"src/components/stories/
|
|
586
|
-
]
|
|
587
|
-
},
|
|
588
|
-
{
|
|
589
|
-
"className": "mb-2 text-sm font-medium",
|
|
590
|
-
"occurrences": 6,
|
|
591
|
-
"files": [
|
|
592
|
-
"src/components/stories/chat-message.stories.tsx",
|
|
597
|
+
"src/components/stories/app-bottom-nav.stories.tsx",
|
|
598
|
+
"src/components/stories/collapsible.stories.tsx",
|
|
593
599
|
"src/components/stories/data-table-utils-example.stories.tsx",
|
|
594
|
-
"src/components/stories/
|
|
595
|
-
|
|
596
|
-
},
|
|
597
|
-
{
|
|
598
|
-
"className": "border-d-border border px-1 capitalize",
|
|
599
|
-
"occurrences": 6,
|
|
600
|
-
"files": [
|
|
601
|
-
"src/components/stories/data-table-docs.stories.tsx",
|
|
602
|
-
"src/components/stories/data-table-no-utils-example.stories.tsx",
|
|
603
|
-
"src/components/stories/data-table-utils-example.stories.tsx"
|
|
600
|
+
"src/components/stories/truncated-cell.stories.tsx",
|
|
601
|
+
"src/components/ui/weekly-calendar/appointment-card.tsx"
|
|
604
602
|
]
|
|
605
603
|
},
|
|
606
604
|
{
|
|
607
|
-
"className": "
|
|
605
|
+
"className": "text-lg font-semibold",
|
|
608
606
|
"occurrences": 6,
|
|
609
607
|
"files": [
|
|
610
|
-
"src/components/stories/
|
|
611
|
-
"src/components/stories/
|
|
608
|
+
"src/components/stories/app-kanban.stories.tsx",
|
|
609
|
+
"src/components/stories/changelog.stories.tsx",
|
|
610
|
+
"src/components/stories/playground.stories.tsx"
|
|
612
611
|
]
|
|
613
612
|
}
|
|
614
613
|
]
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# AppBottomNav
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Mobile bottom navigation bar: up to 5 primary destinations plus a **Menu** cell
|
|
6
|
+
that opens `AppSidebar`'s mobile panel — or up to 6 destinations with
|
|
7
|
+
`showMenu={false}`, which is the same six cells either way. Visible below 768px
|
|
8
|
+
only (`md:hidden`), and rendered **in the normal flow — never `fixed`**.
|
|
9
|
+
|
|
10
|
+
The bar is **full-bleed and attached to the bottom edge**: the surface is
|
|
11
|
+
`bg-d-card` with `border-t border-d-border`, `rounded-t-lg` on the top two
|
|
12
|
+
corners only and `overflow-hidden`, which is the same language `DrawerContent`
|
|
13
|
+
uses for its `bottom` direction. Below 768px `SidebarInset` is already
|
|
14
|
+
full-viewport-width, so the full bleed needs no positioning of its own.
|
|
15
|
+
|
|
16
|
+
**Mount it as the last child of `SidebarInset`**, outside any page-level padding
|
|
17
|
+
wrapper, and make the page content beside it the scroll container
|
|
18
|
+
(`flex-1 min-h-0 overflow-auto`). `SidebarInset` is a flex column, so with the
|
|
19
|
+
dock's `shrink-0` the bar stays put while the page scrolls — the sticky feel
|
|
20
|
+
without `fixed`, and without every consuming app having to add bottom padding to
|
|
21
|
+
every scroll container. **Two things an app can get wrong**: if the document
|
|
22
|
+
scrolls instead of the page container the bar scrolls off-screen, and any
|
|
23
|
+
horizontal padding on an ancestor breaks the full bleed — that one is a layout
|
|
24
|
+
bug, not only a cosmetic one.
|
|
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.
|
|
31
|
+
The reserve is **painted bar surface**: under a home indicator you see bar
|
|
32
|
+
colour, not page.
|
|
33
|
+
|
|
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.**
|
|
40
|
+
|
|
41
|
+
It must be rendered inside a `SidebarProvider` (it reads the sidebar context to
|
|
42
|
+
open the mobile panel); outside one, `useSidebar()` throws.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Props
|
|
47
|
+
|
|
48
|
+
| Prop | Type | Default | Description |
|
|
49
|
+
| --------------- | --------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------- |
|
|
50
|
+
| `items` | `BottomNavItem[]` | **required** | Already filtered, sorted and route-matched by the app. Max 5 used, or 6 with `showMenu={false}`. |
|
|
51
|
+
| `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
|
+
| `menuLabel` | `string` | `"Menu"` | Label of the menu cell. |
|
|
53
|
+
| `ariaLabel` | `string` | `"Main navigation"` | Accessible name of the `<nav>` landmark. |
|
|
54
|
+
| `menuIconName` | `IconName` | `"Menu"` | Icon of the menu cell. |
|
|
55
|
+
| `linkComponent` | `React.ComponentType<React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }>` | `undefined` | Router link component (e.g. Next.js `Link`); plain `<a>` otherwise. |
|
|
56
|
+
| `linkProps` | `Record<string, unknown>` | `{}` | Extra props forwarded to `linkComponent`. |
|
|
57
|
+
| `className` | `string` | `undefined` | Merged onto the dock wrapper. |
|
|
58
|
+
|
|
59
|
+
Inherits the remaining props from `Omit<React.ComponentProps<"div">, "children">`
|
|
60
|
+
(the bar renders its own cells; it takes no children).
|
|
61
|
+
|
|
62
|
+
### `BottomNavItem`
|
|
63
|
+
|
|
64
|
+
| Field | Type | Default | Description |
|
|
65
|
+
| ----------- | -------------- | ------------ | ---------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `title` | `string` | **required** | Visible label. **Must equal the sidebar `NavItem.title`** of the same destination (see below). |
|
|
67
|
+
| `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. |
|
|
69
|
+
| `isActive` | `boolean` | `undefined` | Leaf level only — the subtree state is derived from `subItems`. |
|
|
70
|
+
| `subItems` | `NavSubItem[]` | `undefined` | Presence makes the cell open the mobile menu at this level instead of navigating. |
|
|
71
|
+
| `ariaLabel` | `string` | `undefined` | Only when the spoken name must differ from the label; it must still contain the visible text. |
|
|
72
|
+
|
|
73
|
+
`NavSubItem` is the same type `AppSidebar` uses:
|
|
74
|
+
`{ title: string; url: string; isActive?: boolean; iconName?: IconName }`.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Behavior
|
|
79
|
+
|
|
80
|
+
### Layout
|
|
81
|
+
|
|
82
|
+
- **6 cells maximum**: the first 5 `items` plus the Menu cell, or the first 6
|
|
83
|
+
`items` with `showMenu={false}`. The budget is on **cells and does not move
|
|
84
|
+
with `showMenu`** — hiding the Menu spends the slot it frees on a destination
|
|
85
|
+
rather than fattening the other cells, which is the only reason to hide it.
|
|
86
|
+
Neither number is configurable.
|
|
87
|
+
- Cells are always **`flex-1 min-w-0`** — equal width, evenly distributed, at
|
|
88
|
+
every count and whatever a cell holds. Both halves matter: `flex-1` is
|
|
89
|
+
`1 1 0%`, which stays even only while no cell's content forces it wider, since
|
|
90
|
+
a flex item's automatic minimum size is its min-content width. `min-w-0`
|
|
91
|
+
removes that escape hatch, so the label truncates instead of the row going
|
|
92
|
+
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
|
|
94
|
+
narrowest cell of 52px — but by arithmetic rather than by construction.)
|
|
95
|
+
- **Six is a maximum, not a count.** Nothing pads the row: with fewer items the
|
|
96
|
+
cells simply get fatter, as every native tab bar does, and Menu stays pinned
|
|
97
|
+
to the thumb-reachable bottom-right corner. Minus the 4px inset on each side,
|
|
98
|
+
at 375px that is **61.2px** at 6 cells, **73.4px** at 5, **91.8px** at 4 and
|
|
99
|
+
**122.3px** at 3 (at 320px, 6 cells are 52px) — all above the 44px touch floor
|
|
100
|
+
(`min-h-11`, which lets a cell grow but never clip). The `FewerItems` story is
|
|
101
|
+
three items plus Menu.
|
|
102
|
+
- **The sixth cell is paid for out of the label, and the bill is not small.**
|
|
103
|
+
Take off `px-1` and the label box is **53px at 375px**, against the 65.4px it
|
|
104
|
+
had at five cells. Measured in the `WithoutMenu` story at 375px, that is
|
|
105
|
+
enough to ellipsize labels that used to fit: `Dashboard` and `Documenti`
|
|
106
|
+
render at 63px and now truncate, `Impostazioni` at 74px truncates hard,
|
|
107
|
+
while `Clienti`, `Report` and `Storico` fit. **At six cells, plan for ~8
|
|
108
|
+
characters at 375px** — one-word labels, and short ones. `WithoutMenu` at
|
|
109
|
+
375px is the tightest row the component can render; check a real label set
|
|
110
|
+
there before shipping six.
|
|
111
|
+
- The **dock carries the surface**, not the inner `<nav>`: full-bleed, attached
|
|
112
|
+
to the bottom edge, `border-t` + `rounded-t-lg` (8px, the value
|
|
113
|
+
`DrawerContent` uses for its bottom direction), and `overflow-hidden`, which
|
|
114
|
+
guarantees the corners of the surface stay the dock's own whatever a cell
|
|
115
|
+
paints. The `<nav>` is pure layout, with a **uniform 4px inset** (`p-1`): that
|
|
116
|
+
is what keeps the active pill clear of the 8px top curve — a full-bleed cell
|
|
117
|
+
with a radius of its own comes out shaved on the first and last slot — and it
|
|
118
|
+
is uniform because a pill nearer one edge than the other reads as a
|
|
119
|
+
misalignment. **No gap** between cells: only one is ever filled, so the
|
|
120
|
+
neighbours are transparent and a gap would only narrow the label.
|
|
121
|
+
- **No shadow, deliberately.** The bar is in-flow, so nothing ever passes behind
|
|
122
|
+
it, and Tailwind's shadows project downwards — from the bottom edge that is
|
|
123
|
+
off-screen. In dark a black-alpha shadow is invisible anyway, so the
|
|
124
|
+
separation would rest on the border in one theme and on two mechanisms in the
|
|
125
|
+
other. The border is the single carrier.
|
|
126
|
+
- **No visual hint on cells that open the menu** — no chevron, no caret. At
|
|
127
|
+
~61px a chevron costs two characters of an already-truncating label, and a
|
|
128
|
+
hint on some cells only breaks the even rhythm that makes a tab bar scannable.
|
|
129
|
+
The feedback is the reveal itself.
|
|
130
|
+
|
|
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
|
|
146
|
+
refused, because adding a step to `sizeMap` is a scale decision for ~93
|
|
147
|
+
components taken to serve one bar.
|
|
148
|
+
|
|
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.
|
|
152
|
+
|
|
153
|
+
### Active state
|
|
154
|
+
|
|
155
|
+
Exactly one cell is ever marked, with `data-active="true"` and a **rounded
|
|
156
|
+
pill**: `bg-d-sidebar-accent text-d-sidebar-accent-foreground font-bold` on a
|
|
157
|
+
`rounded-md` (6px) surface, inset 4px from every edge of the dock
|
|
158
|
+
(`designTokens.navItem.selectedPill`, with `navItem.basePill` carrying the
|
|
159
|
+
radius on every cell). **No border on any side.**
|
|
160
|
+
|
|
161
|
+
This is the desktop sidebar row's own active state, ported verbatim — the pair
|
|
162
|
+
`sidebarMenuButtonVariants` paints under `data-[active=true]`. One vocabulary
|
|
163
|
+
for "you are here" across the two navigations was judged worth more than either
|
|
164
|
+
surface being optimal on its own.
|
|
165
|
+
|
|
166
|
+
#### Contrast: very low, and inherited on purpose
|
|
167
|
+
|
|
168
|
+
With no border, the fill is the only carrier of the state, which puts it under
|
|
169
|
+
WCAG 1.4.11 (≥ 3:1 for a non-text indicator) against the bar surface. **The
|
|
170
|
+
sidebar's accent is nowhere near that floor in either theme**:
|
|
171
|
+
`--d-sidebar-accent` is `--colors--surface--tertiary` at 40% alpha, so it
|
|
172
|
+
composites to a few levels off whatever it sits on — `#efefef` on the light bar,
|
|
173
|
+
`#2f2f2f` on the dark one.
|
|
174
|
+
|
|
175
|
+
| against the bar (`d-card`) | light (`#fafafa`) | dark (`#1f1f1f`) |
|
|
176
|
+
| ------------------------------------------- | ----------------- | ---------------- |
|
|
177
|
+
| `bg-d-sidebar-accent` (in use, composited) | **1.10:1** ✗ | **1.23:1** ✗ |
|
|
178
|
+
| `bg-d-accent` (solid tertiary) | 1.26:1 ✗ | 1.77:1 ✗ |
|
|
179
|
+
| `bg-d-primary` (previous) | 1.98:1 ✗ | 7.99:1 ✓ |
|
|
180
|
+
| `bg-d-primary/40` | 1.30:1 ✗ | 2.39:1 ✗ |
|
|
181
|
+
| `bg-d-primary/10` | 1.07:1 ✗ | 1.20:1 ✗ |
|
|
182
|
+
| `bg-d-foreground` (conforming, dropped) | 13.01:1 ✓ | 12.49:1 ✓ |
|
|
183
|
+
|
|
184
|
+
| on the fill | light | dark |
|
|
185
|
+
| --------------------------------------------- | --------- | --------- |
|
|
186
|
+
| `text-d-sidebar-accent-foreground` (label, icon) | 11.9:1 ✓ | 10.1:1 ✓ |
|
|
187
|
+
|
|
188
|
+
Two things follow. **The label is never at risk**:
|
|
189
|
+
`--d-sidebar-accent-foreground` is `--colors--text--body-primary`, the very
|
|
190
|
+
colour the inactive cells already use, so text and icon read 11.9:1 and 10.1:1
|
|
191
|
+
on the composited fill — what falls under the floor is the _separation of the
|
|
192
|
+
active surface from the bar_, not legibility. And **this is not a regression
|
|
193
|
+
introduced by the bottom bar**: the same fill on the sidebar's own surface
|
|
194
|
+
(`#f5f5f5`) measures 1.08:1, so the desktop sidebar has always marked the
|
|
195
|
+
current row by **weight**, with the tint as a hint. Here the weight does the
|
|
196
|
+
same job.
|
|
197
|
+
|
|
198
|
+
Which is why the pill is `font-bold` and not `font-semibold`: with a fill this
|
|
199
|
+
faint, the weight _is_ the state. It costs label width on a cell that already
|
|
200
|
+
truncates at 375px — if that has to be bought back, `font-semibold` is the place
|
|
201
|
+
to spend it, not the fill.
|
|
202
|
+
|
|
203
|
+
If the 3:1 floor ever has to be met, the table's last row is the way back
|
|
204
|
+
(`bg-d-foreground` + `text-d-background`) — a near-black pill, dropped once
|
|
205
|
+
already because it reads as a selected chip rather than as the current tab.
|
|
206
|
+
`bg-d-primary` + `text-d-primary-foreground` is the other history: under the
|
|
207
|
+
floor in light at 1.98:1, but separating by hue, which 1.4.11 does not measure.
|
|
208
|
+
A tint of primary is not an option, as the table shows.
|
|
209
|
+
|
|
210
|
+
The sidebar's `navItem.selected` can still afford a 10% tint because a 2px rule
|
|
211
|
+
carries the state there; here there is no rule to fall back on.
|
|
212
|
+
|
|
213
|
+
`rounded-md` and not `radius.sm`: `sm` is 4px in the Tailwind v4 bundle and 2px
|
|
214
|
+
in the v3 one, and this would be the only place the two disagree. Not
|
|
215
|
+
`rounded-d-md` either — that maps onto `--d-radius` and becomes 18px under the
|
|
216
|
+
claymorphism theme, while the dock around it is a fixed `rounded-t-lg`.
|
|
217
|
+
|
|
218
|
+
- **Leaf item active** (`isActive`) → the cell is a link with
|
|
219
|
+
`aria-current="page"`.
|
|
220
|
+
- **Parent whose child is current** (any `subItems[].isActive`) → the cell is a
|
|
221
|
+
`<button>`, marked, **without** `aria-current`: a button is not a page, it is a
|
|
222
|
+
disclosure. So the app only has to mark the leaf.
|
|
223
|
+
- **Nothing in the bar owns the route** → the **Menu** cell takes the pill, with
|
|
224
|
+
no `aria-current` and no `aria-expanded="true"`. Five neutral cells read as
|
|
225
|
+
"the component failed to work out where I am"; the truthful statement is "the
|
|
226
|
+
page you are on is reachable through here", and it is redundant for a screen
|
|
227
|
+
reader, so nothing is added to the accessibility tree. With
|
|
228
|
+
`showMenu={false}` there is nothing to hand the pill to and the bar does
|
|
229
|
+
render entirely neutral — the honest state when every cell is a destination
|
|
230
|
+
and none of them is the current one.
|
|
231
|
+
- Two active items is an app bug: the first in order wins and one warning is
|
|
232
|
+
logged.
|
|
233
|
+
|
|
234
|
+
Inactive cells use `text-d-foreground` with `font-medium` (not
|
|
235
|
+
`text-d-muted-foreground`, which measures 4.11:1 on the card in the light theme
|
|
236
|
+
— below the 4.5:1 floor at 12px; on `d-background` it would be worse, 3.94:1,
|
|
237
|
+
so the full-bleed surface does not change the conclusion). The subordination
|
|
238
|
+
comes from the weight and from the absence of the pill.
|
|
239
|
+
|
|
240
|
+
**Interaction states.** Inactive cells hover and press to
|
|
241
|
+
`bg-d-sidebar-accent` + `text-d-sidebar-accent-foreground`, and the marked cell
|
|
242
|
+
has **no hover or press variant at all** — again the sidebar's behaviour rather
|
|
243
|
+
than an omission: `sidebarMenuButtonVariants` gives the active row the accent
|
|
244
|
+
and then repeats that same accent under `hover:` and `active:`, so the current
|
|
245
|
+
row is inert to the pointer. The consequence is that the three states share one
|
|
246
|
+
colour and only the bold label separates the current cell from a hovered one.
|
|
247
|
+
Survivable in a vertical list, where the pointer is on the row you are looking
|
|
248
|
+
at; not in a six-cell row, where a hover tint left behind by a tap sits _beside_
|
|
249
|
+
the active cell and reads as a second one. **So hover is gated behind
|
|
250
|
+
`[@media(hover:hover) and (pointer:fine)]`** — the sidebar's own fix for exactly
|
|
251
|
+
this, applied there on its tab triggers. Hover is for a real mouse (below 768px
|
|
252
|
+
also covers a narrow desktop window); the finger gets the press state, which is
|
|
253
|
+
immediate, cannot stick, and previews the destination becoming current. There is
|
|
254
|
+
no `interaction.active` squash: its scale is for buttons, and a tab-bar cell is a
|
|
255
|
+
surface you move between, not an object you push — an anisotropic scale on a
|
|
256
|
+
12px truncating label distorts the one thing the cell exists to let you read.
|
|
257
|
+
|
|
258
|
+
### Opening the menu
|
|
259
|
+
|
|
260
|
+
Both the Menu cell and any cell with `subItems` open `AppSidebar`'s mobile Sheet
|
|
261
|
+
(`setOpenMobile(true)`, never `toggleSidebar()`).
|
|
262
|
+
|
|
263
|
+
- The **Menu** cell always opens at level 0. Its `aria-expanded` follows
|
|
264
|
+
`openMobile`. There is no visual treatment of the open state: the sheet is
|
|
265
|
+
full-screen, so the bar is entirely covered while it is open.
|
|
266
|
+
- A cell with `subItems` opens the panel **already drilled into that level**. It
|
|
267
|
+
publishes `item.title` as the sidebar context's `mobileNavTarget` and
|
|
268
|
+
`AppSidebarMobile` resolves it, at mount, against the first `NavItem` across
|
|
269
|
+
`navigation` then `navigationFooter` whose `title` matches and which has
|
|
270
|
+
sub-items. **This is why `BottomNavItem.title` must equal the sidebar
|
|
271
|
+
`NavItem.title`** — both lists come from the same app-side source. No match
|
|
272
|
+
opens level 0 and logs a warning; it is never an error surface.
|
|
273
|
+
- The target only ever describes the next open: it is cleared when the panel
|
|
274
|
+
closes, so a stale target can never re-drill.
|
|
275
|
+
|
|
276
|
+
#### Opting out: `showMenu={false}`
|
|
277
|
+
|
|
278
|
+
**"Under 768px there is always a way to open the navigation" is the invariant
|
|
279
|
+
the bar guarantees on its own, and `showMenu={false}` is an app taking that
|
|
280
|
+
guarantee over — not switching it off.** With no Menu cell, the only thing left
|
|
281
|
+
in the bar that reaches the mobile panel is a cell that happens to carry
|
|
282
|
+
`subItems`; the full navigation, and every destination that did not make the six
|
|
283
|
+
slots, is otherwise unreachable.
|
|
284
|
+
|
|
285
|
+
So the opt-out is for an app that already provides that way somewhere else — a
|
|
286
|
+
`SidebarTrigger` in its own header is the case it exists for (see the
|
|
287
|
+
`WithoutMenu` story, which renders one). **It is not warned about**, because the
|
|
288
|
+
component cannot see the app's header and a warning it can neither confirm nor
|
|
289
|
+
suppress is noise. Turning it off is a claim; the app has to honour it.
|
|
290
|
+
|
|
291
|
+
### Accessibility
|
|
292
|
+
|
|
293
|
+
- `<nav aria-label={ariaLabel}>` — a named landmark, because this is the primary
|
|
294
|
+
way around the app.
|
|
295
|
+
- Cells that open the menu are `<button type="button">` with
|
|
296
|
+
`aria-expanded`, and deliberately **no** `aria-controls` (the drill panel's id
|
|
297
|
+
is Radix-generated and unreachable from the bar) and **no** `aria-haspopup`
|
|
298
|
+
(`"menu"` would promise arrow-key menu semantics the drill-down does not
|
|
299
|
+
implement).
|
|
300
|
+
- No `title` attributes: they do nothing on touch and some screen readers read
|
|
301
|
+
them after the name.
|
|
302
|
+
- Focus ring is `designTokens.focusRing` + `focus-visible:ring-inset` — the
|
|
303
|
+
cells touch each other, so a 3px outset ring would spill over the neighbouring
|
|
304
|
+
cell and read as if two cells were focused.
|
|
305
|
+
- Above 768px the dock is `display: none`, which also removes every cell from
|
|
306
|
+
the tab order. There is no JS breakpoint gate: `useIsMobile` returns `false` on
|
|
307
|
+
the first render, so a JS gate would omit the bar on SSR and first paint and
|
|
308
|
+
pop it in on hydration.
|
|
309
|
+
|
|
310
|
+
### Motion
|
|
311
|
+
|
|
312
|
+
No entrance animation — the bar is chrome, present from first paint. The only
|
|
313
|
+
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`.
|
|
317
|
+
|
|
318
|
+
### Warnings (`console.warn`, deduplicated per message)
|
|
319
|
+
|
|
320
|
+
- more `items` than slots, naming the dropped titles;
|
|
321
|
+
- `items: []` together with `showMenu={false}`, where the bar renders `null`;
|
|
322
|
+
- two or more active items;
|
|
323
|
+
- a duplicate `url` + `title` pair (duplicate React keys make React reuse DOM
|
|
324
|
+
and state across cells);
|
|
325
|
+
- an unresolved drill target (logged by `AppSidebarMobile`).
|
|
326
|
+
|
|
327
|
+
### Edge cases
|
|
328
|
+
|
|
329
|
+
- **`items: []`** renders the bar with the Menu cell only, spanning the full
|
|
330
|
+
width: "under 768px there is always a way to open the navigation" is the
|
|
331
|
+
design system's invariant, and `null` would strand a user whose permissions
|
|
332
|
+
resolve to zero primary destinations.
|
|
333
|
+
- **`items: []` with `showMenu={false}`** is the one configuration that renders
|
|
334
|
+
**`null`**, with a warning: there is nothing left to put in the bar, and the
|
|
335
|
+
alternative is a 9px strip of card and a border. It is warned about where the
|
|
336
|
+
opt-out itself is not, because the two halves of it are set in different
|
|
337
|
+
places — permission filtering and a layout prop — so an app arrives here by
|
|
338
|
+
accident rather than on purpose.
|
|
339
|
+
- **More items than slots**: the first 5 in order are kept (6 with
|
|
340
|
+
`showMenu={false}`), the rest dropped with a warning naming them. Ordering is
|
|
341
|
+
the app's job, so first-N is the only non-arbitrary cut.
|
|
342
|
+
- **An item without an icon** is a compile error, not a runtime fallback.
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Examples
|
|
347
|
+
|
|
348
|
+
### App shell
|
|
349
|
+
|
|
350
|
+
```tsx
|
|
351
|
+
import {
|
|
352
|
+
AppBottomNav,
|
|
353
|
+
AppSidebar,
|
|
354
|
+
SidebarInset,
|
|
355
|
+
SidebarProvider,
|
|
356
|
+
type BottomNavItem,
|
|
357
|
+
} from "laif-ds";
|
|
358
|
+
import Link from "next/link";
|
|
359
|
+
|
|
360
|
+
// Permission filtering, ordering and route matching all stay in the app.
|
|
361
|
+
const bottomNavItems: BottomNavItem[] = [
|
|
362
|
+
{ title: "Dashboard", url: "/dashboard", iconName: "Home", isActive: true },
|
|
363
|
+
{ title: "Clienti", url: "/clients", iconName: "Users" },
|
|
364
|
+
{
|
|
365
|
+
// Same title as the sidebar item, so the menu opens inside this level.
|
|
366
|
+
title: "Documenti",
|
|
367
|
+
url: "#",
|
|
368
|
+
iconName: "FileText",
|
|
369
|
+
subItems: [
|
|
370
|
+
{ title: "Recenti", url: "/documents/recent" },
|
|
371
|
+
{ title: "Condivisi", url: "/documents/shared" },
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
{ title: "Report", url: "/reports", iconName: "ChartLine" },
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
378
|
+
return (
|
|
379
|
+
<SidebarProvider>
|
|
380
|
+
<AppSidebar navigation={navigation} linkComponent={Link} />
|
|
381
|
+
<SidebarInset>
|
|
382
|
+
{/* The page is the scroll container, not the document. */}
|
|
383
|
+
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
|
384
|
+
<AppBottomNav items={bottomNavItems} linkComponent={Link} />
|
|
385
|
+
</SidebarInset>
|
|
386
|
+
</SidebarProvider>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Localised copy
|
|
392
|
+
|
|
393
|
+
```tsx
|
|
394
|
+
<AppBottomNav
|
|
395
|
+
items={bottomNavItems}
|
|
396
|
+
menuLabel={t("nav.menu")}
|
|
397
|
+
ariaLabel={t("nav.mainNavigation")}
|
|
398
|
+
/>
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Without the Menu cell
|
|
402
|
+
|
|
403
|
+
The header trigger is not optional here — it is what keeps the mobile panel
|
|
404
|
+
reachable once the Menu cell is gone.
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
<SidebarInset>
|
|
408
|
+
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-2">
|
|
409
|
+
<SidebarTrigger />
|
|
410
|
+
</header>
|
|
411
|
+
<div className="min-h-0 flex-1 overflow-auto p-4">{children}</div>
|
|
412
|
+
{/* Six destinations: the slot the Menu cell would have taken. */}
|
|
413
|
+
<AppBottomNav items={sixItems} showMenu={false} linkComponent={Link} />
|
|
414
|
+
</SidebarInset>
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Notes
|
|
420
|
+
|
|
421
|
+
- **Routing**: pass `linkComponent` (e.g. Next.js `Link`) to avoid full page
|
|
422
|
+
reloads; the design system never imports a router itself.
|
|
423
|
+
- **Icons**: `iconName` goes through the `Icon` component (lucide names).
|
|
424
|
+
- **Pairing**: the bar is a sibling of `AppSidebar`, not a replacement — the
|
|
425
|
+
full navigation is still reachable through the Menu cell, or through whatever
|
|
426
|
+
opens the mobile panel in its place under `showMenu={false}`.
|