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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +7 -0
- package/agent/rules/basalt-mantine.md +71 -6
- package/agent/rules/basalt-router.md +129 -30
- package/agent/rules/basalt-tokens.md +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/nav/types.d.ts +110 -0
- package/dist/nav/types.d.ts.map +1 -0
- package/dist/nav/types.js +0 -0
- package/dist/router-tanstack/index.d.ts +9 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +7 -0
- package/dist/router-tanstack/nav.d.ts +163 -0
- package/dist/router-tanstack/nav.d.ts.map +1 -0
- package/dist/router-tanstack/nav.js +38 -0
- package/dist/router-tanstack/nav.test.d.ts +2 -0
- package/dist/router-tanstack/nav.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-param-store.d.ts +19 -9
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.d.ts +30 -0
- package/dist/router-tanstack/use-nav.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.js +71 -0
- package/dist/shell/app-breadcrumbs.d.ts +7 -9
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +8 -4
- package/dist/shell/app-mobile-nav.d.ts +23 -24
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +290 -68
- package/dist/shell/app-mobile-nav.module.css +166 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +11 -2
- package/dist/shell/app-mobile-nav.test.d.ts +2 -0
- package/dist/shell/app-mobile-nav.test.d.ts.map +1 -0
- package/dist/shell/app-sidebar.d.ts +3 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +40 -174
- package/dist/shell/app-sidebar.module.css +3 -2
- package/dist/shell/app-sidebar.module.css.d.ts +0 -2
- package/dist/shell/index.d.ts +22 -65
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +38 -76
- package/dist/shell/mobile-nav-model.d.ts +41 -0
- package/dist/shell/mobile-nav-model.d.ts.map +1 -0
- package/dist/shell/mobile-nav-model.js +244 -0
- package/dist/shell/mobile-nav-model.test.d.ts +2 -0
- package/dist/shell/mobile-nav-model.test.d.ts.map +1 -0
- package/dist/shell/nav-count-badge.d.ts +11 -0
- package/dist/shell/nav-count-badge.d.ts.map +1 -0
- package/dist/shell/nav-count-badge.js +29 -0
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +8 -7
- package/dist/theme/nav-link.module.css +4 -2
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/palette.d.ts +15 -0
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +13 -0
- package/dist/tokens.css +2 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/index.ts +15 -5
- package/src/nav/types.ts +115 -0
- package/src/router-tanstack/index.ts +21 -2
- package/src/router-tanstack/nav.test.ts +111 -0
- package/src/router-tanstack/nav.ts +205 -0
- package/src/router-tanstack/search-param-store.ts +19 -9
- package/src/router-tanstack/use-nav.ts +146 -0
- package/src/shell/app-breadcrumbs.tsx +21 -19
- package/src/shell/app-mobile-nav.module.css +166 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +11 -2
- package/src/shell/app-mobile-nav.test.tsx +369 -0
- package/src/shell/app-mobile-nav.tsx +436 -102
- package/src/shell/app-sidebar.module.css +3 -2
- package/src/shell/app-sidebar.module.css.d.ts +0 -2
- package/src/shell/app-sidebar.test.tsx +6 -10
- package/src/shell/app-sidebar.tsx +49 -206
- package/src/shell/index.test.tsx +62 -7
- package/src/shell/index.tsx +105 -164
- package/src/shell/mobile-nav-model.test.ts +296 -0
- package/src/shell/mobile-nav-model.ts +389 -0
- package/src/shell/nav-count-badge.tsx +39 -0
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +19 -1
- package/src/theme/index.ts +8 -7
- package/src/theme/nav-link.module.css +4 -2
- package/src/theme/spacing.test.ts +11 -0
- package/src/tokens/density.test.ts +2 -0
- package/src/tokens/index.ts +8 -5
- 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
|
+
})
|