basalt-ui 1.17.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 (112) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/agent/rules/basalt-charts.md +12 -0
  4. package/agent/rules/basalt-mantine.md +71 -6
  5. package/agent/rules/basalt-router.md +129 -30
  6. package/agent/rules/basalt-tokens.md +2 -1
  7. package/agent/skills/basalt-design/SKILL.md +2 -2
  8. package/dist/charts/kinds/DualPanel.d.ts +7 -0
  9. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  10. package/dist/charts/kinds/DualPanel.js +10 -3
  11. package/dist/charts/primitives/CartesianChart.d.ts +17 -0
  12. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  13. package/dist/charts/primitives/CartesianChart.js +5 -2
  14. package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
  15. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  16. package/dist/charts/primitives/ChartTooltip.js +2 -1
  17. package/dist/charts/series.d.ts +5 -0
  18. package/dist/charts/series.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +8 -0
  22. package/dist/nav/types.d.ts +110 -0
  23. package/dist/nav/types.d.ts.map +1 -0
  24. package/dist/nav/types.js +0 -0
  25. package/dist/router-tanstack/index.d.ts +9 -2
  26. package/dist/router-tanstack/index.d.ts.map +1 -1
  27. package/dist/router-tanstack/index.js +7 -0
  28. package/dist/router-tanstack/nav.d.ts +163 -0
  29. package/dist/router-tanstack/nav.d.ts.map +1 -0
  30. package/dist/router-tanstack/nav.js +38 -0
  31. package/dist/router-tanstack/nav.test.d.ts +2 -0
  32. package/dist/router-tanstack/nav.test.d.ts.map +1 -0
  33. package/dist/router-tanstack/search-param-store.d.ts +19 -9
  34. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  35. package/dist/router-tanstack/use-nav.d.ts +30 -0
  36. package/dist/router-tanstack/use-nav.d.ts.map +1 -0
  37. package/dist/router-tanstack/use-nav.js +71 -0
  38. package/dist/shell/app-breadcrumbs.d.ts +7 -9
  39. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  40. package/dist/shell/app-breadcrumbs.js +8 -4
  41. package/dist/shell/app-mobile-nav.d.ts +23 -24
  42. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  43. package/dist/shell/app-mobile-nav.js +290 -68
  44. package/dist/shell/app-mobile-nav.module.css +166 -11
  45. package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
  46. package/dist/shell/app-mobile-nav.test.d.ts +2 -0
  47. package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
  48. package/dist/shell/app-sidebar.d.ts +3 -28
  49. package/dist/shell/app-sidebar.d.ts.map +1 -1
  50. package/dist/shell/app-sidebar.js +40 -174
  51. package/dist/shell/app-sidebar.module.css +3 -2
  52. package/dist/shell/app-sidebar.module.css.d.ts +0 -2
  53. package/dist/shell/index.d.ts +22 -65
  54. package/dist/shell/index.d.ts.map +1 -1
  55. package/dist/shell/index.js +38 -76
  56. package/dist/shell/mobile-nav-model.d.ts +41 -0
  57. package/dist/shell/mobile-nav-model.d.ts.map +1 -0
  58. package/dist/shell/mobile-nav-model.js +244 -0
  59. package/dist/shell/mobile-nav-model.test.d.ts +2 -0
  60. package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
  61. package/dist/shell/nav-count-badge.d.ts +11 -0
  62. package/dist/shell/nav-count-badge.d.ts.map +1 -0
  63. package/dist/shell/nav-count-badge.js +29 -0
  64. package/dist/surfaces.d.ts +1 -1
  65. package/dist/surfaces.js +1 -1
  66. package/dist/theme/index.d.ts.map +1 -1
  67. package/dist/theme/index.js +8 -7
  68. package/dist/theme/nav-link.module.css +4 -2
  69. package/dist/tokens/index.d.ts.map +1 -1
  70. package/dist/tokens/index.js +2 -0
  71. package/dist/tokens/palette.d.ts +15 -0
  72. package/dist/tokens/palette.d.ts.map +1 -1
  73. package/dist/tokens/palette.js +13 -0
  74. package/dist/tokens.css +2 -0
  75. package/llms.txt +3 -3
  76. package/package.json +1 -1
  77. package/src/charts/kinds/DualPanel.test.tsx +81 -1
  78. package/src/charts/kinds/DualPanel.tsx +26 -4
  79. package/src/charts/primitives/CartesianChart.test.tsx +173 -1
  80. package/src/charts/primitives/CartesianChart.tsx +29 -5
  81. package/src/charts/primitives/ChartTooltip.test.tsx +22 -1
  82. package/src/charts/primitives/ChartTooltip.tsx +7 -1
  83. package/src/charts/series.ts +5 -0
  84. package/src/index.ts +15 -5
  85. package/src/nav/types.ts +115 -0
  86. package/src/router-tanstack/index.ts +21 -2
  87. package/src/router-tanstack/nav.test.ts +111 -0
  88. package/src/router-tanstack/nav.ts +205 -0
  89. package/src/router-tanstack/search-param-store.ts +19 -9
  90. package/src/router-tanstack/use-nav.ts +146 -0
  91. package/src/shell/app-breadcrumbs.tsx +21 -19
  92. package/src/shell/app-mobile-nav.module.css +166 -11
  93. package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
  94. package/src/shell/app-mobile-nav.test.tsx +369 -0
  95. package/src/shell/app-mobile-nav.tsx +436 -102
  96. package/src/shell/app-sidebar.module.css +3 -2
  97. package/src/shell/app-sidebar.module.css.d.ts +0 -2
  98. package/src/shell/app-sidebar.test.tsx +6 -10
  99. package/src/shell/app-sidebar.tsx +49 -206
  100. package/src/shell/index.test.tsx +62 -7
  101. package/src/shell/index.tsx +105 -164
  102. package/src/shell/mobile-nav-model.test.ts +296 -0
  103. package/src/shell/mobile-nav-model.ts +389 -0
  104. package/src/shell/nav-count-badge.tsx +39 -0
  105. package/src/surfaces.ts +1 -1
  106. package/src/theme/density-relations.test.ts +19 -1
  107. package/src/theme/index.ts +8 -7
  108. package/src/theme/nav-link.module.css +4 -2
  109. package/src/theme/spacing.test.ts +11 -0
  110. package/src/tokens/density.test.ts +2 -0
  111. package/src/tokens/index.ts +8 -5
  112. package/src/tokens/palette.ts +16 -0
@@ -101,6 +101,23 @@ export type CartesianTooltipConfig<T> = {
101
101
  prependRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
102
102
  /** Rows appended after the derived per-series rows. Same `ctx` as {@link prependRows}. */
103
103
  extraRows?: (d: T, ctx: CartesianTooltipRowContext<T>) => ReactNode
104
+ /**
105
+ * Render this chart's tooltip when it is a cursor FOLLOWER (the crosshair came from a sibling
106
+ * chart sharing the cursor), not only when this chart is the SOURCE. Default false — today's
107
+ * behaviour, nothing moves. A follower has no pointer under it, so its tooltip is always ANCHORED
108
+ * to the crosshair — the same positioning `follow: false` gives the source — regardless of what
109
+ * `follow` is set to on this chart: there is no other valid position for a chart nobody is
110
+ * hovering. `follow` keeps governing the SOURCE's own tooltip exactly as before; the two options
111
+ * only interact for a follower, where `onFollow` always wins on positioning. Only the SOURCE's
112
+ * tooltip is `aria-live` — followers announce nothing, so a shared-cursor page doesn't fire N
113
+ * live-region updates per pointer move.
114
+ *
115
+ * Cost, since it scales with the page rather than the chart: anchoring reads one
116
+ * `getBoundingClientRect` per hovered frame (the same cost `follow: false` documents), so N
117
+ * opted-in followers pay N of them per frame. That is the reason this is opt-in per chart rather
118
+ * than a page-level switch — turn it on for the charts a reader actually correlates.
119
+ */
120
+ onFollow?: boolean
104
121
  }
105
122
 
106
123
  /** Everything a mark renderer needs. Handed to `CartesianChart`'s child on every render. */
@@ -479,10 +496,17 @@ function CartesianPlot<T>({
479
496
  const rows =
480
497
  tooltipEnabled && point !== null ? deriveTooltipRows(tooltipSeries, point, leftFormat) : []
481
498
 
499
+ // This chart renders its tooltip either as the cursor SOURCE (always) or, opted in via
500
+ // `onFollow`, as a FOLLOWER (a sibling owns the cursor). A follower has no pointer under it, so
501
+ // it is always positioned via the anchored (crosshair) path below, regardless of `follow`.
502
+ const isFollowerRender = !cursor.isSource && tooltipCfg?.onFollow === true
503
+ const showTooltip = tooltipEnabled && point !== null && (cursor.isSource || isFollowerRender)
504
+
482
505
  // Anchored mode resolves the crosshair's plot-local x into viewport space; following mode uses
483
- // the pointer position `useChartCursor` already recorded.
484
- const anchorX =
485
- tooltipCfg?.follow === false && point !== null && cursorX !== null ? cursorX : null
506
+ // the pointer position `useChartCursor` already recorded. A follower render always anchors, since
507
+ // `follow`'s pointer-tracking path has no pointer to read on this chart.
508
+ const useAnchoredPosition = tooltipCfg?.follow === false || isFollowerRender
509
+ const anchorX = useAnchoredPosition && point !== null && cursorX !== null ? cursorX : null
486
510
  const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
487
511
  const tooltipAnchor =
488
512
  svgRect === undefined || anchorX === null
@@ -595,8 +619,8 @@ function CartesianPlot<T>({
595
619
  </Group>
596
620
  </svg>
597
621
 
598
- {tooltipEnabled && cursor.isSource && point !== null && (
599
- <ChartTooltipFloat anchor={tooltipAnchor}>
622
+ {showTooltip && (
623
+ <ChartTooltipFloat anchor={tooltipAnchor} ariaLive={cursor.isSource}>
600
624
  <TooltipHeader
601
625
  date={getX(point)}
602
626
  {...(formatHeader !== undefined && {
@@ -3,9 +3,10 @@
3
3
  * `fmtTooltipDate` regexes `YYYY-MM-DD` out of the key and builds a LOCAL `Date`, so a UTC ISO
4
4
  * domain key names the wrong day next to `formatX`/the tooltip badge, which both resolve locally.
5
5
  */
6
+ import { render, screen } from '@testing-library/react'
6
7
  import { describe, expect, test } from 'bun:test'
7
8
  import { renderToStaticMarkup } from 'react-dom/server'
8
- import { TooltipHeader } from './ChartTooltip'
9
+ import { ChartTooltipFloat, TooltipHeader } from './ChartTooltip'
9
10
 
10
11
  describe('TooltipHeader — format prop', () => {
11
12
  test('defaults to fmtTooltipDate when omitted', () => {
@@ -21,3 +22,23 @@ describe('TooltipHeader — format prop', () => {
21
22
  expect(html).not.toContain('Tue Apr 21 2026')
22
23
  })
23
24
  })
25
+
26
+ describe('ChartTooltipFloat — ariaLive', () => {
27
+ test('defaults to an aria-live="polite" region', () => {
28
+ render(
29
+ <ChartTooltipFloat anchor={{ x: 10, y: 10 }}>
30
+ <span>content</span>
31
+ </ChartTooltipFloat>,
32
+ )
33
+ expect(screen.getByRole('tooltip').getAttribute('aria-live')).toBe('polite')
34
+ })
35
+
36
+ test('ariaLive: false drops the live-region attribute entirely — a follower is visual only', () => {
37
+ render(
38
+ <ChartTooltipFloat anchor={{ x: 10, y: 10 }} ariaLive={false}>
39
+ <span>content</span>
40
+ </ChartTooltipFloat>,
41
+ )
42
+ expect(screen.getByRole('tooltip').hasAttribute('aria-live')).toBe(false)
43
+ })
44
+ })
@@ -155,9 +155,15 @@ const VIEWPORT_MARGIN = 8
155
155
  */
156
156
  export function ChartTooltipFloat({
157
157
  anchor,
158
+ ariaLive = true,
158
159
  children,
159
160
  }: {
160
161
  anchor: { x: number; y: number } | null
162
+ /** Whether this tooltip announces itself to screen readers on every position change. Default
163
+ * true. Pass false for a cursor FOLLOWER's tooltip — N followers on a shared-cursor page all
164
+ * announcing on every pointer move would make the page unusable with a screen reader; only the
165
+ * cursor SOURCE should be `aria-live`. */
166
+ ariaLive?: boolean
161
167
  children: ReactNode
162
168
  }) {
163
169
  const ref = useRef<HTMLDivElement>(null)
@@ -196,7 +202,7 @@ export function ChartTooltipFloat({
196
202
  <div
197
203
  ref={ref}
198
204
  role="tooltip"
199
- aria-live="polite"
205
+ {...(ariaLive && { 'aria-live': 'polite' as const })}
200
206
  style={{ ...TOOLTIP_STYLES, left, top, visibility: box === null ? 'hidden' : 'visible' }}
201
207
  >
202
208
  {children}
@@ -66,6 +66,11 @@ export type SeriesStyle = {
66
66
  * that lies is the thing the derived-legend rule exists to prevent). The TOOLTIP ROW swatch and
67
67
  * the CROSSHAIR DOT deliberately do NOT honor it: those are 12px value-readout chips where a
68
68
  * sub-1 opacity reads as a rendering bug, not as data.
69
+ *
70
+ * Applies to STROKES only, so it silently no-ops on a `mark: 'bar'`/`'area'` series — a bar
71
+ * swatch has no stroke to dim. Dim those with `fillOpacity` instead. This bites the
72
+ * zone/reference-legend idiom in particular (`mark: 'bar'`, `getValue: () => null`), where
73
+ * reaching for `strokeOpacity` looks right and does nothing.
69
74
  */
70
75
  strokeOpacity?: number
71
76
  /** Which y-axis this series is measured against. Default 'left'. `'right'` is what makes a
package/src/index.ts CHANGED
@@ -24,21 +24,31 @@ export {
24
24
  type SidebarSearchConfig,
25
25
  SidebarAccount,
26
26
  MobileNav,
27
+ type MobileNavProps,
28
+ projectMobileNav,
29
+ type ProjectMobileNavOptions,
30
+ MOBILE_MAX_TABS_DEFAULT,
31
+ MOBILE_MENU_MAX_DEFAULT,
32
+ MOBILE_MORE_KEY,
27
33
  AppBreadcrumbs,
28
- type BreadcrumbLinkRenderer,
29
34
  PageHeaderProvider,
30
35
  PageActions,
31
36
  PageActionsOutlet,
32
37
  type SidebarSection,
33
38
  type SidebarItem,
39
+ type NavAnchor,
40
+ type NavAnchorProps,
41
+ type NavMobilePlacement,
42
+ type NavSectionMobile,
43
+ type MobileNavConfig,
44
+ type MobileNavGroup,
45
+ type MobileNavModel,
46
+ type MobileNavSlot,
47
+ type MobileNavSurface,
34
48
  type BasaltShellProps,
35
49
  type BrandConfig,
36
50
  type SettingsMenuItem,
37
51
  type AppSidebarProps,
38
- type MobileNavItem,
39
- type MobileNavSection,
40
- type NavLinkRenderer,
41
- type MobileNavLinkRenderer,
42
52
  type AccountBadgeTone,
43
53
  type BasaltIdentity,
44
54
  type BasaltRole,
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The shared nav vocabulary. TYPE-ONLY BY CONTRACT — never add a value export here.
3
+ * `src/shell/**` (Mantine) and `src/router-tanstack/**` (headless) both `import type` from it,
4
+ * which is erased by tsup, so `dist/router-tanstack/index.js` gains no edge into `shell/` and
5
+ * `scripts/check-dist-layering.mjs` stays green with no config change.
6
+ */
7
+ import type { MouseEvent, ReactNode } from 'react'
8
+
9
+ /** Where a destination appears on the mobile bar. `true` ≡ `'tab'`, `false` ≡ `'hidden'`. */
10
+ export type NavMobilePlacement = 'tab' | 'more' | 'hidden'
11
+
12
+ /**
13
+ * Props basalt hands a nav anchor. Deliberately a PLAIN FUNCTION TYPE, not `ComponentType`
14
+ * or `ElementType`: both are unions, and a union defeats Mantine's polymorphic `component`
15
+ * inference (`<NavLink component={…}>` then rejects `label`). VERIFIED both ways.
16
+ */
17
+ export type NavAnchorProps = {
18
+ className?: string
19
+ children?: ReactNode
20
+ onClick?: (e: MouseEvent<HTMLElement>) => void
21
+ 'aria-current'?: 'page' | undefined
22
+ 'aria-label'?: string
23
+ }
24
+ export type NavAnchor = (props: NavAnchorProps) => ReactNode
25
+
26
+ /** A single nav destination. */
27
+ export type SidebarItem = {
28
+ key: string
29
+ label: string
30
+ /** Bar/menu label. Falls back to `label`. Keep ≤ 10 chars — a 5-slot bar is ~72px wide. */
31
+ short?: string
32
+ /** Mobile placement. `true` ≡ `'tab'`, `false` ≡ `'hidden'`. @default 'more' */
33
+ mobile?: boolean | NavMobilePlacement
34
+ icon: ReactNode
35
+ /**
36
+ * The router seam. basalt renders every pixel of chrome (desktop row, 56px slot, 44px sheet
37
+ * row) and only hosts this component. When absent the row falls back to `<a href>` + `onClick`.
38
+ */
39
+ Anchor?: NavAnchor
40
+ /** Plain href — the no-router fallback, and the breadcrumb parent target. */
41
+ href?: string
42
+ active?: boolean
43
+ disabled?: boolean
44
+ onClick?: (e: MouseEvent) => void
45
+ /** Rich desktop badge (e.g. `NavCountBadge`). Desktop only. */
46
+ badge?: ReactNode
47
+ /** Unread count. Renders `NavCountBadge` on desktop when `badge` is absent, and an accent
48
+ * dot on the mobile slot icon. `0` renders nothing. */
49
+ count?: number
50
+ children?: SidebarItem[]
51
+ }
52
+
53
+ /** Per-section mobile configuration. `false` hides the whole section from mobile. */
54
+ export type NavSectionMobile = {
55
+ /** Give this SECTION a bar slot; its destinations open per §2.2 cardinality inference. */
56
+ tab?: true
57
+ /** Slot label override; falls back to `SidebarSection.label`. */
58
+ label?: string
59
+ /** Slot icon override; falls back to `SidebarSection.icon`. */
60
+ icon?: ReactNode
61
+ }
62
+
63
+ export type SidebarSection = {
64
+ label: string
65
+ items: SidebarItem[]
66
+ icon?: ReactNode
67
+ collapsible?: boolean
68
+ defaultCollapsed?: boolean
69
+ /** Mobile behaviour for this section. Omit for the default (items place themselves). */
70
+ mobile?: false | NavSectionMobile
71
+ }
72
+
73
+ export type MobileNavSurface = 'link' | 'menu' | 'sheet'
74
+
75
+ /** One row inside a `menu`/`sheet` slot, grouped under its source section. */
76
+ export type MobileNavGroup = { key: string; label?: string; items: SidebarItem[] }
77
+
78
+ export type MobileNavSlot =
79
+ | {
80
+ kind: 'link'
81
+ key: string
82
+ label: string
83
+ short: string
84
+ icon: ReactNode
85
+ active: boolean
86
+ item: SidebarItem
87
+ }
88
+ | {
89
+ kind: 'menu' | 'sheet'
90
+ key: string
91
+ label: string
92
+ short: string
93
+ icon: ReactNode
94
+ active: boolean
95
+ groups: MobileNavGroup[]
96
+ isMore: boolean
97
+ }
98
+
99
+ /** Pure projection of `sections` onto the bar. Unit-testable with no DOM. */
100
+ export type MobileNavModel = { slots: MobileNavSlot[] }
101
+
102
+ export type MobileNavConfig = {
103
+ /** Explicit slot order by item/section key. Overrides every `item.mobile` placement. */
104
+ tabs?: readonly string[]
105
+ /** Hard cap on bar slots, INCLUDING More. @default 5 */
106
+ maxTabs?: number
107
+ /** Rows a slot may hold before it becomes a sheet instead of a menu. @default 6 */
108
+ menuMax?: number
109
+ /** @default 'More' */
110
+ moreLabel?: string
111
+ /** Extra content pinned to the bottom of the More surface. */
112
+ moreExtra?: ReactNode
113
+ /** Scroll container for active-slot re-tap. @default document.scrollingElement */
114
+ getScrollElement?: () => HTMLElement | null
115
+ }
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * ./router-tanstack — headless TanStack Router bridge for the app shell.
3
3
  * Optional peer: @tanstack/react-router.
4
- * Augments StaticDataRouteOption with title/icon/navSection for breadcrumb
5
- * and nav-active wiring — no Mantine, no JSX, no navigate() helper.
4
+ * Carries the typed navigation definition (`defineNav`/`navGroup`/`navTarget`/`flattenNav`) and
5
+ * `useNav`, which resolves it into `BasaltShell`'s `sections` + `mobileNav` props; plus the
6
+ * StaticDataRouteOption augmentation (title/icon/navSection) for router-derived breadcrumbs and
7
+ * the search-param stores — no Mantine, no JSX.
6
8
  */
7
9
 
8
10
  export {
@@ -15,6 +17,23 @@ export {
15
17
  type MultiSearchParamStore,
16
18
  type MultiSearchParamStoreOptions,
17
19
  } from './multi-search-param-store'
20
+ export { defineNav, navGroup, navTarget, flattenNav } from './nav'
21
+ export type {
22
+ NavItemMeta,
23
+ NavGroupMeta,
24
+ AnyNavItem,
25
+ AnyNavGroup,
26
+ AnyNavLink,
27
+ FlatNavItem,
28
+ NavConfig,
29
+ NavItemId,
30
+ NavGroupId,
31
+ NavTabId,
32
+ NavIndex,
33
+ } from './nav'
34
+ export { useNav } from './use-nav'
35
+ export type { UseNavOptions, NavBinding } from './use-nav'
36
+ export type { NavAnchor, NavAnchorProps, NavMobilePlacement } from '../nav/types'
18
37
  import type { ReactNode } from 'react'
19
38
  import { useLocation, useMatches } from '@tanstack/react-router'
20
39
 
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { defineNav, flattenNav, navGroup, navTarget } from './nav'
3
+
4
+ /**
5
+ * The definition below is deliberately built with the real builders (not object literals) so the
6
+ * runtime-identity assertions below prove the builders are erased at runtime — every bit of value
7
+ * they add is compile-time. The type-level half of the contract lives in the playground's
8
+ * `nav.type-guard.ts` fixture, which is where a registered router exists to validate `to` against.
9
+ */
10
+ const NAV = defineNav({
11
+ groups: [
12
+ navGroup({ id: 'main', label: 'Main' }, [
13
+ { id: 'dash', label: 'Dashboard', short: 'Home', mobile: 'tab', link: { to: '/dashboard' } },
14
+ {
15
+ id: 'reports',
16
+ label: 'Reports',
17
+ link: { to: '/reports' },
18
+ children: [
19
+ { id: 'reports-daily', label: 'Daily', link: { to: '/reports/daily' } },
20
+ { id: 'reports-weekly', label: 'Weekly', link: { to: '/reports/weekly' } },
21
+ ],
22
+ },
23
+ ]),
24
+ navGroup({ id: 'system', label: 'System', collapsible: true }, [
25
+ { id: 'usage', label: 'Usage', disabled: true, link: { to: '/usage', search: { r: '30d' } } },
26
+ ]),
27
+ ],
28
+ })
29
+
30
+ describe('defineNav / navGroup', () => {
31
+ test('defineNav is runtime identity — it returns the very object it was given', () => {
32
+ const config = { groups: [] } as const
33
+ expect(defineNav(config)).toBe(config)
34
+ })
35
+
36
+ test('navGroup merges meta and items into one plain object, preserving item order', () => {
37
+ const group = navGroup({ id: 'g', label: 'G', collapsible: true }, [
38
+ { id: 'a', label: 'A', link: { to: '/a' } },
39
+ { id: 'b', label: 'B', link: { to: '/b' } },
40
+ ])
41
+ expect(group).toEqual({
42
+ id: 'g',
43
+ label: 'G',
44
+ collapsible: true,
45
+ items: [
46
+ { id: 'a', label: 'A', link: { to: '/a' } },
47
+ { id: 'b', label: 'B', link: { to: '/b' } },
48
+ ],
49
+ })
50
+ })
51
+
52
+ test('navGroup does not mutate the meta object it was handed', () => {
53
+ const meta = { id: 'g', label: 'G' }
54
+ navGroup(meta, [{ id: 'a', label: 'A', link: { to: '/a' } }])
55
+ expect(meta).toEqual({ id: 'g', label: 'G' })
56
+ })
57
+ })
58
+
59
+ describe('flattenNav', () => {
60
+ test('walks depth-first — parent immediately before its own children', () => {
61
+ expect(flattenNav(NAV).map((d) => d.id)).toEqual([
62
+ 'dash',
63
+ 'reports',
64
+ 'reports-daily',
65
+ 'reports-weekly',
66
+ 'usage',
67
+ ])
68
+ })
69
+
70
+ test('tags every destination — including nested children — with its declaring group', () => {
71
+ const byId = new Map(flattenNav(NAV).map((d) => [d.id, d]))
72
+ expect(byId.get('dash')).toMatchObject({ groupId: 'main', groupLabel: 'Main' })
73
+ expect(byId.get('reports-weekly')).toMatchObject({ groupId: 'main', groupLabel: 'Main' })
74
+ expect(byId.get('usage')).toMatchObject({ groupId: 'system', groupLabel: 'System' })
75
+ })
76
+
77
+ test('carries the authored metadata through untouched', () => {
78
+ const dash = flattenNav(NAV)[0]
79
+ expect(dash).toMatchObject({
80
+ label: 'Dashboard',
81
+ short: 'Home',
82
+ mobile: 'tab',
83
+ link: { to: '/dashboard' },
84
+ })
85
+ })
86
+
87
+ test('an empty definition flattens to an empty list', () => {
88
+ expect(flattenNav({ groups: [] })).toEqual([])
89
+ })
90
+ })
91
+
92
+ describe('navTarget', () => {
93
+ test('returns the destination link options by id', () => {
94
+ expect(navTarget(NAV, 'dash')).toEqual({ to: '/dashboard' })
95
+ expect(navTarget(NAV, 'usage')).toEqual({ to: '/usage', search: { r: '30d' } })
96
+ })
97
+
98
+ test('returns the SAME object the definition holds — spreadable with no copy', () => {
99
+ expect(navTarget(NAV, 'dash')).toBe(NAV.groups[0].items[0].link)
100
+ })
101
+
102
+ test('resolves ids nested under a parent destination', () => {
103
+ expect(navTarget(NAV, 'reports-weekly')).toEqual({ to: '/reports/weekly' })
104
+ })
105
+
106
+ test('throws a named error on an unknown id', () => {
107
+ // The id union makes this a compile error for a consumer; the throw is the runtime backstop
108
+ // for the untyped edge (a dynamic id, or an unregistered router degrading `NavItemId` away).
109
+ expect(() => navTarget(NAV, 'zzz' as 'dash')).toThrow('navTarget: no nav item with id "zzz"')
110
+ })
111
+ })
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Typed navigation definition. Wraps TanStack's own `linkOptions` rather than reimplementing its
3
+ * validator, so `to` / `search` / `params` get the router's full checking, and nav metadata rides
4
+ * OUTSIDE the validated object in a sibling `link:` key.
5
+ *
6
+ * Why not flat (`{ id, label, to, search }`) via a carrier `TComp` over `ValidateLinkOptions`:
7
+ * `Constrain<T, C> = (T extends C ? T : never) | C` is an ASSIGNABILITY check, and assignability
8
+ * does not do excess-property checking — so `{ id, label, to: '/charts', colour: 'red' }` compiles
9
+ * silently. Compile-verified. A config whose selling point is "typos are compile errors" cannot
10
+ * have a hole exactly where metadata typos live.
11
+ *
12
+ * No JSX and no Mantine in this module — `./router-tanstack` is a headless barrel by contract.
13
+ */
14
+ import type { ReactNode } from 'react'
15
+ import type { NavMobilePlacement, NavSectionMobile } from '../nav/types'
16
+
17
+ // ── authoring shapes ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ export type NavItemMeta = {
20
+ id: string
21
+ label: string
22
+ /** Bar/menu label. Falls back to `label`. */
23
+ short?: string
24
+ icon?: ReactNode
25
+ disabled?: boolean
26
+ /** Mobile placement. `true` ≡ `'tab'`, `false` ≡ `'hidden'`. @default 'more' */
27
+ mobile?: boolean | NavMobilePlacement
28
+ /** Active on an exact path match only. Default: prefix (`fuzzy: true`). `'/'` is always exact. */
29
+ exact?: boolean
30
+ }
31
+
32
+ export type NavGroupMeta = {
33
+ id: string
34
+ label: string
35
+ icon?: ReactNode
36
+ collapsible?: boolean
37
+ defaultCollapsed?: boolean
38
+ mobile?: false | NavSectionMobile
39
+ }
40
+
41
+ /** Widened read shapes. The framework reads through these; a consumer never writes them. */
42
+ export type AnyNavLink = { to: string; search?: unknown; params?: unknown }
43
+ export type AnyNavItem = NavItemMeta & { link: AnyNavLink; children?: ReadonlyArray<AnyNavItem> }
44
+ export type AnyNavGroup = NavGroupMeta & { items: ReadonlyArray<AnyNavItem> }
45
+
46
+ // ── inference machinery ──────────────────────────────────────────────────────────────────────────
47
+
48
+ /** Exact-object check: any key outside `Shape` is typed `never`, which surfaces as an error. */
49
+ export type Exact<T, Shape> = T & { readonly [K in Exclude<keyof T, keyof Shape>]: never }
50
+
51
+ /**
52
+ * `Exact` for ONE item, recursing into `children`.
53
+ *
54
+ * The recursion is the whole point: `AnyNavItem['children']` is a plain
55
+ * `ReadonlyArray<AnyNavItem>` constraint, and a constraint is an assignability check — so a
56
+ * top-level-only `Exact` leaves the metadata-typo hole wide open exactly one level down
57
+ * (`children: [{ id, label, colour: 'red', link }]` compiled silently). Reapplying `Exact` per
58
+ * child, at every depth, is what makes the nested `link:` design actually deliver "every typo is a
59
+ * compile error" rather than "every typo in the top row is a compile error".
60
+ *
61
+ * The `[K in Extract<keyof T, 'children'>]` form rather than a `children?:` property is what keeps
62
+ * this inference-safe: it evaluates to `{}` for an item that declares no children (so no phantom
63
+ * key is added to the checked shape and nothing becomes required), and the intersection with
64
+ * `Exact<T, AnyNavItem>` keeps `T` in a naked inference position so the caller's literal types
65
+ * still flow out of `navGroup`'s `const T`.
66
+ */
67
+ export type ExactNavItem<T> = Exact<T, AnyNavItem> & {
68
+ readonly [K in Extract<keyof T, 'children'>]: T[K] extends ReadonlyArray<AnyNavItem>
69
+ ? NavItemsInput<T[K]>
70
+ : T[K]
71
+ }
72
+
73
+ /** Per-element `Exact` over a tuple of items, so an unknown key errors at the item that owns it. */
74
+ export type NavItemsInput<T extends ReadonlyArray<AnyNavItem>> = {
75
+ readonly [K in keyof T]: ExactNavItem<T[K]>
76
+ }
77
+
78
+ type ItemsOf<G> = G extends ReadonlyArray<{ items: ReadonlyArray<infer I> }> ? I : never
79
+
80
+ /**
81
+ * One item plus every descendant, at arbitrary depth. Recursive so the TYPE side matches what
82
+ * `findById` / `flattenNav` already do at RUNTIME — a one-level walk made `navTarget(nav,
83
+ * 'grandchild')` a compile error against an id the runtime resolves fine.
84
+ */
85
+ type WithDescendants<I> =
86
+ | I
87
+ | (I extends { children: ReadonlyArray<infer C> } ? WithDescendants<C> : never)
88
+
89
+ type AllItemsOf<G> = WithDescendants<ItemsOf<G>>
90
+
91
+ /**
92
+ * id → link options, as a mapped type with key remapping. Do NOT replace this with
93
+ * `Extract<AllItemsOf<G>, { id: K }>['link']` — that form was compile-tested and returns the
94
+ * WHOLE union, silently losing per-id precision.
95
+ */
96
+ export type NavIndex<G> = {
97
+ [I in AllItemsOf<G> as I extends { id: infer K extends string } ? K : never]: I extends {
98
+ link: infer L
99
+ }
100
+ ? L
101
+ : never
102
+ }
103
+
104
+ /**
105
+ * The literal union of every addressable destination id in a definition — parents, children and
106
+ * grandchildren alike, at ARBITRARY depth, matching what `findById` / `flattenNav` resolve at
107
+ * runtime. Verified to seven levels of `children` with no instantiation-depth complaint.
108
+ *
109
+ * If this ever resolves to plain `string`, the entire nav API validates nothing while reporting
110
+ * zero errors — so any change to the generics above must be re-checked against
111
+ * `apps/playground/src/nav.type-guard.tsx`, whose `@ts-expect-error` rows go UNUSED (TS2578) the
112
+ * moment the union widens.
113
+ */
114
+ export type NavItemId<G> = Extract<keyof NavIndex<G>, string>
115
+ export type NavGroupId<G> = G extends ReadonlyArray<{ id: infer I }> ? I : never
116
+ export type NavTabId<G> = NavItemId<G> | NavGroupId<G>
117
+
118
+ export type NavConfig<
119
+ G extends ReadonlyArray<AnyNavGroup>,
120
+ T extends ReadonlyArray<NavTabId<G>>,
121
+ > = {
122
+ groups: G
123
+ mobile?: {
124
+ /** Explicit bar order by item/group id. Omit to let each item's `mobile` field decide. */
125
+ tabs?: T
126
+ /** @default 5 */ maxTabs?: number
127
+ /** @default 6 */ menuMax?: number
128
+ /** @default 'More' */ moreLabel?: string
129
+ }
130
+ }
131
+
132
+ // ── builders (identity at runtime) ───────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * One nav group. `const M` on the meta is load-bearing: without it the group `id` widens to
136
+ * `string` from `NavGroupMeta` and `mobile.tabs` silently stops validating group ids.
137
+ */
138
+ export function navGroup<const M extends NavGroupMeta, const T extends ReadonlyArray<AnyNavItem>>(
139
+ meta: Exact<M, NavGroupMeta>,
140
+ items: NavItemsInput<T>,
141
+ ): M & { items: T } {
142
+ return { ...(meta as M), items: items as unknown as T }
143
+ }
144
+
145
+ /**
146
+ * The whole navigation — desktop and mobile — in one inferred definition.
147
+ *
148
+ * There is deliberately NO compile-time cap on `mobile.tabs.length`. Both a
149
+ * `T extends … & { length: 0|1|2|3|4|5 }` constraint and a `tabs: T & MaxFive` property form were
150
+ * compile-tested: each makes the length check work but silently degrades `NavTabId<G>` to `string`,
151
+ * killing the far more valuable id-union validation. The cap is a runtime concern — the shell's
152
+ * `projectMobileNav` slices to `maxTabs` and DEV-warns about what it dropped.
153
+ */
154
+ export function defineNav<
155
+ const G extends ReadonlyArray<AnyNavGroup>,
156
+ const T extends ReadonlyArray<NavTabId<G>>,
157
+ >(config: NavConfig<G, T>): NavConfig<G, T> {
158
+ return config
159
+ }
160
+
161
+ // ── readers ──────────────────────────────────────────────────────────────────────────────────────
162
+
163
+ function findById(items: ReadonlyArray<AnyNavItem>, id: string): AnyNavItem | undefined {
164
+ for (const item of items) {
165
+ if (item.id === id) return item
166
+ const hit = item.children ? findById(item.children, id) : undefined
167
+ if (hit) return hit
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ /**
173
+ * The link options for one destination, by id. Spreadable into `<Link>`, `router.navigate()`
174
+ * and `redirect()`. This is what collapses a consumer's Spotlight commands and index redirect.
175
+ *
176
+ * `id` is the definition's literal id union (`NavItemId`) at any nesting depth, so a nested child
177
+ * or grandchild is addressable exactly like a top-level item — the type side and `findById`'s
178
+ * runtime walk agree.
179
+ */
180
+ export function navTarget<
181
+ const C extends { groups: ReadonlyArray<AnyNavGroup> },
182
+ K extends NavItemId<C['groups']>,
183
+ >(config: C, id: K): NavIndex<C['groups']>[K] {
184
+ for (const g of config.groups) {
185
+ const hit = findById(g.items, id)
186
+ if (hit) return hit.link as NavIndex<C['groups']>[K]
187
+ }
188
+ throw new Error(`navTarget: no nav item with id "${id}"`)
189
+ }
190
+
191
+ /** A flattened destination, tagged with the group it was declared in. */
192
+ export type FlatNavItem = AnyNavItem & { groupId: string; groupLabel: string }
193
+
194
+ /** Depth-first flatten, parent then children — the leaf every other surface reads. */
195
+ export function flattenNav(config: { groups: ReadonlyArray<AnyNavGroup> }): FlatNavItem[] {
196
+ const out: FlatNavItem[] = []
197
+ const walk = (items: ReadonlyArray<AnyNavItem>, groupId: string, groupLabel: string): void => {
198
+ for (const item of items) {
199
+ out.push({ ...item, groupId, groupLabel })
200
+ if (item.children) walk(item.children, groupId, groupLabel)
201
+ }
202
+ }
203
+ for (const g of config.groups) walk(g.items, g.id, g.label)
204
+ return out
205
+ }