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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +14 -0
- package/agent/rules/basalt-charts.md +12 -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/agent/skills/basalt-design/SKILL.md +2 -2
- package/dist/charts/kinds/DualPanel.d.ts +7 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +10 -3
- package/dist/charts/primitives/CartesianChart.d.ts +17 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/ChartTooltip.d.ts +6 -1
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +2 -1
- package/dist/charts/series.d.ts +5 -0
- package/dist/charts/series.d.ts.map +1 -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/charts/kinds/DualPanel.test.tsx +81 -1
- package/src/charts/kinds/DualPanel.tsx +26 -4
- package/src/charts/primitives/CartesianChart.test.tsx +173 -1
- package/src/charts/primitives/CartesianChart.tsx +29 -5
- package/src/charts/primitives/ChartTooltip.test.tsx +22 -1
- package/src/charts/primitives/ChartTooltip.tsx +7 -1
- package/src/charts/series.ts +5 -0
- 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,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `projectMobileNav` — the slot-selection law, one test per numbered rule.
|
|
3
|
+
*
|
|
4
|
+
* This is the highest-value suite in the mobile-nav rewrite: every decision the bottom bar makes
|
|
5
|
+
* (which destinations get a slot, which slot navigates and which raises a surface, whether that
|
|
6
|
+
* surface is a menu or a sheet, what falls into More) is decided here and painted by
|
|
7
|
+
* `app-mobile-nav.tsx` without a single further choice. Pinning the projection therefore pins the
|
|
8
|
+
* interaction model itself, with no renderer, no DOM and no timing — a rule that regresses fails
|
|
9
|
+
* as a value comparison rather than as a flaky query against a portalled dropdown.
|
|
10
|
+
*
|
|
11
|
+
* Two regressions in particular are what this file exists to keep dead:
|
|
12
|
+
* - a slot that raises a full surface to show ONE row (§2.2 cardinality inference), and
|
|
13
|
+
* - a `disabled` destination that is silently dropped instead of rendered disabled (rule 11).
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test'
|
|
16
|
+
import { MOBILE_MORE_KEY, projectMobileNav } from './mobile-nav-model'
|
|
17
|
+
import type { ProjectMobileNavOptions } from './mobile-nav-model'
|
|
18
|
+
import type { MobileNavGroup, MobileNavModel, MobileNavSlot, SidebarItem } from '../nav/types'
|
|
19
|
+
|
|
20
|
+
// ── fixtures ─────────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** A destination. `icon: null` keeps the whole suite JSX-free — the model never reads the icon. */
|
|
23
|
+
function item(key: string, extra: Partial<SidebarItem> = {}): SidebarItem {
|
|
24
|
+
return { key, label: key.toUpperCase(), icon: null, ...extra }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `n` plain destinations named `d1…dn`, the shape every cardinality case counts rows against. */
|
|
28
|
+
function items(n: number, extra: Partial<SidebarItem> = {}): SidebarItem[] {
|
|
29
|
+
return Array.from({ length: n }, (_, i) => item(`d${i + 1}`, extra))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const keys = (model: MobileNavModel): string[] => model.slots.map((slot) => slot.key)
|
|
33
|
+
const kinds = (model: MobileNavModel): string[] => model.slots.map((slot) => slot.kind)
|
|
34
|
+
|
|
35
|
+
function slotAt(model: MobileNavModel, index: number): MobileNavSlot {
|
|
36
|
+
const slot = model.slots[index]
|
|
37
|
+
if (!slot) throw new Error(`expected a slot at index ${index}, got ${model.slots.length} slots`)
|
|
38
|
+
return slot
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The trailing More slot, asserted to exist and to be a surface (a `link` More is rule 10's
|
|
42
|
+
* collapse case and is asserted by key/kind directly where it is the point). */
|
|
43
|
+
function moreSlot(model: MobileNavModel): Extract<MobileNavSlot, { groups: MobileNavGroup[] }> {
|
|
44
|
+
const slot = model.slots.at(-1)
|
|
45
|
+
if (!slot || slot.kind === 'link') throw new Error('expected a menu/sheet More slot last')
|
|
46
|
+
expect(slot.key).toBe(MOBILE_MORE_KEY)
|
|
47
|
+
expect(slot.isMore).toBe(true)
|
|
48
|
+
return slot
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every destination key a surface slot renders, depth-first — the reachability assertion. */
|
|
52
|
+
function rowKeys(groups: readonly MobileNavGroup[]): string[] {
|
|
53
|
+
const out: string[] = []
|
|
54
|
+
const walk = (list: readonly SidebarItem[]): void => {
|
|
55
|
+
for (const row of list) {
|
|
56
|
+
out.push(row.key)
|
|
57
|
+
walk(row.children ?? [])
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const group of groups) walk(group.items)
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Runs the projection with `console.warn` captured. The DEV warnings are part of the contract
|
|
66
|
+
* (rules 11 and 13 are "warn, then do the safe thing"), and swallowing them here also keeps the
|
|
67
|
+
* suite's output clean — the cases below deliberately trigger them.
|
|
68
|
+
*/
|
|
69
|
+
function projectQuietly(
|
|
70
|
+
sections: Parameters<typeof projectMobileNav>[0],
|
|
71
|
+
opts?: ProjectMobileNavOptions,
|
|
72
|
+
): { model: MobileNavModel; warnings: string[] } {
|
|
73
|
+
const warnings: string[] = []
|
|
74
|
+
const original = console.warn
|
|
75
|
+
console.warn = (...args: unknown[]) => {
|
|
76
|
+
warnings.push(args.map(String).join(' '))
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return { model: projectMobileNav(sections, opts), warnings }
|
|
80
|
+
} finally {
|
|
81
|
+
console.warn = original
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── the law ──────────────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe('projectMobileNav', () => {
|
|
88
|
+
/** Rule 6 — the bar must be usable with the consumer having configured nothing at all. */
|
|
89
|
+
test('1. zero-config fallback takes the first maxTabs-1 non-disabled top-level destinations', () => {
|
|
90
|
+
const model = projectMobileNav([
|
|
91
|
+
{
|
|
92
|
+
label: 'Main',
|
|
93
|
+
items: [
|
|
94
|
+
item('a'),
|
|
95
|
+
item('b', { disabled: true }),
|
|
96
|
+
item('c'),
|
|
97
|
+
item('d'),
|
|
98
|
+
item('e'),
|
|
99
|
+
item('f'),
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
// Four link slots (maxTabs 5 minus the More the leftovers force), the disabled one skipped.
|
|
105
|
+
expect(keys(model)).toEqual(['a', 'c', 'd', 'e', MOBILE_MORE_KEY])
|
|
106
|
+
expect(kinds(model)).toEqual(['link', 'link', 'link', 'link', 'menu'])
|
|
107
|
+
expect(rowKeys(moreSlot(model).groups)).toEqual(['b', 'f'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/** Rules 8-9 — nothing left over, no account, no settings: the bar fills all five slots. */
|
|
111
|
+
test('2. no overflow and no extra rows yields maxTabs slots and NO More', () => {
|
|
112
|
+
const model = projectMobileNav([{ label: 'Main', items: items(5, { mobile: 'tab' }) }])
|
|
113
|
+
|
|
114
|
+
expect(model.slots).toHaveLength(5)
|
|
115
|
+
expect(kinds(model)).toEqual(['link', 'link', 'link', 'link', 'link'])
|
|
116
|
+
expect(keys(model)).not.toContain(MOBILE_MORE_KEY)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
/** Rules 9 + 13 — the slice is what creates the overflow, and it says so in DEV. */
|
|
120
|
+
test('3. six tab requests become four slots plus More, with the last two in overflow', () => {
|
|
121
|
+
const { model, warnings } = projectQuietly([
|
|
122
|
+
{ label: 'Main', items: items(6, { mobile: 'tab' }) },
|
|
123
|
+
])
|
|
124
|
+
|
|
125
|
+
expect(keys(model)).toEqual(['d1', 'd2', 'd3', 'd4', MOBILE_MORE_KEY])
|
|
126
|
+
expect(rowKeys(moreSlot(model).groups)).toEqual(['d5', 'd6'])
|
|
127
|
+
expect(warnings.some((line) => line.includes('6 slot candidates for 4'))).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/** Rule 5 — the one named escape hatch, and it REPLACES the declarative pass rather than
|
|
131
|
+
* reordering its result: `a` asks for a tab and still does not get one. */
|
|
132
|
+
test('4. config.tabs replaces every item placement and preserves the given order', () => {
|
|
133
|
+
const model = projectMobileNav(
|
|
134
|
+
[
|
|
135
|
+
{
|
|
136
|
+
label: 'Main',
|
|
137
|
+
items: [item('a', { mobile: 'tab' }), item('b', { mobile: 'tab' }), item('c'), item('d')],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
{ config: { tabs: ['d', 'b'] } },
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(keys(model)).toEqual(['d', 'b', MOBILE_MORE_KEY])
|
|
144
|
+
expect(rowKeys(moreSlot(model).groups)).toEqual(['a', 'c'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/** §2.2 — a group of one IS a destination. This is the case that raises a full-viewport drawer
|
|
148
|
+
* for a single row today, and the reason surface inference is cardinality, not configuration. */
|
|
149
|
+
test('5. a mobile:{tab:true} section holding one destination collapses to a link slot', () => {
|
|
150
|
+
const model = projectMobileNav([
|
|
151
|
+
{ label: 'Main', items: [item('a')] },
|
|
152
|
+
{ label: 'Admin', mobile: { tab: true }, items: [item('settings')] },
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
const slot = slotAt(model, 0)
|
|
156
|
+
expect(slot.kind).toBe('link')
|
|
157
|
+
expect(slot.key).toBe('settings')
|
|
158
|
+
// The SLOT is what the consumer configured, so the section's label names it.
|
|
159
|
+
expect(slot.label).toBe('Admin')
|
|
160
|
+
if (slot.kind !== 'link') throw new Error('unreachable')
|
|
161
|
+
expect(slot.item.key).toBe('settings')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/** §2.2 — the same section, twice, differing only in how many rows it holds. */
|
|
165
|
+
test('6. a group slot of 4 destinations is a menu; of 9 it is a sheet', () => {
|
|
166
|
+
const small = projectMobileNav([{ label: 'Reports', mobile: { tab: true }, items: items(4) }])
|
|
167
|
+
const large = projectMobileNav([{ label: 'Reports', mobile: { tab: true }, items: items(9) }])
|
|
168
|
+
|
|
169
|
+
expect(slotAt(small, 0).kind).toBe('menu')
|
|
170
|
+
expect(slotAt(large, 0).kind).toBe('sheet')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/** The threshold is arithmetic (6 rows x 44px + 8px fits above a 56px bar on a 568px viewport),
|
|
174
|
+
* so the boundary itself is worth pinning — off-by-one here renders a menu below the fold. */
|
|
175
|
+
test('7. menuMax is inclusive: exactly menuMax rows is a menu, one more is a sheet', () => {
|
|
176
|
+
const atLimit = projectMobileNav([{ label: 'Reports', mobile: { tab: true }, items: items(6) }])
|
|
177
|
+
const overLimit = projectMobileNav([
|
|
178
|
+
{ label: 'Reports', mobile: { tab: true }, items: items(7) },
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
expect(slotAt(atLimit, 0).kind).toBe('menu')
|
|
182
|
+
expect(slotAt(overLimit, 0).kind).toBe('sheet')
|
|
183
|
+
|
|
184
|
+
// And the threshold is a knob, not a constant: lowering it moves the same six rows to a sheet.
|
|
185
|
+
const tightened = projectMobileNav(
|
|
186
|
+
[{ label: 'Reports', mobile: { tab: true }, items: items(6) }],
|
|
187
|
+
{ config: { menuMax: 5 } },
|
|
188
|
+
)
|
|
189
|
+
expect(slotAt(tightened, 0).kind).toBe('sheet')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rule 8 + §11 risk 6 — account and settings rows are only reachable from More now that the
|
|
194
|
+
* full-height sidebar drawer is gone, so they must count toward the menu-vs-sheet threshold.
|
|
195
|
+
* The model cannot see them (they are Mantine chrome, not `SidebarItem`s); the shell counts.
|
|
196
|
+
*/
|
|
197
|
+
test('8. extraMoreRows push a six-destination More over the threshold into a sheet', () => {
|
|
198
|
+
const sections = [{ label: 'Main', items: [item('a', { mobile: 'tab' }), ...items(6)] }]
|
|
199
|
+
|
|
200
|
+
expect(slotAt(projectMobileNav(sections), 1).kind).toBe('menu')
|
|
201
|
+
expect(slotAt(projectMobileNav(sections, { extraMoreRows: 1 }), 1).kind).toBe('sheet')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
/** Rule 1 — a section's `mobile: false` wins over every item inside it, everywhere. */
|
|
205
|
+
test('9. mobile:false on a section hides all its destinations from slots AND from overflow', () => {
|
|
206
|
+
const model = projectMobileNav([
|
|
207
|
+
{ label: 'Main', items: [item('a')] },
|
|
208
|
+
{
|
|
209
|
+
label: 'Internal',
|
|
210
|
+
mobile: false,
|
|
211
|
+
items: [item('secret'), item('debug', { mobile: 'tab' })],
|
|
212
|
+
},
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
expect(keys(model)).toEqual(['a'])
|
|
216
|
+
expect(JSON.stringify(model)).not.toContain('secret')
|
|
217
|
+
expect(JSON.stringify(model)).not.toContain('debug')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Rule 11 — THE REGRESSION THIS DESIGN FIXES. Today's projection drops `disabled` outright and
|
|
222
|
+
* ships a live row; the law is the opposite: never a tab (a tab that cannot be tapped is a dead
|
|
223
|
+
* 72px of bar), always a row, always rendered disabled.
|
|
224
|
+
*/
|
|
225
|
+
test('10. a disabled destination is never a link slot but survives into the More rows', () => {
|
|
226
|
+
const { model, warnings } = projectQuietly([
|
|
227
|
+
{
|
|
228
|
+
label: 'Main',
|
|
229
|
+
items: [item('a', { mobile: 'tab' }), item('x', { mobile: 'tab', disabled: true })],
|
|
230
|
+
},
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
expect(keys(model)).toEqual(['a', MOBILE_MORE_KEY])
|
|
234
|
+
const more = moreSlot(model)
|
|
235
|
+
expect(rowKeys(more.groups)).toEqual(['x'])
|
|
236
|
+
expect(more.groups[0]?.items[0]?.disabled).toBe(true)
|
|
237
|
+
expect(warnings.some((line) => line.includes('"x"') && line.includes('disabled'))).toBe(true)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/** Rule 2 — nesting is a lookup-order concern, not a rendering one: the tree reaches the rows
|
|
241
|
+
* intact, so a child no longer reads as a sibling of its own parent on mobile. */
|
|
242
|
+
test('11. nested children are preserved as a tree in the overflow rows, not flattened away', () => {
|
|
243
|
+
const model = projectMobileNav([
|
|
244
|
+
{
|
|
245
|
+
label: 'Main',
|
|
246
|
+
items: [
|
|
247
|
+
item('a', { mobile: 'tab' }),
|
|
248
|
+
item('reports', { children: [item('daily'), item('weekly')] }),
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
const more = moreSlot(model)
|
|
254
|
+
const reports = more.groups[0]?.items[0]
|
|
255
|
+
expect(reports?.key).toBe('reports')
|
|
256
|
+
expect(reports?.children?.map((child) => child.key)).toEqual(['daily', 'weekly'])
|
|
257
|
+
// Depth-first, parent before children — the order the renderer indents against.
|
|
258
|
+
expect(rowKeys(more.groups)).toEqual(['reports', 'daily', 'weekly'])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
/** Rule 12 — today's `items.some(i => i.active)` leaves the tab dark while a child route is
|
|
262
|
+
* open, which reads as "you are nowhere". A slot is active when ANY destination under it is. */
|
|
263
|
+
test('12. a slot is active when a NESTED child is the active destination', () => {
|
|
264
|
+
const model = projectMobileNav([
|
|
265
|
+
{
|
|
266
|
+
label: 'Main',
|
|
267
|
+
items: [
|
|
268
|
+
item('reports', { mobile: 'tab', children: [item('daily', { active: true })] }),
|
|
269
|
+
item('other', { mobile: 'tab' }),
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
expect(slotAt(model, 0).active).toBe(true)
|
|
275
|
+
expect(slotAt(model, 1).active).toBe(false)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
/** §2.4 — a five-slot bar is ~72px wide, so the bar label is `short` when the consumer supplied
|
|
279
|
+
* one and the full label otherwise. Both paths, one test. */
|
|
280
|
+
test('13. the slot label is `short`, falling back to `label`', () => {
|
|
281
|
+
const model = projectMobileNav([
|
|
282
|
+
{
|
|
283
|
+
label: 'Main',
|
|
284
|
+
items: [
|
|
285
|
+
item('dashboard', { mobile: 'tab', label: 'Dashboard', short: 'Dash' }),
|
|
286
|
+
item('activity', { mobile: 'tab', label: 'Activity' }),
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
])
|
|
290
|
+
|
|
291
|
+
expect(slotAt(model, 0).short).toBe('Dash')
|
|
292
|
+
// `label` stays the FULL text — it is the accessible name, which is never abbreviated.
|
|
293
|
+
expect(slotAt(model, 0).label).toBe('Dashboard')
|
|
294
|
+
expect(slotAt(model, 1).short).toBe('Activity')
|
|
295
|
+
})
|
|
296
|
+
})
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mobile-bar projection — `SidebarSection[]` + `MobileNavConfig` → `MobileNavModel`.
|
|
3
|
+
*
|
|
4
|
+
* PURE BY CONTRACT: no React, no Mantine, no DOM, no JSX. Everything the bottom bar decides —
|
|
5
|
+
* which destinations get a slot, which slot is a plain navigation link and which raises a surface,
|
|
6
|
+
* whether that surface is a menu or a sheet, what falls into the overflow "More" slot — is decided
|
|
7
|
+
* here and unit-testable with no renderer. `app-mobile-nav.tsx` only paints the result.
|
|
8
|
+
*
|
|
9
|
+
* The law it implements is the interaction model, not a heuristic: a slot is a DESTINATION, so a
|
|
10
|
+
* tap navigates with no overlay to dismiss. An overlay exists only where a slot genuinely holds
|
|
11
|
+
* more than one destination, and its surface is INFERRED from how many rows it holds rather than
|
|
12
|
+
* configured — six rows is the arithmetic ceiling for a menu that pops out of a 56px bar without
|
|
13
|
+
* ever rendering below the fold (6 x 44px + 8px padding = 272px against 415px of headroom on the
|
|
14
|
+
* smallest supported viewport), so past that the surface becomes a bottom sheet.
|
|
15
|
+
*/
|
|
16
|
+
import type {
|
|
17
|
+
MobileNavConfig,
|
|
18
|
+
MobileNavGroup,
|
|
19
|
+
MobileNavModel,
|
|
20
|
+
MobileNavSlot,
|
|
21
|
+
NavMobilePlacement,
|
|
22
|
+
SidebarItem,
|
|
23
|
+
SidebarSection,
|
|
24
|
+
} from '../nav/types'
|
|
25
|
+
|
|
26
|
+
/** Hard cap on bar slots, INCLUDING More. Five 72px slots is the widest a 360px viewport holds. */
|
|
27
|
+
export const MOBILE_MAX_TABS_DEFAULT = 5
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rows a slot may hold before it becomes a sheet instead of a menu. Arithmetic, not taste — see
|
|
31
|
+
* the module doc: six rows is the last count a bottom-anchored, non-flipping menu fits above the
|
|
32
|
+
* bar on the smallest supported viewport.
|
|
33
|
+
*/
|
|
34
|
+
export const MOBILE_MENU_MAX_DEFAULT = 6
|
|
35
|
+
|
|
36
|
+
/** The overflow slot's reserved key. A slot like any other — it just exists only when needed. */
|
|
37
|
+
export const MOBILE_MORE_KEY = '__more'
|
|
38
|
+
|
|
39
|
+
export type ProjectMobileNavOptions = {
|
|
40
|
+
config?: MobileNavConfig | undefined
|
|
41
|
+
/**
|
|
42
|
+
* Extra rows the More surface will hold — account + settings + `moreExtra`. Drives both
|
|
43
|
+
* `needsMore` and the menu-vs-sheet threshold. The model cannot see those rows (they are
|
|
44
|
+
* Mantine-rendered chrome, not `SidebarItem`s), so the shell counts them and passes the total.
|
|
45
|
+
*/
|
|
46
|
+
extraMoreRows?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEV = process.env['NODE_ENV'] !== 'production'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* §2.3 rule 1. A section's `mobile: false` wins over every item inside it; otherwise the item's own
|
|
53
|
+
* field decides, with the shorthand booleans normalized and `undefined` meaning "reachable, but not
|
|
54
|
+
* from the bar" — the default that makes a nav work with nothing configured.
|
|
55
|
+
*/
|
|
56
|
+
function placementOf(item: SidebarItem, sectionHidden: boolean): NavMobilePlacement {
|
|
57
|
+
if (sectionHidden) return 'hidden'
|
|
58
|
+
const mobile = item.mobile
|
|
59
|
+
if (mobile === undefined) return 'more'
|
|
60
|
+
if (mobile === true) return 'tab'
|
|
61
|
+
if (mobile === false) return 'hidden'
|
|
62
|
+
return mobile
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A section opts its whole subtree out of mobile with `mobile: false`. */
|
|
66
|
+
function isSectionHidden(section: SidebarSection): boolean {
|
|
67
|
+
return section.mobile === false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** §2.3 rule 12 — a slot is active when ANY of its destinations is, nested children included. */
|
|
71
|
+
function hasActiveDestination(item: SidebarItem): boolean {
|
|
72
|
+
if (item.active) return true
|
|
73
|
+
return item.children?.some(hasActiveDestination) ?? false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Rows a group tree will render. Nesting costs a row per node — children are indented rows. */
|
|
77
|
+
function countRows(items: readonly SidebarItem[]): number {
|
|
78
|
+
return items.reduce((total, item) => total + 1 + countRows(item.children ?? []), 0)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every destination key in a tree — the reachability set a slot contributes. */
|
|
82
|
+
function collectKeys(items: readonly SidebarItem[], out: Set<string>): void {
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
out.add(item.key)
|
|
85
|
+
collectKeys(item.children ?? [], out)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Depth-first flatten, parent before children — the order every candidate list and the zero-config
|
|
91
|
+
* fallback read. Nesting is NOT lost: this is the lookup/selection view, while the overflow and
|
|
92
|
+
* group rendering below keep the tree (today's projection flattens it away, which is why a nested
|
|
93
|
+
* destination reads as a sibling of its own parent on mobile).
|
|
94
|
+
*/
|
|
95
|
+
function flattenItems(items: readonly SidebarItem[], out: SidebarItem[]): void {
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
out.push(item)
|
|
98
|
+
flattenItems(item.children ?? [], out)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Drops every destination `keep` rejects, HOISTING a rejected parent's surviving children into its
|
|
104
|
+
* place rather than discarding the subtree — placement is normalized per destination (rule 1), so a
|
|
105
|
+
* visible child of a hidden parent is still a visible destination and must stay reachable.
|
|
106
|
+
*/
|
|
107
|
+
function pruneTree(
|
|
108
|
+
items: readonly SidebarItem[],
|
|
109
|
+
keep: (item: SidebarItem) => boolean,
|
|
110
|
+
): SidebarItem[] {
|
|
111
|
+
const out: SidebarItem[] = []
|
|
112
|
+
for (const item of items) {
|
|
113
|
+
const children = pruneTree(item.children ?? [], keep)
|
|
114
|
+
if (!keep(item)) {
|
|
115
|
+
out.push(...children)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
const next: SidebarItem = { ...item }
|
|
119
|
+
if (children.length > 0) next.children = children
|
|
120
|
+
else delete next.children
|
|
121
|
+
out.push(next)
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A slot candidate before the `maxTabs` slice decides which ones survive. */
|
|
127
|
+
type Candidate = {
|
|
128
|
+
/** What the candidate is CALLED — a destination key, or a section's label. Only rule 13's warn
|
|
129
|
+
* reads it, and that warn exists to name the slots that lost the slice. */
|
|
130
|
+
key: string
|
|
131
|
+
/** Destination keys this slot makes reachable — everything else falls to overflow (rule 7). */
|
|
132
|
+
covered: Set<string>
|
|
133
|
+
/** Built after the slice, since a dropped candidate never becomes a slot. */
|
|
134
|
+
toSlot: () => MobileNavSlot | undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A destination becomes a `link` slot: one tap, one navigation, nothing to dismiss. */
|
|
138
|
+
function itemCandidate(item: SidebarItem): Candidate {
|
|
139
|
+
return {
|
|
140
|
+
key: item.key,
|
|
141
|
+
covered: new Set([item.key]),
|
|
142
|
+
toSlot: () => ({
|
|
143
|
+
kind: 'link',
|
|
144
|
+
key: item.key,
|
|
145
|
+
label: item.label,
|
|
146
|
+
short: item.short ?? item.label,
|
|
147
|
+
icon: item.icon,
|
|
148
|
+
active: hasActiveDestination(item),
|
|
149
|
+
item,
|
|
150
|
+
}),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A SECTION becomes one slot (`mobile: { tab: true }`), holding its destinations. Cardinality picks
|
|
156
|
+
* the surface (§2.2): an empty section is not a slot at all, and a section of ONE destination IS
|
|
157
|
+
* that destination — collapsing to a `link` rather than raising a full surface for a single row,
|
|
158
|
+
* which is exactly what today's projection gets wrong.
|
|
159
|
+
*/
|
|
160
|
+
function sectionCandidate(section: SidebarSection, menuMax: number): Candidate {
|
|
161
|
+
const mobile =
|
|
162
|
+
section.mobile === false || section.mobile === undefined ? undefined : section.mobile
|
|
163
|
+
const items = pruneTree(section.items, (item) => placementOf(item, false) !== 'hidden')
|
|
164
|
+
const covered = new Set<string>()
|
|
165
|
+
collectKeys(items, covered)
|
|
166
|
+
const label = mobile?.label ?? section.label
|
|
167
|
+
const rows = countRows(items)
|
|
168
|
+
const only = items[0]
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
key: section.label,
|
|
172
|
+
covered,
|
|
173
|
+
toSlot: () => {
|
|
174
|
+
if (rows === 0) return undefined
|
|
175
|
+
if (rows === 1 && only !== undefined && !only.disabled) {
|
|
176
|
+
return {
|
|
177
|
+
kind: 'link',
|
|
178
|
+
key: only.key,
|
|
179
|
+
label,
|
|
180
|
+
short: label,
|
|
181
|
+
// The section's own identity wins when it declares one — the consumer configured the
|
|
182
|
+
// SLOT — and falls back to the single destination's icon when it does not.
|
|
183
|
+
icon: mobile?.icon ?? section.icon ?? only.icon,
|
|
184
|
+
active: hasActiveDestination(only),
|
|
185
|
+
item: only,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
kind: rows <= menuMax ? 'menu' : 'sheet',
|
|
190
|
+
key: section.label,
|
|
191
|
+
label,
|
|
192
|
+
short: label,
|
|
193
|
+
icon: mobile?.icon ?? section.icon,
|
|
194
|
+
active: items.some(hasActiveDestination),
|
|
195
|
+
// One section, so the rows need no group heading — the tab itself names them.
|
|
196
|
+
groups: [{ key: section.label, items }],
|
|
197
|
+
isMore: false,
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* §2.3 rules 3-6 — the candidate list, in bar order.
|
|
205
|
+
*
|
|
206
|
+
* `config.tabs` REPLACES the declarative pass entirely (rule 5): it is the one named escape hatch
|
|
207
|
+
* for "bar order != sidebar order", so it wins over every `item.mobile`. Otherwise sections are
|
|
208
|
+
* walked in declaration order, which is what interleaves section slots with item slots correctly.
|
|
209
|
+
*/
|
|
210
|
+
function buildCandidates(
|
|
211
|
+
sections: readonly SidebarSection[],
|
|
212
|
+
config: MobileNavConfig | undefined,
|
|
213
|
+
maxTabs: number,
|
|
214
|
+
menuMax: number,
|
|
215
|
+
): Candidate[] {
|
|
216
|
+
const flat: SidebarItem[] = []
|
|
217
|
+
for (const section of sections) {
|
|
218
|
+
if (isSectionHidden(section)) continue
|
|
219
|
+
flattenItems(section.items, flat)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Rule 11 — `mobile: 'tab'` on a disabled destination is a configuration mistake, not a slot. */
|
|
223
|
+
const tabbable = (item: SidebarItem): boolean => {
|
|
224
|
+
if (!item.disabled) return true
|
|
225
|
+
if (DEV) {
|
|
226
|
+
console.warn(
|
|
227
|
+
`[basalt] MobileNav: "${item.key}" asks for a bar slot but is disabled — a disabled destination never becomes a tab. It still renders, disabled, in the More surface.`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (config?.tabs !== undefined) {
|
|
234
|
+
const candidates: Candidate[] = []
|
|
235
|
+
for (const id of config.tabs) {
|
|
236
|
+
const item = flat.find((candidate) => candidate.key === id)
|
|
237
|
+
if (item !== undefined) {
|
|
238
|
+
if (tabbable(item)) candidates.push(itemCandidate(item))
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
// A section is named by its `label` — `SidebarSection` carries no key of its own.
|
|
242
|
+
const section = sections.find((s) => s.label === id && !isSectionHidden(s))
|
|
243
|
+
if (section !== undefined) {
|
|
244
|
+
candidates.push(sectionCandidate(section, menuMax))
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
if (DEV) {
|
|
248
|
+
console.warn(
|
|
249
|
+
`[basalt] MobileNav: mobileNav.tabs names "${id}", which is neither a destination key nor a visible section label — ignoring it.`,
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return candidates
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const candidates: Candidate[] = []
|
|
257
|
+
for (const section of sections) {
|
|
258
|
+
if (isSectionHidden(section)) continue
|
|
259
|
+
// Rule 3: a section slot consumes its destinations, so they never also compete as item slots.
|
|
260
|
+
if (section.mobile !== undefined && section.mobile !== false && section.mobile.tab === true) {
|
|
261
|
+
candidates.push(sectionCandidate(section, menuMax))
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
const items: SidebarItem[] = []
|
|
265
|
+
flattenItems(section.items, items)
|
|
266
|
+
for (const item of items) {
|
|
267
|
+
if (placementOf(item, false) !== 'tab') continue
|
|
268
|
+
if (tabbable(item)) candidates.push(itemCandidate(item))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (candidates.length > 0) return candidates
|
|
272
|
+
|
|
273
|
+
// Rule 6 — nobody configured anything. The bar still works: the first few top-level destinations
|
|
274
|
+
// become slots, so a consumer gets a usable nav for free and tunes it only if it is wrong.
|
|
275
|
+
const fallback: Candidate[] = []
|
|
276
|
+
for (const section of sections) {
|
|
277
|
+
if (isSectionHidden(section)) continue
|
|
278
|
+
for (const item of section.items) {
|
|
279
|
+
if (fallback.length >= maxTabs - 1) return fallback
|
|
280
|
+
if (item.disabled) continue
|
|
281
|
+
if (placementOf(item, false) === 'hidden') continue
|
|
282
|
+
fallback.push(itemCandidate(item))
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return fallback
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Rule 7 — the leftovers, grouped by source section, tree intact. */
|
|
289
|
+
function buildOverflow(
|
|
290
|
+
sections: readonly SidebarSection[],
|
|
291
|
+
covered: Set<string>,
|
|
292
|
+
): MobileNavGroup[] {
|
|
293
|
+
const groups: MobileNavGroup[] = []
|
|
294
|
+
for (const section of sections) {
|
|
295
|
+
const hidden = isSectionHidden(section)
|
|
296
|
+
if (hidden) continue
|
|
297
|
+
const items = pruneTree(
|
|
298
|
+
section.items,
|
|
299
|
+
(item) => placementOf(item, hidden) !== 'hidden' && !covered.has(item.key),
|
|
300
|
+
)
|
|
301
|
+
if (items.length === 0) continue
|
|
302
|
+
groups.push({ key: section.label, label: section.label, items })
|
|
303
|
+
}
|
|
304
|
+
return groups
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Projects `sections` onto the bar. Implements §2.3's thirteen rules in order, and is the ONLY
|
|
309
|
+
* place slot selection happens — `MobileNav` receives a finished model and paints it.
|
|
310
|
+
*/
|
|
311
|
+
export function projectMobileNav(
|
|
312
|
+
sections: SidebarSection[],
|
|
313
|
+
opts?: ProjectMobileNavOptions,
|
|
314
|
+
): MobileNavModel {
|
|
315
|
+
const config = opts?.config
|
|
316
|
+
const maxTabs = Math.max(1, config?.maxTabs ?? MOBILE_MAX_TABS_DEFAULT)
|
|
317
|
+
const menuMax = Math.max(1, config?.menuMax ?? MOBILE_MENU_MAX_DEFAULT)
|
|
318
|
+
const extraMoreRows = opts?.extraMoreRows ?? 0
|
|
319
|
+
|
|
320
|
+
const candidates = buildCandidates(sections, config, maxTabs, menuMax)
|
|
321
|
+
|
|
322
|
+
// Rules 8-9 are mutually recursive on paper — `needsMore` reads the overflow, the slice writes
|
|
323
|
+
// it. The fixpoint is one line: a candidate list longer than the bar ALWAYS needs More, because
|
|
324
|
+
// the slice is what pushes the remainder into overflow.
|
|
325
|
+
const coveredByAll = new Set<string>()
|
|
326
|
+
for (const candidate of candidates) for (const key of candidate.covered) coveredByAll.add(key)
|
|
327
|
+
const overflowIfNoneDropped = buildOverflow(sections, coveredByAll)
|
|
328
|
+
const needsMore =
|
|
329
|
+
countRows(overflowIfNoneDropped.flatMap((group) => group.items)) > 0 ||
|
|
330
|
+
extraMoreRows > 0 ||
|
|
331
|
+
candidates.length > maxTabs
|
|
332
|
+
|
|
333
|
+
const limit = needsMore ? maxTabs - 1 : maxTabs
|
|
334
|
+
const kept = candidates.slice(0, limit)
|
|
335
|
+
const dropped = candidates.slice(limit)
|
|
336
|
+
if (DEV && dropped.length > 0) {
|
|
337
|
+
// Rule 13 wants the NAMES, not a tally: "2 candidates dropped" tells a consumer their bar is
|
|
338
|
+
// wrong, while the keys tell them which `mobile: 'tab'` to move or which section to reorder.
|
|
339
|
+
console.warn(
|
|
340
|
+
`[basalt] MobileNav: ${candidates.length} slot candidates for ${limit} available slot(s) — the last ${dropped.length} moved into the More surface: ${dropped.map((candidate) => `"${candidate.key}"`).join(', ')}. Raise mobileNav.maxTabs or drop a mobile: 'tab'.`,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const covered = new Set<string>()
|
|
345
|
+
for (const candidate of kept) for (const key of candidate.covered) covered.add(key)
|
|
346
|
+
|
|
347
|
+
const slots: MobileNavSlot[] = []
|
|
348
|
+
for (const candidate of kept) {
|
|
349
|
+
const slot = candidate.toSlot()
|
|
350
|
+
if (slot !== undefined) slots.push(slot)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (!needsMore) return { slots }
|
|
354
|
+
|
|
355
|
+
// Rule 10 — the More slot, inferred over everything the bar could not reach.
|
|
356
|
+
const groups = buildOverflow(sections, covered)
|
|
357
|
+
const overflowItems = groups.flatMap((group) => group.items)
|
|
358
|
+
const rows = countRows(overflowItems) + extraMoreRows
|
|
359
|
+
if (rows === 0) return { slots }
|
|
360
|
+
|
|
361
|
+
const label = config?.moreLabel ?? 'More'
|
|
362
|
+
const only = overflowItems[0]
|
|
363
|
+
if (rows === 1 && extraMoreRows === 0 && only !== undefined && !only.disabled) {
|
|
364
|
+
// §2.2 again: a More surface holding one destination IS that destination.
|
|
365
|
+
slots.push({
|
|
366
|
+
kind: 'link',
|
|
367
|
+
key: only.key,
|
|
368
|
+
label: only.label,
|
|
369
|
+
short: only.short ?? only.label,
|
|
370
|
+
icon: only.icon,
|
|
371
|
+
active: hasActiveDestination(only),
|
|
372
|
+
item: only,
|
|
373
|
+
})
|
|
374
|
+
return { slots }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
slots.push({
|
|
378
|
+
kind: rows <= menuMax ? 'menu' : 'sheet',
|
|
379
|
+
key: MOBILE_MORE_KEY,
|
|
380
|
+
label,
|
|
381
|
+
short: label,
|
|
382
|
+
// No icon: the More glyph is chrome the renderer owns, and this module holds no JSX.
|
|
383
|
+
icon: undefined,
|
|
384
|
+
active: overflowItems.some(hasActiveDestination),
|
|
385
|
+
groups,
|
|
386
|
+
isMore: true,
|
|
387
|
+
})
|
|
388
|
+
return { slots }
|
|
389
|
+
}
|