basalt-ui 1.18.0 → 1.19.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.
Files changed (92) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/agent/rules/basalt-mantine.md +71 -6
  4. package/agent/rules/basalt-router.md +129 -30
  5. package/agent/rules/basalt-tokens.md +2 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +8 -0
  9. package/dist/nav/types.d.ts +110 -0
  10. package/dist/nav/types.d.ts.map +1 -0
  11. package/dist/nav/types.js +0 -0
  12. package/dist/router-tanstack/index.d.ts +9 -2
  13. package/dist/router-tanstack/index.d.ts.map +1 -1
  14. package/dist/router-tanstack/index.js +7 -0
  15. package/dist/router-tanstack/nav.d.ts +163 -0
  16. package/dist/router-tanstack/nav.d.ts.map +1 -0
  17. package/dist/router-tanstack/nav.js +38 -0
  18. package/dist/router-tanstack/nav.test.d.ts +2 -0
  19. package/dist/router-tanstack/nav.test.d.ts.map +1 -0
  20. package/dist/router-tanstack/search-param-store.d.ts +19 -9
  21. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  22. package/dist/router-tanstack/use-nav.d.ts +30 -0
  23. package/dist/router-tanstack/use-nav.d.ts.map +1 -0
  24. package/dist/router-tanstack/use-nav.js +71 -0
  25. package/dist/shell/app-breadcrumbs.d.ts +7 -9
  26. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  27. package/dist/shell/app-breadcrumbs.js +8 -4
  28. package/dist/shell/app-mobile-nav.d.ts +23 -24
  29. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  30. package/dist/shell/app-mobile-nav.js +290 -68
  31. package/dist/shell/app-mobile-nav.module.css +166 -11
  32. package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
  33. package/dist/shell/app-mobile-nav.test.d.ts +2 -0
  34. package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
  35. package/dist/shell/app-sidebar.d.ts +3 -28
  36. package/dist/shell/app-sidebar.d.ts.map +1 -1
  37. package/dist/shell/app-sidebar.js +40 -174
  38. package/dist/shell/app-sidebar.module.css +3 -2
  39. package/dist/shell/app-sidebar.module.css.d.ts +0 -2
  40. package/dist/shell/index.d.ts +22 -65
  41. package/dist/shell/index.d.ts.map +1 -1
  42. package/dist/shell/index.js +38 -76
  43. package/dist/shell/mobile-nav-model.d.ts +41 -0
  44. package/dist/shell/mobile-nav-model.d.ts.map +1 -0
  45. package/dist/shell/mobile-nav-model.js +244 -0
  46. package/dist/shell/mobile-nav-model.test.d.ts +2 -0
  47. package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
  48. package/dist/shell/nav-count-badge.d.ts +11 -0
  49. package/dist/shell/nav-count-badge.d.ts.map +1 -0
  50. package/dist/shell/nav-count-badge.js +29 -0
  51. package/dist/surfaces.d.ts +1 -1
  52. package/dist/surfaces.js +1 -1
  53. package/dist/theme/index.d.ts.map +1 -1
  54. package/dist/theme/index.js +8 -7
  55. package/dist/theme/nav-link.module.css +4 -2
  56. package/dist/tokens/index.d.ts.map +1 -1
  57. package/dist/tokens/index.js +2 -0
  58. package/dist/tokens/palette.d.ts +15 -0
  59. package/dist/tokens/palette.d.ts.map +1 -1
  60. package/dist/tokens/palette.js +13 -0
  61. package/dist/tokens.css +2 -0
  62. package/llms.txt +3 -3
  63. package/package.json +1 -1
  64. package/src/index.ts +15 -5
  65. package/src/nav/types.ts +115 -0
  66. package/src/router-tanstack/index.ts +21 -2
  67. package/src/router-tanstack/nav.test.ts +111 -0
  68. package/src/router-tanstack/nav.ts +205 -0
  69. package/src/router-tanstack/search-param-store.ts +19 -9
  70. package/src/router-tanstack/use-nav.ts +146 -0
  71. package/src/shell/app-breadcrumbs.tsx +21 -19
  72. package/src/shell/app-mobile-nav.module.css +166 -11
  73. package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
  74. package/src/shell/app-mobile-nav.test.tsx +369 -0
  75. package/src/shell/app-mobile-nav.tsx +436 -102
  76. package/src/shell/app-sidebar.module.css +3 -2
  77. package/src/shell/app-sidebar.module.css.d.ts +0 -2
  78. package/src/shell/app-sidebar.test.tsx +6 -10
  79. package/src/shell/app-sidebar.tsx +49 -206
  80. package/src/shell/index.test.tsx +62 -7
  81. package/src/shell/index.tsx +105 -164
  82. package/src/shell/mobile-nav-model.test.ts +296 -0
  83. package/src/shell/mobile-nav-model.ts +389 -0
  84. package/src/shell/nav-count-badge.tsx +39 -0
  85. package/src/surfaces.ts +1 -1
  86. package/src/theme/density-relations.test.ts +19 -1
  87. package/src/theme/index.ts +8 -7
  88. package/src/theme/nav-link.module.css +4 -2
  89. package/src/theme/spacing.test.ts +11 -0
  90. package/src/tokens/density.test.ts +2 -0
  91. package/src/tokens/index.ts +8 -5
  92. package/src/tokens/palette.ts +16 -0
@@ -1,38 +1,60 @@
1
1
  /**
2
- * Mobile bottom tab bar for the primary nav groups. Rendered inside an `AppShell.Footer`
3
- * (`hiddenFrom="sm"`, height collapsed to 0 on desktop) so it only appears below the navbar
4
- * breakpoint. One tab per group plus a trailing "More" opener; tapping a group tab raises a
5
- * bottom sheet listing that group's destinations as large tap rows. Active state is a neutral
6
- * fill, never the identity blue ("ink earns its color", DESIGN.md).
2
+ * The mobile bottom bar. Rendered inside an `AppShell.Footer` (`hiddenFrom="sm"`, height collapsed
3
+ * to 0 on desktop) so it only exists below the navbar breakpoint.
7
4
  *
8
- * Router-agnostic: each row renders through `renderNavLink` when supplied (the consumer's router
9
- * `Link`), else a plain `<a href>` + `item.onClick`. Active state arrives as `item.active`.
10
- * Grounded verbatim in argo `apps/dashboard/src/components/app-shell/app-mobile-nav.tsx`.
5
+ * A SLOT IS A DESTINATION. Tapping one navigates through the consumer's router anchor — no
6
+ * overlay, no animation, nothing to dismiss — which is the whole point of the rewrite: reaching a
7
+ * page used to cost a tap to open a sheet plus a tap to pick a row. An overlay now exists only
8
+ * where a slot genuinely holds more than one destination (the trailing "More" slot, or an opt-in
9
+ * group slot), and its surface is INFERRED from the row count rather than configured: a content-
10
+ * sized `Menu` that pops out of the tab up to `menuMax` rows, a bottom `Drawer` past it.
11
+ *
12
+ * This component paints a finished `MobileNavModel` and owns exactly one piece of state — which
13
+ * slot's surface is open. Every selection decision lives in `mobile-nav-model.ts`, which is pure.
14
+ *
15
+ * Router-agnostic: each slot and row renders through `SidebarItem.Anchor` when supplied (the
16
+ * consumer's router `Link`), else a plain `<a href>` + `item.onClick`. Active state arrives
17
+ * precomputed as `item.active`.
11
18
  */
12
- import { Box, Drawer, NavLink, Stack, Text, UnstyledButton } from '@mantine/core'
13
- import { useState } from 'react'
14
- import type { MouseEvent, ReactNode } from 'react'
19
+ import { Drawer, Menu, NavLink, ScrollArea, Stack, Text, UnstyledButton } from '@mantine/core'
20
+ import { useReducedMotion } from '@mantine/hooks'
21
+ import { Fragment, useState } from 'react'
22
+ import type { MouseEvent, ReactElement, ReactNode } from 'react'
23
+ import type {
24
+ MobileNavConfig,
25
+ MobileNavGroup,
26
+ MobileNavModel,
27
+ MobileNavSlot,
28
+ SidebarItem,
29
+ } from '../nav/types'
30
+ import type { BasaltAccountProps } from './account-types'
31
+ import type { SettingsMenuItem } from './index'
32
+ import { NavCountBadge } from './nav-count-badge'
33
+ import { useBasaltSpacing } from '../theme'
15
34
  import classes from './app-mobile-nav.module.css'
16
35
 
17
- export type MobileNavItem = {
18
- key: string
19
- label: string
20
- icon: ReactNode
21
- href?: string
22
- active?: boolean
23
- onClick?: (e: MouseEvent) => void
24
- }
36
+ /**
37
+ * Floating-ui middlewares for a tab menu. `flip: false` is correct, not lazy: flipping a
38
+ * bottom-anchored menu puts it UNDER the footer, off-screen — `menuMax` is what guarantees it fits
39
+ * above the bar (§2.2). `shift.padding` is a viewport inset in px, not a layout spacing token.
40
+ */
41
+ const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } } // theme-allow
25
42
 
26
- export type MobileNavSection = {
27
- key: string
28
- label: string
29
- icon: ReactNode
30
- active: boolean
31
- items: MobileNavItem[]
32
- }
43
+ /** A slot that navigates on tap. */
44
+ type LinkSlot = Extract<MobileNavSlot, { kind: 'link' }>
45
+ /** A slot that raises a surface. `menu` and `sheet` share one shape — only the renderer differs. */
46
+ type SurfaceSlot = Exclude<MobileNavSlot, LinkSlot>
33
47
 
34
- /** Renders a single sheet row. The consumer's router `Link` lives here; `active` is precomputed. */
35
- export type MobileNavLinkRenderer = (item: MobileNavItem, opts: { active: boolean }) => ReactNode
48
+ export type MobileNavProps = {
49
+ /** The projection from `projectMobileNav` — `BasaltShell` builds it, memoized. */
50
+ model: MobileNavModel
51
+ config?: MobileNavConfig | undefined
52
+ /** Rendered as FLAT ROWS in the More surface, never by mounting `SidebarAccount` (which opens
53
+ * its own `Menu` — a menu inside a menu). This is what makes the mobile sidebar drawer
54
+ * deletable: everything it used to hold is reachable from More. */
55
+ account?: BasaltAccountProps | undefined
56
+ settingsMenuItems?: SettingsMenuItem[] | undefined
57
+ }
36
58
 
37
59
  /** Inline "More" glyph — keeps the shell icon-dependency-free. */
38
60
  function IconMore() {
@@ -56,91 +78,403 @@ function IconMore() {
56
78
  )
57
79
  }
58
80
 
59
- export function MobileNav({
60
- sections,
61
- onOpenMore,
62
- renderNavLink,
63
- }: {
64
- sections: MobileNavSection[]
65
- onOpenMore: () => void
66
- renderNavLink?: MobileNavLinkRenderer
67
- }) {
68
- // The open group sheet; null = closed. A single Drawer is reused across all groups.
69
- const [openSection, setOpenSection] = useState<MobileNavSection | null>(null)
81
+ /** A row the More surface derives from `account`/`settingsMenuItems` — never a destination. */
82
+ type ActionRow = {
83
+ key: string
84
+ label: string
85
+ icon?: ReactNode
86
+ danger?: boolean
87
+ onClick?: ((e: MouseEvent<HTMLElement>) => void) | undefined
88
+ }
70
89
 
90
+ /**
91
+ * Flattens `BasaltAccountProps` into rows. `SidebarAccount`'s own dropdown cannot be reused here —
92
+ * it is a `Menu`, and the More surface is already one.
93
+ */
94
+ function accountRows(account: BasaltAccountProps | undefined): ActionRow[] {
95
+ if (!account || account.state.status === 'loading') return []
96
+ const actions = account.actions
97
+ if (account.state.status === 'unauthenticated') {
98
+ return [{ key: 'basalt-account-sign-in', label: 'Sign in', onClick: actions?.onSignIn }]
99
+ }
100
+ const rows: ActionRow[] = []
101
+ if (actions?.onManageAccount) {
102
+ rows.push({
103
+ key: 'basalt-account-manage',
104
+ label: 'Account settings',
105
+ onClick: actions.onManageAccount,
106
+ })
107
+ }
108
+ if (actions?.onManageBilling) {
109
+ rows.push({
110
+ key: 'basalt-account-billing',
111
+ label: 'Billing & payment',
112
+ onClick: actions.onManageBilling,
113
+ })
114
+ }
115
+ if (account.state.plan?.isFree && actions?.onUpgrade) {
116
+ rows.push({ key: 'basalt-account-upgrade', label: 'Upgrade', onClick: actions.onUpgrade })
117
+ }
118
+ for (const item of actions?.extraMenuItems ?? []) {
119
+ rows.push({
120
+ key: item.key,
121
+ label: item.label,
122
+ icon: item.icon,
123
+ ...(item.danger !== undefined && { danger: item.danger }),
124
+ onClick: item.onClick,
125
+ })
126
+ }
127
+ if (actions?.onSignOut) {
128
+ const onSignOut = actions.onSignOut
129
+ rows.push({
130
+ key: 'basalt-account-sign-out',
131
+ label: 'Sign out',
132
+ danger: true,
133
+ onClick: () => void onSignOut(),
134
+ })
135
+ }
136
+ return rows
137
+ }
138
+
139
+ /**
140
+ * How many rows `account` contributes to the More surface — DELEGATED to `accountRows` rather than
141
+ * re-derived, because `BasaltShell` needs this number BEFORE the projection runs (it feeds
142
+ * `extraMoreRows`, which picks `menu` vs `sheet` against `menuMax`) while only this module knows
143
+ * how many rows an account actually expands into. Two independent counts is how a "1 row" account
144
+ * shipped a 9-row menu into headroom sized for 6, and how a `loading` account — which renders NO
145
+ * rows — still conjured a More slot that opened empty.
146
+ */
147
+ export function accountRowCount(account: BasaltAccountProps | undefined): number {
148
+ return accountRows(account).length
149
+ }
150
+
151
+ /** Settings entries already carry the row shape — they just lose the sidebar's `Menu` wrapper. */
152
+ function settingsRows(items: SettingsMenuItem[] | undefined): ActionRow[] {
153
+ return (items ?? []).map((item) => ({
154
+ key: item.key,
155
+ label: item.label,
156
+ icon: item.icon,
157
+ onClick: item.onClick,
158
+ }))
159
+ }
160
+
161
+ /** One destination row, in a `Menu` dropdown. `depth` indents a nested child. */
162
+ const menuRow = (item: SidebarItem, depth: number): ReactElement => {
163
+ // `.menuItem[data-disabled] { pointer-events: none }` is the primary guard, but it cannot be the
164
+ // only one: Mantine's `MenuItem` composes the CALLER's `onClick` first and only then checks
165
+ // `data-disabled` itself, so anything that reaches the node without a pointer — a keyboard
166
+ // activation, a synthetic click, a consumer stylesheet resetting `pointer-events` — would still
167
+ // fire the destination's handler on a row the projection deliberately rendered dead (§2.9).
168
+ const onClick = (event: MouseEvent<HTMLElement>) => {
169
+ if (item.disabled) {
170
+ event.preventDefault()
171
+ return
172
+ }
173
+ item.onClick?.(event)
174
+ }
175
+ const shared = {
176
+ className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
177
+ leftSection: item.icon,
178
+ disabled: Boolean(item.disabled),
179
+ // Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
180
+ // to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
181
+ 'aria-disabled': item.disabled || undefined,
182
+ 'aria-current': item.active ? ('page' as const) : undefined,
183
+ rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
184
+ onClick,
185
+ }
186
+ const Anchor = item.Anchor
187
+ if (Anchor && !item.disabled) {
188
+ return (
189
+ <Menu.Item key={item.key} component={Anchor} {...shared}>
190
+ {item.label}
191
+ </Menu.Item>
192
+ )
193
+ }
71
194
  return (
195
+ <Menu.Item
196
+ key={item.key}
197
+ component="a"
198
+ {...(item.href !== undefined && !item.disabled && { href: item.href })}
199
+ {...shared}
200
+ >
201
+ {item.label}
202
+ </Menu.Item>
203
+ )
204
+ }
205
+
206
+ /** Depth-first: parent, then its children as indented rows — nesting survives to mobile. */
207
+ const rows = (
208
+ items: readonly SidebarItem[],
209
+ depth: number,
210
+ render: (item: SidebarItem, depth: number) => ReactElement,
211
+ ): ReactElement[] =>
212
+ items.flatMap((item) => [render(item, depth), ...rows(item.children ?? [], depth + 1, render)])
213
+
214
+ /** An account/settings row, in a `Menu` dropdown. */
215
+ const menuActionRow = (row: ActionRow) => (
216
+ <Menu.Item
217
+ key={row.key}
218
+ className={classes.menuItem}
219
+ leftSection={row.icon}
220
+ {...(row.danger ? { color: 'red' } : {})}
221
+ onClick={(event: MouseEvent<HTMLElement>) => row.onClick?.(event)}
222
+ >
223
+ {row.label}
224
+ </Menu.Item>
225
+ )
226
+
227
+ /** The sheet has no `Menu.Label` equivalent, so the section heading is a plain micro-label. */
228
+ const sheetGroupLabel = (group: MobileNavGroup) =>
229
+ group.label ? (
230
+ <Text component="div" className={classes.menuLabel}>
231
+ {group.label}
232
+ </Text>
233
+ ) : null
234
+
235
+ export function MobileNav({
236
+ model,
237
+ config,
238
+ account,
239
+ settingsMenuItems,
240
+ }: MobileNavProps): ReactElement {
241
+ // `openKey` is the ONLY state here, and it is keyed by slot rather than holding a slot object:
242
+ // `sections` identity churning on every consumer render must not close an open menu.
243
+ const [openKey, setOpenKey] = useState<string | null>(null)
244
+ // `DEFAULT_THEME.respectReducedMotion` is false in Mantine 9.3 and `createBasaltTheme` does not
245
+ // set it, so the preference has to be read explicitly at the call site.
246
+ const reduceMotion = useReducedMotion()
247
+ const { step } = useBasaltSpacing()
248
+
249
+ const close = () => setOpenKey(null)
250
+
251
+ /** §2.5 — re-tapping the ACTIVE slot scrolls to top instead of pushing a redundant history entry. */
252
+ const scrollToTop = () => {
253
+ const target =
254
+ config?.getScrollElement?.() ?? document.scrollingElement ?? document.documentElement
255
+ target.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' })
256
+ }
257
+
258
+ const extraRows = [...accountRows(account), ...settingsRows(settingsMenuItems)]
259
+
260
+ /** True when any destination in the slot carries an unread count — the icon dot (§2.4). */
261
+ const hasCount = (slot: MobileNavSlot): boolean =>
262
+ slot.kind === 'link'
263
+ ? Boolean(slot.item.count)
264
+ : slot.groups.some((g) => groupHasCount(g.items))
265
+
266
+ const tabInner = (slot: MobileNavSlot) => (
72
267
  <>
73
- <nav className={classes.bar}>
74
- {sections.map((section) => (
75
- <UnstyledButton
76
- key={section.key}
77
- onClick={() => setOpenSection(section)}
78
- className={classes.tab}
79
- data-active={section.active || undefined}
80
- aria-current={section.active ? 'page' : undefined}
81
- aria-label={section.label}
82
- >
83
- {section.icon}
84
- <Text className={classes.label}>{section.label}</Text>
85
- </UnstyledButton>
86
- ))}
87
- <UnstyledButton onClick={onOpenMore} className={classes.tab} aria-label="More">
88
- <IconMore />
89
- <Text className={classes.label}>More</Text>
268
+ <span className={classes.tabIcon}>
269
+ {slot.icon ?? (slot.kind !== 'link' && slot.isMore ? <IconMore /> : null)}
270
+ {hasCount(slot) ? <span className={classes.tabDot} aria-hidden /> : null}
271
+ </span>
272
+ <Text className={classes.label}>{slot.short}</Text>
273
+ </>
274
+ )
275
+
276
+ /**
277
+ * The trigger for a `menu`/`sheet` slot. A plain button: Mantine's `Menu.Target` clones
278
+ * `aria-haspopup`/`aria-expanded`/`aria-controls` onto it (`withRoles` default), so hand-setting
279
+ * them here would double them up (§2.9).
280
+ */
281
+ const surfaceTab = (slot: SurfaceSlot, onClick?: () => void) => (
282
+ <UnstyledButton
283
+ className={classes.tab}
284
+ data-active={slot.active || undefined}
285
+ aria-current={slot.active ? 'page' : undefined}
286
+ aria-label={slot.label}
287
+ {...(onClick ? { onClick } : {})}
288
+ >
289
+ {tabInner(slot)}
290
+ </UnstyledButton>
291
+ )
292
+
293
+ /** A `link` slot: the consumer's router anchor IS the tab, so preload/back/middle-click all work. */
294
+ const linkTab = (slot: LinkSlot) => {
295
+ const onClick = (e: MouseEvent<HTMLElement>) => {
296
+ // VERIFIED in @tanstack/react-router: `Link` composes the caller's handler FIRST and returns
297
+ // early when `defaultPrevented`, so this suppresses navigation without touching preload.
298
+ if (slot.active) {
299
+ e.preventDefault()
300
+ scrollToTop()
301
+ }
302
+ slot.item.onClick?.(e)
303
+ }
304
+ const shared = {
305
+ className: classes.tab,
306
+ 'data-active': slot.active || undefined,
307
+ 'aria-current': slot.active ? ('page' as const) : undefined,
308
+ 'aria-label': slot.label,
309
+ onClick,
310
+ }
311
+ const Anchor = slot.item.Anchor
312
+ if (Anchor) {
313
+ return (
314
+ <UnstyledButton key={slot.key} component={Anchor} {...shared}>
315
+ {tabInner(slot)}
90
316
  </UnstyledButton>
91
- </nav>
317
+ )
318
+ }
319
+ return (
320
+ <UnstyledButton
321
+ key={slot.key}
322
+ component="a"
323
+ {...(slot.item.href !== undefined && { href: slot.item.href })}
324
+ {...shared}
325
+ >
326
+ {tabInner(slot)}
327
+ </UnstyledButton>
328
+ )
329
+ }
92
330
 
331
+ /** One destination row, in the sheet. 44px minimum, per the touch-target floor. */
332
+ const sheetRow = (item: SidebarItem, depth: number): ReactElement => {
333
+ const onClick = (e: MouseEvent<HTMLElement>) => {
334
+ item.onClick?.(e)
335
+ close()
336
+ }
337
+ const shared = {
338
+ classNames: {
339
+ root: depth > 0 ? `${classes.row} ${classes.rowNested}` : classes.row,
340
+ },
341
+ label: item.label,
342
+ leftSection: item.icon,
343
+ rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
344
+ active: Boolean(item.active),
345
+ disabled: Boolean(item.disabled),
346
+ // Same as the menu row above: `NavLink`'s `disabled` is a `mod`, i.e. `data-disabled` only.
347
+ 'aria-disabled': item.disabled || undefined,
348
+ 'aria-current': item.active ? ('page' as const) : undefined,
349
+ onClick,
350
+ }
351
+ const Anchor = item.Anchor
352
+ if (Anchor && !item.disabled) {
353
+ return <NavLink key={item.key} component={Anchor} {...shared} />
354
+ }
355
+ return (
356
+ <NavLink
357
+ key={item.key}
358
+ component="a"
359
+ {...(item.href !== undefined && !item.disabled && { href: item.href })}
360
+ {...shared}
361
+ />
362
+ )
363
+ }
364
+
365
+ const sheetActionRow = (row: ActionRow) => (
366
+ <NavLink
367
+ key={row.key}
368
+ classNames={{ root: classes.row }}
369
+ label={row.label}
370
+ leftSection={row.icon}
371
+ {...(row.danger ? { color: 'red' } : {})}
372
+ onClick={(event: MouseEvent<HTMLElement>) => {
373
+ row.onClick?.(event)
374
+ close()
375
+ }}
376
+ />
377
+ )
378
+
379
+ const menuSlot = (slot: SurfaceSlot) => (
380
+ <Menu
381
+ key={slot.key}
382
+ opened={openKey === slot.key}
383
+ onChange={(opened) => setOpenKey(opened ? slot.key : null)}
384
+ position={slot.isMore ? 'top-end' : 'top'}
385
+ offset={8}
386
+ width={step.mobileNavMenuWidth}
387
+ withinPortal
388
+ // AppShell itself sits at z-index 100 (`getDefaultZIndex('app')`), so the menu must clear it.
389
+ zIndex={400}
390
+ middlewares={MENU_MIDDLEWARES}
391
+ trapFocus
392
+ returnFocus
393
+ closeOnItemClick
394
+ closeOnClickOutside
395
+ closeOnEscape
396
+ // Required for a navigation menu following the WAI-ARIA disclosure pattern — Mantine's own
397
+ // note on the prop. Without it every row is `tabindex="-1"`.
398
+ menuItemTabIndex={0}
399
+ transitionProps={{
400
+ transition: slot.isMore ? 'pop-bottom-right' : 'pop',
401
+ duration: reduceMotion ? 0 : 140,
402
+ exitDuration: reduceMotion ? 0 : 100,
403
+ }}
404
+ classNames={{ dropdown: classes.menuDropdown, label: classes.menuLabel }}
405
+ >
406
+ <Menu.Target>{surfaceTab(slot)}</Menu.Target>
407
+ <Menu.Dropdown>
408
+ {slot.groups.flatMap((group) => [
409
+ ...(group.label
410
+ ? [<Menu.Label key={`${group.key}-label`}>{group.label}</Menu.Label>]
411
+ : []),
412
+ ...rows(group.items, 0, menuRow),
413
+ ])}
414
+ {/* A separator separates. With nothing above it — a More slot raised purely by the account
415
+ and settings rows — it would render as the dropdown's first child, a rule under the
416
+ top edge. Both sides have to exist. */}
417
+ {slot.isMore && extraRows.length > 0 && slotHasRows(slot) ? <Menu.Divider /> : null}
418
+ {slot.isMore ? extraRows.map(menuActionRow) : null}
419
+ {slot.isMore ? config?.moreExtra : null}
420
+ </Menu.Dropdown>
421
+ </Menu>
422
+ )
423
+
424
+ const sheetSlot = (slot: SurfaceSlot) => (
425
+ <Fragment key={slot.key}>
426
+ {surfaceTab(slot, () => setOpenKey(slot.key))}
93
427
  <Drawer
94
- opened={openSection !== null}
95
- onClose={() => setOpenSection(null)}
428
+ opened={openKey === slot.key}
429
+ onClose={close}
96
430
  position="bottom"
97
431
  size="auto"
98
432
  padding="md"
99
- title={openSection?.label}
433
+ title={slot.label}
100
434
  classNames={{ content: classes.sheet, title: classes.sheetTitle }}
435
+ transitionProps={{
436
+ transition: 'slide-up',
437
+ duration: reduceMotion ? 0 : 220,
438
+ timingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)',
439
+ }}
101
440
  >
102
- <Stack gap={2}>
103
- {openSection?.items.map((item) => {
104
- const handleClick = (e: MouseEvent) => {
105
- item.onClick?.(e)
106
- setOpenSection(null)
107
- }
108
- const active = Boolean(item.active)
109
-
110
- if (renderNavLink) {
111
- return (
112
- <Box key={item.key} onClick={() => setOpenSection(null)}>
113
- {renderNavLink(item, { active })}
114
- </Box>
115
- )
116
- }
117
-
118
- // Items with an href stay anchors so the consumer's router can preload them.
119
- return item.href ? (
120
- <NavLink
121
- key={item.key}
122
- classNames={{ root: classes.row }}
123
- component="a"
124
- href={item.href}
125
- label={item.label}
126
- leftSection={item.icon}
127
- active={active}
128
- onClick={handleClick}
129
- />
130
- ) : (
131
- <UnstyledButton
132
- key={item.key}
133
- onClick={handleClick}
134
- className={classes.row}
135
- data-active={active || undefined}
136
- >
137
- {item.icon}
138
- <Text className={classes.rowLabel}>{item.label}</Text>
139
- </UnstyledButton>
140
- )
141
- })}
142
- </Stack>
441
+ {/* The dismiss affordance. No swipe-to-dismiss — see `.grabber` in the CSS module. */}
442
+ <div className={classes.grabber} aria-hidden />
443
+ <ScrollArea.Autosize mah="62dvh" type="scroll">
444
+ <Stack gap="xs">
445
+ {slot.groups.map((group) => (
446
+ <Stack key={group.key} gap={2}>
447
+ {sheetGroupLabel(group)}
448
+ {rows(group.items, 0, sheetRow)}
449
+ </Stack>
450
+ ))}
451
+ {slot.isMore && extraRows.length > 0 ? (
452
+ <Stack gap={2}>{extraRows.map(sheetActionRow)}</Stack>
453
+ ) : null}
454
+ {slot.isMore ? config?.moreExtra : null}
455
+ </Stack>
456
+ </ScrollArea.Autosize>
143
457
  </Drawer>
144
- </>
458
+ </Fragment>
145
459
  )
460
+
461
+ return (
462
+ <nav className={classes.bar} aria-label="Primary">
463
+ {model.slots.map((slot) => {
464
+ if (slot.kind === 'link') return linkTab(slot)
465
+ if (slot.kind === 'menu') return menuSlot(slot)
466
+ return sheetSlot(slot)
467
+ })}
468
+ </nav>
469
+ )
470
+ }
471
+
472
+ /** Whether a surface slot paints any destination row at all (as opposed to only derived rows). */
473
+ function slotHasRows(slot: SurfaceSlot): boolean {
474
+ return slot.groups.some((group) => group.items.length > 0)
475
+ }
476
+
477
+ /** Any destination in the tree carrying a non-zero count. */
478
+ function groupHasCount(items: readonly SidebarItem[]): boolean {
479
+ return items.some((item) => Boolean(item.count) || groupHasCount(item.children ?? []))
146
480
  }
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * The active tint and icon-accent color live in the Mantine theme
10
10
  * (`NavLink.extend` + `theme/nav-link.module.css`) so they apply to EVERY render path, including a
11
- * consumer's router `<Link>` via `renderNavLink` (which never sees this CSS module). This module
11
+ * consumer's router `<Link>` hosted through `SidebarItem.Anchor` (which never sees this CSS
12
+ * module). This module
12
13
  * owns the shell-local layout (docs/DESIGN-SPEC.md §5) and the INACTIVE state (muted text, faint
13
14
  * icon, ink-6% hover) — the active state is left to the theme.
14
15
  *
@@ -382,7 +383,7 @@
382
383
  }
383
384
 
384
385
  /* Collapse every nav row to its icon. Targets `.navItem` — the wrapper Box present on EVERY render
385
- * path — so it covers the shell's fallback NavLink AND a consumer's `renderNavLink` (e.g. a router
386
+ * path — so it covers the shell's fallback NavLink AND a consumer's `SidebarItem.Anchor` (e.g. a router
386
387
  * <Link>), both of which render a Mantine NavLink inside. Keying off `.link` alone missed consumer
387
388
  * links, so their labels overflowed the 64px rail. */
388
389
  .root[data-collapsed] .navItem :global(.mantine-NavLink-root) {
@@ -2,7 +2,6 @@
2
2
  * Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
3
3
  * signature — so dot access (`classes.link`) stays verbatim under the package's strict flags
4
4
  * (`noUncheckedIndexedAccess`, `noPropertyAccessFromIndexSignature`, `exactOptionalPropertyTypes`).
5
- * `collapseBtn` is referenced by the component for parity with argo though it has no CSS rule.
6
5
  */
7
6
  declare const classes: {
8
7
  readonly root: string
@@ -15,7 +14,6 @@ declare const classes: {
15
14
  readonly navScroll: string
16
15
  readonly navViewport: string
17
16
  readonly nav: string
18
- readonly collapseBtn: string
19
17
  readonly footer: string
20
18
  readonly footerBtn: string
21
19
  readonly footerText: string
@@ -7,14 +7,11 @@
7
7
  * class, stable across runs — CSS-module class hashes are not available under `bun test`, see
8
8
  * `stat-card.test.tsx`/`use-basalt-spacing.test.tsx` for the same constraint elsewhere), as the
9
9
  * LAST child, so a long tree scrolls with the nav instead of fighting it for height.
10
- * 2. It STAYS MOUNTED when `collapsed` — `collapsed` is one value shared by the desktop rail AND
11
- * the mobile drawer (`AppShell.Navbar`'s `collapsed: { mobile: !mobileOpened }` never touches
12
- * the desktop side), and the rail-vs-drawer split lives entirely in a `min-width: sm` CSS media
13
- * query, never in JS. A JS gate on `collapsed` would silently drop `navExtra` from the mobile
14
- * drawer too, which opens at full width regardless of the persisted rail state. jsdom does not
15
- * evaluate the media query, so this asserts presence in the DOM, not computed visibility — the
16
- * CSS half (`.root[data-collapsed] .navExtra { display: none }`) is exercised by the guard rules
17
- * it shares with `.childList`/`.sectionBand`, not by this test.
10
+ * 2. It STAYS MOUNTED when `collapsed` — hiding it on the rail lives entirely in a `min-width: sm`
11
+ * CSS media query, never in JS, so the DOM is identical either way. jsdom does not evaluate the
12
+ * media query, so this asserts presence in the DOM, not computed visibility — the CSS half
13
+ * (`.root[data-collapsed] .navExtra { display: none }`) is exercised by the guard rules it
14
+ * shares with `.childList`/`.sectionBand`, not by this test.
18
15
  * 3. `sections={[]}` plus only `navExtra` renders cleanly: the nav Stack holds exactly the
19
16
  * extra content, no orphan section wrapper.
20
17
  * 4. Omitting the prop reproduces today's DOM exactly — the nav Stack's child count matches
@@ -41,7 +38,6 @@ function renderSidebar(props: Partial<AppSidebarProps> & { navExtra?: ReactNode
41
38
  sections={ONE_SECTION}
42
39
  collapsed={false}
43
40
  onToggleCollapse={() => {}}
44
- onClose={() => {}}
45
41
  {...props}
46
42
  />
47
43
  </MantineProvider>,
@@ -75,7 +71,7 @@ describe('navExtra', () => {
75
71
  const stack = navStack(container)
76
72
  // Presence, not visibility — jsdom never evaluates the `min-width: sm` media query that hides
77
73
  // this in the rail. A regression here (reintroducing `!collapsed &&` in app-sidebar.tsx) would
78
- // fail this assertion instead of only breaking silently in a real mobile-drawer browser.
74
+ // fail this assertion instead of only breaking silently in a real browser at the rail width.
79
75
  expect(stack.contains(extra)).toBe(true)
80
76
  })
81
77