basalt-ui 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +14 -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 +291 -69
  31. package/dist/shell/app-mobile-nav.module.css +191 -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 +191 -11
  73. package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
  74. package/src/shell/app-mobile-nav.test.tsx +403 -0
  75. package/src/shell/app-mobile-nav.tsx +449 -104
  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
@@ -4,14 +4,15 @@
4
4
  * optional version label).
5
5
  *
6
6
  * Router-agnostic by design: active detection, typed navigation and the collapse store stay in the
7
- * consumer (or in `BasaltShell`), which feeds resolved `sections` + `collapsed`/`onToggleCollapse`/
8
- * `onClose` here. Each item renders through `renderNavLink` when supplied, else a plain `<a href>`
9
- * with `item.onClick` — never a router primitive. Active state arrives as `item.active`.
7
+ * consumer (or in `BasaltShell`), which feeds resolved `sections` + `collapsed`/`onToggleCollapse`
8
+ * here. Each destination renders through its own `SidebarItem.Anchor` when supplied (the
9
+ * consumer's router `Link`), else a plain `<a href>` with `item.onClick` — never a router
10
+ * primitive. Active state arrives as `item.active`.
10
11
  *
11
- * Collapse is desktop-only: the rail styling is gated behind a `min-width: sm` media query so the
12
- * mobile drawer always shows full labels regardless of the persisted `collapsed` flag. The close
13
- * button is mobile-only (`hiddenFrom="sm"`); the collapse chevron is desktop-only (`visibleFrom`).
14
- * Grounded verbatim in argo `apps/dashboard/src/components/app-shell/app-sidebar.tsx`.
12
+ * DESKTOP ONLY. There is no mobile sidebar drawer any more: below the `sm` breakpoint the navbar
13
+ * is permanently collapsed and the bottom bar (`app-mobile-nav.tsx`) is the whole nav. The rail
14
+ * styling is still gated behind a `min-width: sm` media query, and the collapse chevron is
15
+ * `visibleFrom="sm"`. Grounded in argo `apps/dashboard/src/components/app-shell/app-sidebar.tsx`.
15
16
  */
16
17
  import {
17
18
  ActionIcon,
@@ -28,9 +29,11 @@ import {
28
29
  UnstyledButton,
29
30
  } from '@mantine/core'
30
31
  import { useDisclosure } from '@mantine/hooks'
31
- import { useRef, useState } from 'react'
32
- import type { MouseEvent, ReactNode } from 'react'
33
- import type { BrandConfig, SettingsMenuItem, SidebarItem, SidebarSection } from './index'
32
+ import { useEffect, useRef, useState } from 'react'
33
+ import type { ReactNode } from 'react'
34
+ import type { BrandConfig, SettingsMenuItem } from './index'
35
+ import type { SidebarItem, SidebarSection } from '../nav/types'
36
+ import { NavCountBadge } from './nav-count-badge'
34
37
  import { SidebarAccount } from './app-sidebar-account'
35
38
  import type { BasaltAccountProps } from './account-types'
36
39
  import { SidebarSearch } from './sidebar-search'
@@ -39,39 +42,11 @@ import { VX } from '../tokens'
39
42
  import { useBasaltSpacing } from '../theme'
40
43
  import classes from './app-sidebar.module.css'
41
44
 
42
- /**
43
- * Renders a single nav item. The consumer's router `Link` lives here; `active` is precomputed.
44
- *
45
- * `opts.close` (item 20) is the mobile "More" drawer's close handle — invoke-safe when absent, a
46
- * no-op on desktop. Call it from the rendered link's own `onClick` (e.g. before/after navigating)
47
- * so tapping a nav row in the mobile drawer also closes it; the built-in fallback `<a>` path does
48
- * this automatically.
49
- *
50
- * `opts.navLinkClassName` (item 18) is the internal `.link` CSS-module class the fallback `<a>`
51
- * path uses for inactive/hover treatment — present whenever `AppSidebar` renders this callback.
52
- * Apply it to the rendered link's `className` to match that styling exactly instead of
53
- * re-deriving it.
54
- */
55
- export type NavLinkRenderer = (
56
- item: SidebarItem,
57
- opts: {
58
- active: boolean
59
- close?: (() => void) | undefined
60
- navLinkClassName?: string | undefined
61
- },
62
- ) => ReactNode
63
-
64
45
  export type AppSidebarProps = {
65
46
  brand: BrandConfig
66
47
  sections: SidebarSection[]
67
48
  collapsed: boolean
68
49
  onToggleCollapse: () => void
69
- onClose: () => void
70
- /**
71
- * Optional consumer link renderer (e.g. a router `<Link>`). Receives the precomputed `active`
72
- * flag. When omitted, items fall back to a plain `<a href>` + `item.onClick`.
73
- */
74
- renderNavLink?: NavLinkRenderer
75
50
  /**
76
51
  * Footer settings-menu entries (theme switcher, devtools, …) — supplied by the consumer.
77
52
  * The pinned footer "Settings" row renders ONLY when this is a non-empty list — apps that put
@@ -79,8 +54,6 @@ export type AppSidebarProps = {
79
54
  * `brand.version` label renders inside this menu, so it also only shows when the row does.
80
55
  */
81
56
  settingsMenuItems?: SettingsMenuItem[]
82
- /** Extra content appended to the sidebar footer, beside the settings menu (mobile close, etc.). */
83
- footerExtra?: ReactNode
84
57
  /**
85
58
  * Optional account row rendered below the settings menu in the sidebar footer (see
86
59
  * `SidebarAccount` / `BasaltAccountProps`), separated by its own top hairline. Omitting it
@@ -170,25 +143,6 @@ function IconGear() {
170
143
  )
171
144
  }
172
145
 
173
- function IconClose() {
174
- return (
175
- <svg
176
- width={18}
177
- height={18}
178
- viewBox="0 0 24 24"
179
- fill="none"
180
- stroke="currentColor"
181
- strokeWidth={2}
182
- strokeLinecap="round"
183
- strokeLinejoin="round"
184
- aria-hidden
185
- >
186
- <path d="M18 6l-12 12" />
187
- <path d="M6 6l12 12" />
188
- </svg>
189
- )
190
- }
191
-
192
146
  const HOVER_OPEN_DELAY = 150
193
147
  const HOVER_CLOSE_DELAY = 200
194
148
 
@@ -199,39 +153,27 @@ function hasActiveDescendant(item: SidebarItem): boolean {
199
153
  }
200
154
 
201
155
  /**
202
- * Renders a nav link body without the Tooltip/Box wrapper. `close` (item 20) closes the mobile
203
- * "More" drawer; forwarded to `renderNavLink` for the consumer to invoke, and wired directly into
204
- * the fallback `<a>` path's own click so it closes without any consumer wiring.
156
+ * Renders a nav link body without the Tooltip/Box wrapper.
157
+ *
158
+ * `item.Anchor` is the router seam and the ONLY one: basalt owns every pixel of the row (the
159
+ * `.link` class, the label, the icon, the badge) and merely hosts the consumer's `Link` as the
160
+ * element. That is what replaced the old render callback — a callback had to re-derive basalt's
161
+ * own styling to look right, and every consumer got it subtly wrong.
205
162
  */
206
- function NavLinkBody({
207
- item,
208
- active,
209
- renderNavLink,
210
- close,
211
- }: {
212
- item: SidebarItem
213
- active: boolean
214
- renderNavLink?: NavLinkRenderer | undefined
215
- close?: (() => void) | undefined
216
- }) {
217
- if (renderNavLink) {
218
- return <>{renderNavLink(item, { active, close, navLinkClassName: classes.link })}</>
163
+ function NavLinkBody({ item, active }: { item: SidebarItem; active: boolean }) {
164
+ const Anchor = item.Anchor
165
+ const shared = {
166
+ classNames: { root: classes.link },
167
+ label: item.label,
168
+ leftSection: item.icon,
169
+ rightSection: item.badge ?? (item.count ? <NavCountBadge count={item.count} /> : undefined),
170
+ active,
171
+ ...(item.onClick !== undefined && { onClick: item.onClick }),
219
172
  }
220
- return (
221
- <NavLink
222
- classNames={{ root: classes.link }}
223
- component="a"
224
- label={item.label}
225
- leftSection={item.icon}
226
- rightSection={item.badge}
227
- active={active}
228
- {...(item.href !== undefined && { href: item.href })}
229
- onClick={(e: MouseEvent) => {
230
- item.onClick?.(e)
231
- close?.()
232
- }}
233
- />
234
- )
173
+ if (Anchor) {
174
+ return <NavLink component={Anchor} {...shared} />
175
+ }
176
+ return <NavLink component="a" {...(item.href !== undefined && { href: item.href })} {...shared} />
235
177
  }
236
178
 
237
179
  /**
@@ -249,21 +191,14 @@ function SectionLabel({ children, flush }: { children: ReactNode; flush?: boolea
249
191
  )
250
192
  }
251
193
 
252
- function NavItemRow({
253
- item,
254
- collapsed,
255
- renderNavLink,
256
- closeMobile,
257
- }: {
258
- item: SidebarItem
259
- collapsed: boolean
260
- renderNavLink?: NavLinkRenderer | undefined
261
- /** Closes the mobile "More" drawer (item 20) — forwarded to `NavLinkBody` as `close`. */
262
- closeMobile?: (() => void) | undefined
263
- }) {
194
+ function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean }) {
264
195
  const [opened, { open, close }] = useDisclosure(false)
265
196
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
266
197
 
198
+ // The hover-popover timers outlive the row when a nav re-render unmounts it mid-delay, and a
199
+ // fired `open()` on an unmounted component is a React warning plus a leaked timer.
200
+ useEffect(() => () => clearTimeout(timer.current), [])
201
+
267
202
  const scheduleOpen = () => {
268
203
  clearTimeout(timer.current)
269
204
  timer.current = setTimeout(() => open(), HOVER_OPEN_DELAY)
@@ -297,12 +232,7 @@ function NavItemRow({
297
232
  return (
298
233
  <Tooltip key={item.key} label={item.label} position="right" withArrow disabled={!collapsed}>
299
234
  <Box className={classes.navItem}>
300
- <NavLinkBody
301
- item={item}
302
- active={active}
303
- renderNavLink={renderNavLink}
304
- close={closeMobile}
305
- />
235
+ <NavLinkBody item={item} active={active} />
306
236
  </Box>
307
237
  </Tooltip>
308
238
  )
@@ -317,12 +247,7 @@ function NavItemRow({
317
247
  <Box key={item.key}>
318
248
  <Tooltip label={item.label} position="right" withArrow disabled={!collapsed}>
319
249
  <Box className={classes.navItem}>
320
- <NavLinkBody
321
- item={item}
322
- active={active}
323
- renderNavLink={renderNavLink}
324
- close={closeMobile}
325
- />
250
+ <NavLinkBody item={item} active={active} />
326
251
  </Box>
327
252
  </Tooltip>
328
253
  <Stack gap={0} className={classes.childList}>
@@ -344,12 +269,7 @@ function NavItemRow({
344
269
  const childActive = Boolean(child.active)
345
270
  return (
346
271
  <Box key={child.key}>
347
- <NavLinkBody
348
- item={child}
349
- active={childActive}
350
- renderNavLink={renderNavLink}
351
- close={closeMobile}
352
- />
272
+ <NavLinkBody item={child} active={childActive} />
353
273
  </Box>
354
274
  )
355
275
  })}
@@ -379,12 +299,7 @@ function NavItemRow({
379
299
  onFocus={scheduleOpen}
380
300
  onBlur={scheduleClose}
381
301
  >
382
- <NavLinkBody
383
- item={item}
384
- active={active}
385
- renderNavLink={renderNavLink}
386
- close={closeMobile}
387
- />
302
+ <NavLinkBody item={item} active={active} />
388
303
  </Box>
389
304
  </Tooltip>
390
305
  </Popover.Target>
@@ -416,50 +331,15 @@ function NavItemRow({
416
331
  const childActive = Boolean(child.active)
417
332
  return (
418
333
  <Box key={child.key}>
419
- <NavLinkBody
420
- item={child}
421
- active={childActive}
422
- renderNavLink={renderNavLink}
423
- close={closeMobile}
424
- />
334
+ <NavLinkBody item={child} active={childActive} />
425
335
  </Box>
426
336
  )
427
337
  })}
428
338
  </Stack>
429
339
  </Popover.Dropdown>
430
340
 
431
- {/* Inline children when active */}
432
- {isExpanded && (
433
- <Stack gap={0} className={classes.childList}>
434
- {item.children!.map((child) => {
435
- if (child.disabled) {
436
- return (
437
- <Tooltip key={child.key} label="Coming soon" position="right" withArrow>
438
- <Box>
439
- <NavLink
440
- classNames={{ root: classes.link }}
441
- label={child.label}
442
- leftSection={child.icon}
443
- data-disabled
444
- />
445
- </Box>
446
- </Tooltip>
447
- )
448
- }
449
- const childActive = Boolean(child.active)
450
- return (
451
- <Box key={child.key}>
452
- <NavLinkBody
453
- item={child}
454
- active={childActive}
455
- renderNavLink={renderNavLink}
456
- close={closeMobile}
457
- />
458
- </Box>
459
- )
460
- })}
461
- </Stack>
462
- )}
341
+ {/* No inline child list here: `isExpanded` early-returns above, so this branch is only ever
342
+ * reached when the children are NOT visible inline — that is what the popover is for. */}
463
343
  </Popover>
464
344
  )
465
345
  }
@@ -469,10 +349,7 @@ export function AppSidebar({
469
349
  sections,
470
350
  collapsed,
471
351
  onToggleCollapse,
472
- onClose,
473
- renderNavLink,
474
352
  settingsMenuItems,
475
- footerExtra,
476
353
  account,
477
354
  search,
478
355
  navExtra,
@@ -489,36 +366,12 @@ export function AppSidebar({
489
366
  )
490
367
 
491
368
  const renderSectionItems = (section: SidebarSection) =>
492
- section.items.map((item) => (
493
- <NavItemRow
494
- key={item.key}
495
- item={item}
496
- collapsed={collapsed}
497
- renderNavLink={renderNavLink}
498
- closeMobile={onClose}
499
- />
500
- ))
501
-
502
- // Mobile-only footer controls (close + consumer extras) — must render regardless of whether
503
- // the settings row does, or the mobile drawer loses its close affordance.
504
- const mobileControls = (
505
- <Group gap={2} wrap="nowrap" hiddenFrom="sm">
506
- {footerExtra}
507
- <ActionIcon
508
- variant="subtle"
509
- className={classes.ghostIcon}
510
- onClick={onClose}
511
- aria-label="Close navigation"
512
- >
513
- <IconClose />
514
- </ActionIcon>
515
- </Group>
516
- )
369
+ section.items.map((item) => <NavItemRow key={item.key} item={item} collapsed={collapsed} />)
517
370
 
518
371
  // The pinned footer "Settings" row is OPT-IN: it renders only when the consumer supplies
519
372
  // settings-menu entries. Most apps put Settings in a nav section instead — rendering the row
520
- // unconditionally produced a duplicate "Settings" the consumer couldn't remove. Without it,
521
- // the mobile controls still render standalone (invisible on desktop via hiddenFrom).
373
+ // unconditionally produced a duplicate "Settings" the consumer couldn't remove. On mobile the
374
+ // same entries are reachable as flat rows in the bottom bar's More surface.
522
375
  const hasSettingsMenu = (settingsMenuItems?.length ?? 0) > 0
523
376
  const settingsRow = hasSettingsMenu ? (
524
377
  <Group {...(account ? {} : { className: classes.footer })} gap="xs" wrap="nowrap">
@@ -547,18 +400,8 @@ export function AppSidebar({
547
400
  )}
548
401
  </Menu.Dropdown>
549
402
  </Menu>
550
- {mobileControls}
551
- </Group>
552
- ) : (
553
- <Group
554
- {...(account ? {} : { className: classes.footer })}
555
- gap="xs"
556
- wrap="nowrap"
557
- hiddenFrom="sm"
558
- >
559
- {mobileControls}
560
403
  </Group>
561
- )
404
+ ) : null
562
405
 
563
406
  return (
564
407
  <Stack gap={0} h="100%" className={classes.root} data-collapsed={collapsed || undefined}>
@@ -585,7 +428,7 @@ export function AppSidebar({
585
428
  // control-height override. Reproduces Mantine's own static `--ai-size-md` (28px).
586
429
  size={28}
587
430
  visibleFrom="sm"
588
- className={`${classes.collapseBtn} ${classes.ghostIcon}`}
431
+ className={classes.ghostIcon}
589
432
  onClick={onToggleCollapse}
590
433
  aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
591
434
  >
@@ -1,15 +1,14 @@
1
1
  /**
2
- * `BasaltShell`'s `sidebarNavExtra` forwarding path — `AppSidebar` (exercised directly in
3
- * `app-sidebar.test.tsx`) is not the only place this prop can break. `BasaltShell` destructures
4
- * `sidebarNavExtra` and hands it to `AppSidebar` as `navExtra`; a typo'd destructure key, a dropped
5
- * prop, or a `sidebarFooterExtra`/`sidebarNavExtra` mix-up in that hand-off would ship silently
6
- * without a test that renders `BasaltShell` itself.
2
+ * Hand-off paths that only `BasaltShell` can break — the sub-components are exercised directly in
3
+ * `app-sidebar.test.tsx` / `app-mobile-nav.test.tsx`, but the shell is where the props are
4
+ * destructured and where `extraMoreRows` is COMPUTED. A typo'd destructure key or a row count that
5
+ * disagrees with the renderer ships silently without a test that renders `BasaltShell` itself.
7
6
  */
8
7
  import { MantineProvider } from '@mantine/core'
9
- import { render, screen } from '@testing-library/react'
8
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
10
9
  import { describe, expect, test } from 'bun:test'
11
10
  import { BasaltShell } from './index'
12
- import type { SidebarSection } from './index'
11
+ import type { BasaltAccountProps, SidebarSection } from './index'
13
12
 
14
13
  const BRAND = { name: 'Argo' }
15
14
  const ONE_SECTION: SidebarSection[] = [
@@ -33,3 +32,59 @@ describe('BasaltShell sidebarNavExtra', () => {
33
32
  expect(stack?.contains(extra)).toBe(true)
34
33
  })
35
34
  })
35
+
36
+ /**
37
+ * §2.2's arithmetic guarantee is that a `menu` never exceeds `menuMax` rows — six rows at 44px fit
38
+ * the headroom above the bar, and the menu runs `flip: false`, so a menu that overflows has no way
39
+ * to escape upward and simply renders off-screen. The guarantee is only as good as the row count
40
+ * the shell feeds `projectMobileNav`: counting `account` as ONE row while `accountRows` expands it
41
+ * into up to seven is what let a nine-row More surface pick `menu`.
42
+ */
43
+ describe('BasaltShell extraMoreRows', () => {
44
+ const NAV: SidebarSection[] = [
45
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null, mobile: 'tab' }] },
46
+ ]
47
+
48
+ /** Seven account rows: manage + billing + upgrade + three extras + sign out. */
49
+ const FAT_ACCOUNT: BasaltAccountProps = {
50
+ state: {
51
+ status: 'authenticated',
52
+ identity: { id: 'u1', name: 'Jo', email: 'jo@example.com' },
53
+ plan: { key: 'free', label: 'Free', isFree: true },
54
+ },
55
+ actions: {
56
+ onManageAccount: () => {},
57
+ onManageBilling: () => {},
58
+ onUpgrade: () => {},
59
+ onSignOut: () => {},
60
+ extraMenuItems: [
61
+ { key: 'a', label: 'Extra A', onClick: () => {} },
62
+ { key: 'b', label: 'Extra B', onClick: () => {} },
63
+ { key: 'c', label: 'Extra C', onClick: () => {} },
64
+ ],
65
+ },
66
+ }
67
+
68
+ test('an account expanding past menuMax raises the SHEET, not an overflowing menu', async () => {
69
+ render(
70
+ <MantineProvider>
71
+ <BasaltShell brand={BRAND} sections={NAV} account={FAT_ACCOUNT} />
72
+ </MantineProvider>,
73
+ )
74
+
75
+ fireEvent.click(screen.getByLabelText('More'))
76
+ await waitFor(() => expect(document.querySelector('.mantine-Drawer-content')).not.toBeNull())
77
+ expect(document.querySelector('[role="menu"]')).toBeNull()
78
+ })
79
+
80
+ /** A `loading` account renders NO rows, so it must not conjure a More slot that opens empty. */
81
+ test('a loading account produces no More slot at all', () => {
82
+ render(
83
+ <MantineProvider>
84
+ <BasaltShell brand={BRAND} sections={NAV} account={{ state: { status: 'loading' } }} />
85
+ </MantineProvider>,
86
+ )
87
+
88
+ expect(screen.queryByLabelText('More')).toBeNull()
89
+ })
90
+ })