laif-ds 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ Low-level primitives to build responsive sidebars with desktop/offcanvas modes,
16
16
  - **SidebarHeader / SidebarFooter**: Top/bottom containers.
17
17
  - **SidebarContent**: Scrollable body.
18
18
  - **SidebarGroup / SidebarGroupLabel / SidebarGroupAction / SidebarGroupContent**: Labeled groups with optional action.
19
- - **SidebarMenu / SidebarMenuItem / SidebarMenuButton / SidebarMenuAction / SidebarMenuBadge**: Menu building blocks.
19
+ - **SidebarMenu / SidebarMenuItem / SidebarMenuButton / SidebarMenuAction / SidebarMenuBadge**: Menu building blocks. `SidebarMenuBadge` is **deprecated** — see below.
20
20
  - **SidebarMenuSub / SidebarMenuSubItem / SidebarMenuSubButton**: Nested menus.
21
21
 
22
22
  ---
@@ -30,8 +30,78 @@ Low-level primitives to build responsive sidebars with desktop/offcanvas modes,
30
30
  toggle an `openMobile` no sheet is listening to, leaving the navigation
31
31
  unreachable. So on mobile `collapsible` has no effect at all.
32
32
  - **Collapsible**: `icon` mode shows only icons and tooltips; `offcanvas` moves panel off-screen.
33
+ - **Icon-mode geometry** (`SidebarMenuButton` in the 3rem rail): a 32px pill
34
+ (`!size-8`, `mx-auto`) with **6px** of padding (`!p-1.5`) around a **20px**
35
+ glyph (`[&>svg]:size-5`) — 16px in expanded rows, where the button's own
36
+ `[&>svg]:size-4` applies. The three numbers come from
37
+ `navTokens.rail.button` (`src/components/nav-tokens.ts`) and are one decision: `32 - 2*6 = 20` makes the
38
+ content box **exactly** the glyph, which is what keeps every glyph on the
39
+ panel's centre line. The label stays mounted as a `flex-1` sibling (it is the
40
+ row's accessible name), so any slack in that box is claimed by the label and
41
+ pushes the glyph off-centre by half of it. Give the glyph a different size and
42
+ the padding has to follow.
33
43
  - **Keyboard**: Toggle with ⌘/Ctrl + B.
34
44
  - **Tooltips**: Menu buttons show tooltips only when collapsed on desktop.
45
+ - **Peek (overlay expansion)**: see below.
46
+
47
+ ---
48
+
49
+ ## `SidebarMenuBadge` is deprecated
50
+
51
+ Use `AppSidebar` with **`NavItem.badge` / `NavItem.badgeLabel`** instead (see
52
+ `AppSidebar.md` → "Badges"). The primitive still works and is still exported,
53
+ unchanged, for a consumer hand-building a `Sidebar` out of these parts — but it
54
+ is the wrong tool for a navigation row:
55
+
56
+ - it is `group-data-[collapsible=icon]:hidden`, so whatever it carries simply
57
+ **disappears** in the 3rem rail, where `NavItem.badge` degrades to a dot and
58
+ moves the value into the tooltip;
59
+ - it has **no spoken form** — `NavItem.badgeLabel` rides an `sr-only` span
60
+ inside the row while the visual mark is `aria-hidden`;
61
+ - it is positioned absolutely over the row rather than laid out in it, so it
62
+ cannot share the row with a chevron or an action;
63
+ - it renders on one surface, where `NavItem.badge` renders on all four a
64
+ `NavItem` feeds, the mobile panel and `AppBottomNav` included.
65
+
66
+ Removing it would be a breaking change on a published package, so it stays until
67
+ a major.
68
+
69
+ ---
70
+
71
+ ## Peek — `useSidebar().peek` / `setPeek`
72
+
73
+ A second, temporary way for the sidebar to be expanded, used by `AppSidebar`'s
74
+ `expandOnHover`. The primitive provides the mechanism only: the hover, focus and
75
+ pin logic lives in `AppSidebar`, which calls `setPeek` and nothing else.
76
+
77
+ ```ts
78
+ const { state, open, setOpen, peek, setPeek } = useSidebar();
79
+ ```
80
+
81
+ - `peek: boolean` — temporary expansion driven by hover or focus.
82
+ - `setPeek(peek: boolean)` — the only thing the owner of the logic writes.
83
+
84
+ Three things follow from it, and each exists for a reason:
85
+
86
+ - **`state` is `open || peek ? "expanded" : "collapsed"`.** Widening the panel
87
+ alone would not work: the children read `state` and the
88
+ `group-data-[collapsible=icon]:*` rules, not the panel's width, so labels,
89
+ inline submenus and the tooltip suppression on `SidebarMenuButton` all hang off
90
+ this. Note that `data-collapsible` empties as soon as `state` is `"expanded"`,
91
+ peek included.
92
+ - **`peek` never goes through `setOpen`,** so it never writes the
93
+ `sidebar_state` cookie. Otherwise every pass of the mouse would overwrite the
94
+ consumer's persisted preference with noise.
95
+ - **The wrapper gets `data-peek="true"`,** and that is what keeps the peek an
96
+ _overlay_ rather than a push: the gap element and `SidebarInset` cancel the
97
+ effect of `data-state` on `data-peek="true"` and stay at icon width (per
98
+ variant — `floating` / `inset` keep their
99
+ `calc(var(--sidebar-width-icon) + 1rem)`), while the `fixed` panel takes the
100
+ raised z-index and the shadow. Any consumer styling that keys off
101
+ `data-state="collapsed"` for layout has to be re-anchored the same way.
102
+
103
+ `peek` is forced to `false` below 768px, where the panel is a Sheet and there is
104
+ no hover to speak of.
35
105
 
36
106
  ---
37
107
 
@@ -66,6 +136,7 @@ export function Shell() {
66
136
  <SidebarMenuButton isActive>
67
137
  <span>Dashboard</span>
68
138
  </SidebarMenuButton>
139
+ {/* Deprecated — prefer `AppSidebar` + `NavItem.badge`. */}
69
140
  <SidebarMenuBadge>3</SidebarMenuBadge>
70
141
  </SidebarMenuItem>
71
142
  </SidebarMenu>
@@ -46,9 +46,9 @@ This document provides a complete mapping of all components available in the lai
46
46
  ### Layout & Structure Components
47
47
 
48
48
  - **Accordion** - Collapsible content panels for organizing information hierarchically
49
- - **AppBottomNav** - Mobile bottom navigation bar (`md:hidden`, in the normal flow as the last child of `SidebarInset`): six equal cells — up to 5 primary destinations plus a Menu cell that opens `AppSidebar`'s mobile panel, optionally already drilled into a level, or up to 6 destinations with `showMenu={false}` when the app opens that panel from its own header
49
+ - **AppBottomNav** - Mobile bottom navigation bar (`md:hidden`, in the normal flow as the last child of `SidebarInset`): six equal cells — up to 5 primary destinations plus a Menu cell that opens `AppSidebar`'s mobile panel, optionally already drilled into a level, or up to 6 destinations with `showMenu={false}` when the app opens that panel from its own header, with an optional badge per cell (`badge`: a count as a superscript on the glyph, `99+` past 99, short text degrading to a dot) and `menuBadge` for a dot on the Menu cell, OR-ed with the badged items dropped past the cell budget
50
50
  - **AppCard** - Enhanced card component with variant styles, semantic states, size control, and loading skeleton support
51
- - **AppSidebar** - Application sidebar navigation component, with groups rendered as a stacked list or as a tab bar (`navigationDisplay="tab"`, one group at a time, only the selected tab labelled and animated open to fit it, `navigationFooter` always visible), optional icon-only collapse (`collapsible="icon"`: tooltips on top-level items, dropdown for sub-items) and, below 768px, a Settings-style mobile menu (grouped cards, 44px rows, drill-down on sub-items)
51
+ - **AppSidebar** - Application sidebar navigation component, with groups rendered as a stacked list or as a tab bar (`navigationDisplay="tab"`, one group at a time, only the selected tab labelled and animated open to fit it, `navigationFooter` always visible), optional icon-only collapse (`collapsible="icon"`: tooltips on top-level items, dropdown for sub-items), optional hover expansion over that (`expandOnHover`: the pointer or the focus expands the panel as an overlay that does not move the page, a Pin in the header makes it permanent, `pinned`/`defaultPinned`/`onPinnedChange` alias the primitive's `open`) and, below 768px, a Settings-style mobile menu (grouped cards, 44px rows, drill-down on sub-items), plus an optional badge on every row and group (`badge`: `number | string | true` with `badgeLabel` for the spoken form, degrading per surface — pill when expanded, dot in the icon rail with the value in the tooltip, superscript in `AppBottomNav` — and never aggregating counts)
52
52
  - **AppStepper** - Step-by-step progress indicator for multi-step processes
53
53
  - **AspectRatio** - Container that maintains a specific aspect ratio for responsive layouts
54
54
  - **Breadcrumb** - Breadcrumb navigation component for hierarchical navigation
@@ -152,6 +152,7 @@ The following components are marked as deprecated and should not be used in new
152
152
  - **MultipleSelector** - ~~Use `AppSelect` instead~~
153
153
  - **AppMultipleSelectDropdown** - ~~Use `AppSelect` instead~~
154
154
  - **ResizePrompt** - ~~Use alternative resize handling methods~~
155
+ - **SidebarMenuBadge** - ~~Use `NavItem.badge` instead~~ (on `AppSidebar`, with `badgeLabel` for the spoken form; the primitive stays exported and functional, but it is hidden in icon mode and has no spoken form — see `Sidebar.md`)
155
156
 
156
157
  ---
157
158
 
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "schemaVersion": "1.1.0",
3
- "generatedAt": "2026-09-02T16:31:00.468Z",
3
+ "generatedAt": "2026-09-18T09:11:12.439Z",
4
4
  "package": {
5
5
  "name": "laif-ds",
6
- "version": "1.0.0"
6
+ "version": "1.0.2"
7
7
  },
8
8
  "stats": {
9
9
  "documentedComponentCount": 94,
10
- "catalogedComponentCount": 93,
11
- "missingFromDocsCount": 1,
10
+ "catalogedComponentCount": 94,
11
+ "missingFromDocsCount": 2,
12
12
  "deprecatedComponentCount": 2,
13
13
  "averageAiReadinessScore": 56.17,
14
14
  "highAiReadinessCount": 38,
15
15
  "lowAiReadinessCount": 41
16
16
  },
17
17
  "missingFromDocs": [
18
+ "SidebarMenuBadge",
18
19
  "TruncatedCell"
19
20
  ],
20
21
  "components": [
@@ -286,8 +287,9 @@
286
287
  "exampleTitles": [
287
288
  "`BottomNavItem`",
288
289
  "Layout",
289
- "Icon size: a deliberate divergence from the native baselines",
290
+ "Icon size",
290
291
  "Active state",
292
+ "Badges",
291
293
  "Opening the menu",
292
294
  "Accessibility",
293
295
  "Motion",
@@ -295,6 +297,7 @@
295
297
  "Edge cases",
296
298
  "App shell",
297
299
  "Localised copy",
300
+ "A Menu cell of the app's own",
298
301
  "Without the Menu cell"
299
302
  ],
300
303
  "controlledPattern": "likely-controlled",
@@ -307,10 +310,10 @@
307
310
  },
308
311
  "metadata": {
309
312
  "props": {
310
- "totalProps": 15,
313
+ "totalProps": 19,
311
314
  "requiredPropsCount": 4,
312
- "typedPropsCount": 15,
313
- "describedPropsCount": 14
315
+ "typedPropsCount": 19,
316
+ "describedPropsCount": 18
314
317
  },
315
318
  "accessibility": {
316
319
  "hasCoverage": true
@@ -1016,6 +1019,10 @@
1016
1019
  "title": "Behavior",
1017
1020
  "slug": "behavior"
1018
1021
  },
1022
+ {
1023
+ "title": "Badges (`badge` / `badgeLabel`)",
1024
+ "slug": "badges-badge-badgelabel"
1025
+ },
1019
1026
  {
1020
1027
  "title": "Navigation display (`navigationDisplay`)",
1021
1028
  "slug": "navigation-display-navigationdisplay"
@@ -1024,6 +1031,10 @@
1024
1031
  "title": "Icon mode (`collapsible=\"icon\"`)",
1025
1032
  "slug": "icon-mode-collapsible-icon"
1026
1033
  },
1034
+ {
1035
+ "title": "Hover expansion (`expandOnHover`)",
1036
+ "slug": "hover-expansion-expandonhover"
1037
+ },
1027
1038
  {
1028
1039
  "title": "Mobile menu (`<768px`)",
1029
1040
  "slug": "mobile-menu-768px"
@@ -1041,8 +1052,24 @@
1041
1052
  "hasExamples": true,
1042
1053
  "exampleTitles": [
1043
1054
  "Icons: `iconName` or `icon`",
1055
+ "The three forms",
1056
+ "Degradation per surface",
1057
+ "Counts never aggregate",
1058
+ "Colour and geometry",
1059
+ "Accessibility",
1060
+ "Live counts vs static ones, and who announces them",
1061
+ "What this deliberately does not do",
1044
1062
  "The group selector (`navigationDisplay=\"tab\"` + `collapsible=\"icon\"`, collapsed)",
1063
+ "The peek is an overlay, the pin is a push",
1064
+ "Preconditions",
1065
+ "Timing",
1066
+ "The Pin",
1067
+ "`pinned` is `open`",
1068
+ "Keyboard and focus",
1069
+ "Menus that live in a portal",
1070
+ "Other things worth knowing",
1045
1071
  "Basic",
1072
+ "With badges",
1046
1073
  "With Header/Footer Content"
1047
1074
  ],
1048
1075
  "controlledPattern": "controlled-uncontrolled-documented",
@@ -1052,10 +1079,10 @@
1052
1079
  },
1053
1080
  "metadata": {
1054
1081
  "props": {
1055
- "totalProps": 11,
1082
+ "totalProps": 21,
1056
1083
  "requiredPropsCount": 1,
1057
- "typedPropsCount": 11,
1058
- "describedPropsCount": 11
1084
+ "typedPropsCount": 21,
1085
+ "describedPropsCount": 19
1059
1086
  },
1060
1087
  "accessibility": {
1061
1088
  "hasCoverage": true
@@ -1064,10 +1091,11 @@
1064
1091
  "coveredStates": [
1065
1092
  "hover",
1066
1093
  "focus",
1094
+ "disabled",
1067
1095
  "empty",
1068
1096
  "selected"
1069
1097
  ],
1070
- "coveredStateCount": 4
1098
+ "coveredStateCount": 5
1071
1099
  }
1072
1100
  },
1073
1101
  "aiReadiness": {
@@ -5080,6 +5108,14 @@
5080
5108
  "title": "Behavior",
5081
5109
  "slug": "behavior"
5082
5110
  },
5111
+ {
5112
+ "title": "`SidebarMenuBadge` is deprecated",
5113
+ "slug": "sidebarmenubadge-is-deprecated"
5114
+ },
5115
+ {
5116
+ "title": "Peek — `useSidebar().peek` / `setPeek`",
5117
+ "slug": "peek-usesidebar-peek-setpeek"
5118
+ },
5083
5119
  {
5084
5120
  "title": "Example",
5085
5121
  "slug": "example"
@@ -5092,7 +5128,7 @@
5092
5128
  "hasPropsTable": false,
5093
5129
  "hasExamples": false,
5094
5130
  "exampleTitles": [],
5095
- "controlledPattern": "not-specified",
5131
+ "controlledPattern": "likely-controlled",
5096
5132
  "requiredProps": []
5097
5133
  },
5098
5134
  "metadata": {
@@ -5107,9 +5143,10 @@
5107
5143
  },
5108
5144
  "states": {
5109
5145
  "coveredStates": [
5146
+ "hover",
5110
5147
  "focus"
5111
5148
  ],
5112
- "coveredStateCount": 1
5149
+ "coveredStateCount": 2
5113
5150
  }
5114
5151
  },
5115
5152
  "aiReadiness": {
@@ -6275,5 +6312,5 @@
6275
6312
  ]
6276
6313
  }
6277
6314
  ],
6278
- "checksum": "7347dd0e46eb44f6186c98afd32585008fadbbbcdcb7909e342274f93f42e45d"
6315
+ "checksum": "1b95270b522d7fdbd54fa834020be6d9bf1b0632fda8a9458556d024398a8574"
6279
6316
  }
@@ -70,70 +70,6 @@ const e = {
70
70
  badge: {
71
71
  base: "inline-flex items-center justify-center border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1"
72
72
  },
73
- // Navigation rows (sidebar / menu lists) specific.
74
- //
75
- // Not `dropdownItem.selected`: that one paints the accent colour *on a 10%
76
- // dilution of itself* — measured 1.85:1 in light, 3.28 tangerine, 4.23
77
- // claymorphism, 6.62 dark. Fine as a chip inside a dropdown, unreadable as a
78
- // row label. Here the tint stays on the surface and the label keeps
79
- // full-contrast `text-d-foreground` (12.19 / 10.35 / 9.35 / 10.22).
80
- //
81
- // The bar is `d-foreground`, not `d-primary`, for the same reason: in the
82
- // light theme `--d-primary` is a pale cyan that measures 1.85:1 against the
83
- // tinted row, so a primary bar would fail WCAG 1.4.11 (>= 3:1 for a non-text
84
- // indicator) in the default theme. Shape and position carry the state; the
85
- // tint adds the brand colour on top where the theme can afford it.
86
- //
87
- // `base` goes on every row so the selected row's 2px does not shift content.
88
- navItem: {
89
- base: "border-l-2 border-l-transparent",
90
- selected: "bg-d-primary/10 border-l-d-foreground text-d-foreground font-semibold",
91
- // A bottom bar's cells are columns, not rows, so the left rule has no edge
92
- // to run along and the state has to be carried by the cell's own surface: a
93
- // pill, no border on any side.
94
- //
95
- // The pill is the sidebar row's own state, ported: `bg-d-sidebar-accent` +
96
- // `text-d-sidebar-accent-foreground` + a bold label, which is exactly what
97
- // `sidebarMenuButtonVariants` paints under `data-[active=true]`. One
98
- // vocabulary for "you are here" across the two navigations, which is worth
99
- // more than either surface being optimal on its own.
100
- //
101
- // What comes with it is the sidebar's contrast, and it is very low.
102
- // `--d-sidebar-accent` is `--colors--surface--tertiary` at 40% alpha, so on
103
- // the bar (`d-card`, #fafafa light / #1f1f1f dark) it composites to #efefef
104
- // and #2f2f2f — **1.10:1 in light and 1.23:1 in dark**, far under the 3:1 of
105
- // WCAG 1.4.11 for a non-text state indicator. That is not a regression
106
- // introduced here: the same fill on the sidebar's own surface (#f5f5f5)
107
- // measures 1.08:1, so the desktop sidebar has always marked the current row
108
- // by *weight*, with the tint as a hint. The label is never at risk either
109
- // way — `--d-sidebar-accent-foreground` is `--colors--text--body-primary`,
110
- // the colour the inactive cells already use, 11.9:1 on the composited fill.
111
- //
112
- // `font-bold` and not `semibold`: with a fill this faint the weight is the
113
- // state, and it is the sidebar's own value. It costs label width on a cell
114
- // that already truncates at 375px — if that has to be bought back,
115
- // `font-semibold` is the place to spend it, not the fill.
116
- //
117
- // Measured alternatives, for whenever the 3:1 floor has to be met.
118
- // `bg-d-primary` + `text-d-primary-foreground`: 1.98:1 light / 7.99:1 dark —
119
- // the brand fill this replaced, under the floor in light but separating by
120
- // hue, which 1.4.11 does not measure. `bg-d-foreground` + `text-d-background`:
121
- // 13.01:1 / 12.49:1 — conforming, but it reads as a selected chip rather
122
- // than the current tab and leaves the bar with no brand. Not a tint of
123
- // primary: /10 is 1.07:1 light, /40 still 1.30:1, solid `bg-d-accent` 1.26:1.
124
- //
125
- // `rounded-md` (6px, = `radius.default`) and not `radius.sm`: `sm` is 4px in
126
- // the v4 build and 2px in the v3 one, and this would be the single place
127
- // where the two CSS bundles disagree. Not `rounded-d-md` either — that maps
128
- // onto `--d-radius` and becomes 18px under claymorphism, while the dock it
129
- // sits inside is a fixed `rounded-t-lg`.
130
- //
131
- // `basePill` reserves nothing (there is no border to reserve), so activating
132
- // a cell cannot shift its content; it is there so every cell shares the
133
- // radius and only the fill changes.
134
- basePill: "rounded-md",
135
- selectedPill: "bg-d-sidebar-accent text-d-sidebar-accent-foreground font-bold"
136
- },
137
73
  // Dropdown item specific
138
74
  dropdownItem: {
139
75
  selected: "bg-d-primary/10 text-d-primary font-medium data-[selected=true]:text-d-primary data-[selected=true]:bg-d-primary/30 hover:text-d-primary hover:bg-d-primary/30",
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ const e = {
3
+ /**
4
+ * The state of a navigation item, in the two shapes the surfaces need: a left
5
+ * rule for a vertical list, a pill for a horizontal bar.
6
+ */
7
+ item: {
8
+ // The bar is `d-foreground`, not `d-primary`: in the light theme
9
+ // `--d-primary` measures 1.85:1 against the tinted row and a primary bar
10
+ // would fail WCAG 1.4.11 (>= 3:1 for a non-text indicator).
11
+ // `base` goes on every row so the selected row's 2px does not shift content.
12
+ base: "border-l-2 border-l-transparent",
13
+ selected: "bg-d-primary/10 border-l-d-foreground text-d-foreground font-semibold",
14
+ // Duplicates what `sidebarMenuButtonVariants` paints under
15
+ // `data-[active=true]` in `sidebar.tsx` — two copies of "you are here", by
16
+ // decision. The fill is ~1.1:1, under WCAG 1.4.11's 3:1, so `font-bold`
17
+ // carries the state and the tint is only a hint. `rounded-md` and not
18
+ // `radius.sm` (4px in v4, 2px in v3) nor `rounded-d-md` (18px in clay).
19
+ basePill: "rounded-md",
20
+ selectedPill: "bg-d-sidebar-accent text-d-sidebar-accent-foreground font-bold"
21
+ },
22
+ /**
23
+ * The icon rail at 3rem, where the glyph carries the whole row. One home for
24
+ * the geometry because `sidebarMenuButtonVariants`, the two initial-letter
25
+ * fallbacks in `app-sidebar.tsx` and the group selector must agree on it.
26
+ */
27
+ rail: {
28
+ // The content box is exactly the glyph (`32 - 2*6 = 20`), and that equality
29
+ // is the centring mechanism: the label stays mounted as a `flex-1` sibling,
30
+ // so any slack in the box is claimed by the label and pushes every glyph
31
+ // off-centre. Change one of the three numbers and the other two follow.
32
+ button: "group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-1.5 group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:[&>svg]:size-5",
33
+ // Sized to the rail's content box in both states, for the same reason: a
34
+ // 16px box in the rail's 20px content box leaves 4px for the label to claim
35
+ // and that row's letter sits left of every glyph above it.
36
+ fallbackGlyph: "size-4 group-data-[collapsible=icon]:size-5 group-data-[collapsible=icon]:text-sm"
37
+ }
38
+ };
39
+ export {
40
+ e as navTokens
41
+ };