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
@@ -0,0 +1,369 @@
1
+ /**
2
+ * `MobileNav` — the eight behaviours that make the bar a NAVIGATION bar rather than a menu.
3
+ *
4
+ * The first three are the user's actual complaint, encoded:
5
+ * 1. tapping a destination navigates through the consumer's anchor and raises NOTHING,
6
+ * 3. a small group opens a content-sized `Menu` — never the full-viewport `Drawer` that used to
7
+ * appear for a handful of rows,
8
+ * 4. and the `Drawer` still exists, but only past the row count a menu can hold.
9
+ *
10
+ * Harness constraints that shape every selector below (see `tests/setup/dom.ts`): components mount
11
+ * under a BARE `<MantineProvider>` (`BasaltProvider` would drag in the query client and the palette
12
+ * `<style>`, neither of which this component reads); CSS-module class hashes are unavailable under
13
+ * `bun test`, so nothing here selects on `classes.*` — it selects on `data-testid`, the accessible
14
+ * name, or Mantine's stable static classes (`.mantine-Menu-item`, `.mantine-Drawer-content`); and
15
+ * `matchMedia` is pinned to `matches: false`, so `hiddenFrom`/`prefers-reduced-motion` are not
16
+ * assertable here at all (§9.6 leaves those to a real device).
17
+ *
18
+ * Both surfaces mount a frame AFTER the click that opens them — floating-ui positions on an effect
19
+ * — so every open/close assertion is a `waitFor`, never a synchronous `expect` on the next line.
20
+ */
21
+ import { MantineProvider } from '@mantine/core'
22
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
23
+ import { describe, expect, test } from 'bun:test'
24
+ import type { ReactElement } from 'react'
25
+ import { MobileNav } from './app-mobile-nav'
26
+ import { projectMobileNav } from './mobile-nav-model'
27
+ import type { MobileNavConfig, NavAnchorProps, SidebarItem, SidebarSection } from '../nav/types'
28
+
29
+ // ── fixtures ─────────────────────────────────────────────────────────────────────────────────────
30
+
31
+ function item(key: string, extra: Partial<SidebarItem> = {}): SidebarItem {
32
+ return { key, label: key[0]?.toUpperCase() + key.slice(1), icon: null, ...extra }
33
+ }
34
+
35
+ /**
36
+ * A stand-in for the consumer's router `Link`. The real seam is exactly this shape — basalt hands
37
+ * it `className`/`onClick`/`aria-*` and hosts whatever it renders — so a test anchor that spreads
38
+ * its props onto an `<a>` proves the hand-off without mounting a router.
39
+ */
40
+ function testAnchor(testid: string) {
41
+ // `children` is destructured out and re-placed rather than left in the spread purely so
42
+ // `jsx-a11y(anchor-has-content)` can see the content — the rule cannot follow a spread.
43
+ return function TestAnchor({ children, ...rest }: NavAnchorProps): ReactElement {
44
+ return (
45
+ <a data-testid={testid} {...rest}>
46
+ {children}
47
+ </a>
48
+ )
49
+ }
50
+ }
51
+
52
+ function renderBar(sections: SidebarSection[], config?: MobileNavConfig) {
53
+ const model = projectMobileNav(sections, config ? { config } : undefined)
54
+ return render(
55
+ <MantineProvider>
56
+ <MobileNav model={model} {...(config ? { config } : {})} />
57
+ </MantineProvider>,
58
+ )
59
+ }
60
+
61
+ const menu = (): Element | null => document.querySelector('[role="menu"]')
62
+ const drawer = (): Element | null => document.querySelector('.mantine-Drawer-content')
63
+
64
+ /**
65
+ * Waits for a surface to DISAPPEAR — and does it by throwing a plain `Error`, never by failing an
66
+ * `expect(node).toBeNull()` inside the `waitFor` callback.
67
+ *
68
+ * That is not style. `waitFor` re-runs the callback until it stops throwing, and bun's matcher
69
+ * serializes the received value into the failure message on EVERY attempt: a happy-dom element
70
+ * carries the whole listener map plus its parent chain, so one such message takes multiple seconds
71
+ * to build — longer than `waitFor`'s own 1s timeout, which makes an assertion that WOULD have
72
+ * passed on the next poll fail with a stale error instead. A bare `throw` costs nothing.
73
+ */
74
+ async function waitForGone(what: 'menu' | 'drawer'): Promise<void> {
75
+ const find = what === 'menu' ? menu : drawer
76
+ await waitFor(() => {
77
+ if (find() !== null) throw new Error(`the ${what} is still open`)
78
+ })
79
+ }
80
+
81
+ /** A cancelable click, dispatched by hand so the test can read `defaultPrevented` back off it —
82
+ * which is the only observable difference between "navigated" and "suppressed the navigation". */
83
+ function clickCancelable(node: Element): MouseEvent {
84
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true })
85
+ fireEvent(node, event)
86
+ return event
87
+ }
88
+
89
+ // ── the behaviours ───────────────────────────────────────────────────────────────────────────────
90
+
91
+ describe('MobileNav', () => {
92
+ /**
93
+ * THE POINT OF THE REWRITE. A slot is a destination: the consumer's anchor IS the tab, and a tap
94
+ * raises no overlay at all. Both negative assertions are load-bearing — the old bar opened a
95
+ * sheet here, which cost a second tap to reach the same page.
96
+ */
97
+ test('1. a link slot renders item.Anchor with the spread props and opens no overlay', async () => {
98
+ renderBar([
99
+ {
100
+ label: 'Main',
101
+ items: [
102
+ item('home', { mobile: 'tab', Anchor: testAnchor('anchor-home') }),
103
+ item('reports'),
104
+ ],
105
+ },
106
+ ])
107
+
108
+ const anchor = screen.getByTestId('anchor-home')
109
+ // The consumer's component received basalt's chrome props, not just its children.
110
+ expect(anchor.tagName).toBe('A')
111
+ expect(anchor.getAttribute('aria-label')).toBe('Home')
112
+ expect(anchor.getAttribute('class')).toBeTruthy()
113
+ expect(anchor.textContent).toContain('Home')
114
+
115
+ fireEvent.click(anchor)
116
+ // Give any overlay a frame to mount — the assertion is that none ever does.
117
+ await waitFor(() => expect(screen.getByTestId('anchor-home')).toBeTruthy())
118
+ expect(menu()).toBeNull()
119
+ expect(drawer()).toBeNull()
120
+ })
121
+
122
+ /** §2.5 — re-tapping the slot you are already on is a scroll-to-top, not a redundant history
123
+ * entry. `preventDefault` is what suppresses the router's own click handler. */
124
+ test('2. re-tapping the ACTIVE slot scrolls the configured element and suppresses navigation', () => {
125
+ const scrolled: ScrollToOptions[] = []
126
+ const target = document.createElement('div')
127
+ target.scrollTo = ((options: ScrollToOptions) => {
128
+ scrolled.push(options)
129
+ }) as typeof target.scrollTo
130
+ let asked = 0
131
+
132
+ renderBar(
133
+ [
134
+ {
135
+ label: 'Main',
136
+ items: [
137
+ item('home', { mobile: 'tab', active: true, Anchor: testAnchor('anchor-home') }),
138
+ item('activity', { mobile: 'tab', Anchor: testAnchor('anchor-activity') }),
139
+ ],
140
+ },
141
+ ],
142
+ {
143
+ getScrollElement: () => {
144
+ asked += 1
145
+ return target
146
+ },
147
+ },
148
+ )
149
+
150
+ const active = clickCancelable(screen.getByTestId('anchor-home'))
151
+ expect(active.defaultPrevented).toBe(true)
152
+ expect(asked).toBe(1)
153
+ expect(scrolled).toHaveLength(1)
154
+ expect(scrolled[0]?.top).toBe(0)
155
+
156
+ // The inactive slot is untouched: its click must reach the router.
157
+ const inactive = clickCancelable(screen.getByTestId('anchor-activity'))
158
+ expect(inactive.defaultPrevented).toBe(false)
159
+ expect(scrolled).toHaveLength(1)
160
+ })
161
+
162
+ /**
163
+ * §2.2 — three rows is a menu that pops out of the tab. The `Drawer` assertion is the regression
164
+ * guard: a full-viewport sheet for three rows is exactly the behaviour being deleted.
165
+ */
166
+ test('3. a 3-destination group slot opens a menu and NEVER a drawer', async () => {
167
+ renderBar([
168
+ {
169
+ label: 'Reports',
170
+ mobile: { tab: true },
171
+ items: [item('daily'), item('weekly'), item('monthly')],
172
+ },
173
+ { label: 'Main', items: [item('home')] },
174
+ ])
175
+
176
+ expect(menu()).toBeNull()
177
+ fireEvent.click(screen.getByLabelText('Reports'))
178
+
179
+ await waitFor(() => expect(menu()).not.toBeNull())
180
+ expect(document.querySelectorAll('.mantine-Menu-item')).toHaveLength(3)
181
+ expect(drawer()).toBeNull()
182
+ })
183
+
184
+ /** Past `menuMax` the surface flips — the sheet is not deleted, it is confined to the case a
185
+ * content-sized menu genuinely cannot hold. */
186
+ test('4. a 9-destination group slot opens the bottom sheet', async () => {
187
+ renderBar([
188
+ {
189
+ label: 'Reports',
190
+ mobile: { tab: true },
191
+ items: Array.from({ length: 9 }, (_, i) => item(`row${i + 1}`)),
192
+ },
193
+ { label: 'Main', items: [item('home')] },
194
+ ])
195
+
196
+ fireEvent.click(screen.getByLabelText('Reports'))
197
+
198
+ await waitFor(() => expect(drawer()).not.toBeNull())
199
+ expect(menu()).toBeNull()
200
+ })
201
+
202
+ /** The trigger is a disclosure: the same tap that opened it closes it. */
203
+ test('5. a second tap on the same slot closes the menu', async () => {
204
+ renderBar([
205
+ { label: 'Reports', mobile: { tab: true }, items: [item('daily'), item('weekly')] },
206
+ { label: 'Main', items: [item('home')] },
207
+ ])
208
+
209
+ const trigger = screen.getByLabelText('Reports')
210
+ fireEvent.click(trigger)
211
+ await waitFor(() => expect(menu()).not.toBeNull())
212
+
213
+ fireEvent.click(trigger)
214
+ await waitForGone('menu')
215
+ })
216
+
217
+ /** §2.9 — `closeOnEscape` + `returnFocus` are Mantine props, but "the tab you came from gets
218
+ * focus back" is the behaviour a keyboard user feels, so it is asserted, not assumed. */
219
+ test('6. Escape closes the menu and returns focus to the slot trigger', async () => {
220
+ renderBar([
221
+ { label: 'Reports', mobile: { tab: true }, items: [item('daily'), item('weekly')] },
222
+ { label: 'Main', items: [item('home')] },
223
+ ])
224
+
225
+ const trigger = screen.getByLabelText('Reports')
226
+ // A keyboard user reaches the tab before activating it — and `useFocusReturn` records whatever
227
+ // held focus when the dropdown opened, so this is what makes the return target meaningful.
228
+ trigger.focus()
229
+ fireEvent.click(trigger)
230
+ await waitFor(() => expect(menu()).not.toBeNull())
231
+
232
+ const dropdown = menu()
233
+ if (!dropdown) throw new Error('unreachable')
234
+ fireEvent.keyDown(dropdown, { key: 'Escape' })
235
+
236
+ await waitForGone('menu')
237
+ await waitFor(() => {
238
+ if (document.activeElement !== trigger) throw new Error('focus did not return to the trigger')
239
+ })
240
+ })
241
+
242
+ /** §2.9 — a disabled destination still renders (rule 11), so it must announce itself as
243
+ * disabled rather than just look dimmed. */
244
+ test('7. a disabled row carries data-disabled and aria-disabled', async () => {
245
+ renderBar([
246
+ {
247
+ label: 'Reports',
248
+ mobile: { tab: true },
249
+ items: [item('daily'), item('weekly', { disabled: true })],
250
+ },
251
+ { label: 'Main', items: [item('home')] },
252
+ ])
253
+
254
+ fireEvent.click(screen.getByLabelText('Reports'))
255
+ await waitFor(() => expect(menu()).not.toBeNull())
256
+
257
+ const rows = Array.from(document.querySelectorAll('.mantine-Menu-item'))
258
+ const disabled = rows.find((row) => row.textContent?.includes('Weekly'))
259
+ expect(disabled).toBeTruthy()
260
+ expect(disabled?.getAttribute('data-disabled')).toBe('true')
261
+ expect(disabled?.getAttribute('aria-disabled')).toBe('true')
262
+
263
+ const enabled = rows.find((row) => row.textContent?.includes('Daily'))
264
+ expect(enabled?.hasAttribute('data-disabled')).toBe(false)
265
+ expect(enabled?.hasAttribute('aria-disabled')).toBe(false)
266
+ })
267
+
268
+ /** §2.9 — the landmark and the current-page marker: the two things a screen reader needs to
269
+ * describe the bar at all. */
270
+ test('8. the bar is a Primary nav landmark and the active slot is aria-current="page"', () => {
271
+ const { container } = renderBar([
272
+ {
273
+ label: 'Main',
274
+ items: [
275
+ item('home', { mobile: 'tab', active: true, Anchor: testAnchor('anchor-home') }),
276
+ item('activity', { mobile: 'tab', Anchor: testAnchor('anchor-activity') }),
277
+ ],
278
+ },
279
+ ])
280
+
281
+ const bar = container.querySelector('nav')
282
+ expect(bar).not.toBeNull()
283
+ expect(bar?.getAttribute('aria-label')).toBe('Primary')
284
+
285
+ expect(screen.getByTestId('anchor-home').getAttribute('aria-current')).toBe('page')
286
+ expect(screen.getByTestId('anchor-activity').hasAttribute('aria-current')).toBe(false)
287
+ })
288
+
289
+ /**
290
+ * §2.9 again, and the half that "looks disabled" hides: a disabled row must be DEAD, not dim.
291
+ * Mantine's `Menu.css` gives `[data-disabled]` a muted color and nothing else, the row is
292
+ * polymorphic (`component="a"`) so the native `disabled` attribute never applies, and
293
+ * `MenuItem` composes the CALLER's `onClick` BEFORE its own `data-disabled` check — so with no
294
+ * guard the consumer's navigation handler fires on a destination the projection rendered dead.
295
+ * The module's `pointer-events: none` covers pointers; this covers everything else.
296
+ */
297
+ test('9. a disabled menu row does not fire its onClick, while its enabled sibling does', async () => {
298
+ let disabledFired = 0
299
+ let enabledFired = 0
300
+ renderBar([
301
+ {
302
+ label: 'Reports',
303
+ mobile: { tab: true },
304
+ items: [
305
+ item('daily', {
306
+ onClick: () => {
307
+ enabledFired += 1
308
+ },
309
+ }),
310
+ item('weekly', {
311
+ disabled: true,
312
+ onClick: () => {
313
+ disabledFired += 1
314
+ },
315
+ }),
316
+ ],
317
+ },
318
+ { label: 'Main', items: [item('home')] },
319
+ ])
320
+
321
+ fireEvent.click(screen.getByLabelText('Reports'))
322
+ await waitFor(() => expect(menu()).not.toBeNull())
323
+
324
+ const menuRows = Array.from(document.querySelectorAll('.mantine-Menu-item'))
325
+ const disabled = menuRows.find((row) => row.textContent?.includes('Weekly'))
326
+ const enabled = menuRows.find((row) => row.textContent?.includes('Daily'))
327
+ if (!disabled || !enabled) throw new Error('both rows must render — rule 11 keeps them both')
328
+
329
+ fireEvent.click(disabled)
330
+ expect(disabledFired).toBe(0)
331
+
332
+ // The negative assertion above is only meaningful if the same click path works at all.
333
+ fireEvent.click(enabled)
334
+ expect(enabledFired).toBe(1)
335
+ })
336
+
337
+ /**
338
+ * A separator separates. A More slot raised purely by the account/settings rows has no
339
+ * destinations above the divider, so an unguarded one renders as the dropdown's FIRST child —
340
+ * a rule hanging under the top edge with nothing on the other side of it.
341
+ */
342
+ test('10. the More divider never renders as the dropdown first child', async () => {
343
+ const model = projectMobileNav(
344
+ [
345
+ {
346
+ label: 'Main',
347
+ items: [item('home', { mobile: 'tab' }), item('activity', { mobile: 'tab' })],
348
+ },
349
+ ],
350
+ { extraMoreRows: 1 },
351
+ )
352
+ render(
353
+ <MantineProvider>
354
+ <MobileNav
355
+ model={model}
356
+ settingsMenuItems={[{ key: 'theme', label: 'Theme', onClick: () => {} }]}
357
+ />
358
+ </MantineProvider>,
359
+ )
360
+
361
+ fireEvent.click(screen.getByLabelText('More'))
362
+ await waitFor(() => expect(menu()).not.toBeNull())
363
+
364
+ const dropdown = menu()
365
+ if (!dropdown) throw new Error('unreachable')
366
+ expect(dropdown.querySelectorAll('.mantine-Menu-divider')).toHaveLength(0)
367
+ expect(dropdown.querySelectorAll('.mantine-Menu-item')).toHaveLength(1)
368
+ })
369
+ })