basalt-ui 1.29.2 → 1.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/MIGRATING.md +901 -366
- package/README.md +23 -2
- package/agent/rules/basalt-controls.md +34 -34
- package/agent/skills/basalt-app/SKILL.md +4 -2
- package/agent/templates/query-client.ts.tpl +18 -4
- package/configs/lefthook.yml +51 -19
- package/configs/oxlint-plugin.js +210 -84
- package/configs/oxlint-plugin.test.ts +182 -5
- package/configs/oxlint.json +4 -4
- package/dist/charts/index.d.ts +1 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.d.ts +5 -3
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.d.ts +4 -2
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +4 -2
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +4 -2
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +5 -2
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +4 -2
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +4 -2
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +4 -2
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.d.ts +4 -1
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +2 -2
- package/dist/charts/primitives/ChartFrame.d.ts +25 -4
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -16
- package/dist/charts/primitives/chart-frame-layout.d.ts +115 -6
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +46 -6
- package/dist/cli/check-theme.d.ts.map +1 -1
- package/dist/cli/check-theme.js +6 -0
- package/dist/cli/doctor.d.ts +10 -4
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +28 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +2 -1
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +29 -0
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +4 -15
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +8 -14
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +12 -2
- package/dist/dashboard/section.module.css +15 -7
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +7 -2
- package/dist/dashboard/stat-group.d.ts +8 -2
- package/dist/dashboard/stat-group.d.ts.map +1 -1
- package/dist/dashboard/stat-group.js +23 -15
- package/dist/dashboard/stat-group.module.css +85 -9
- package/dist/dashboard/stat-group.module.css.d.ts +1 -0
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-grid.js +4 -5
- package/dist/dashboard/widget-grid.module.css +64 -21
- package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +19 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +27 -4
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/shell/app-brand.module.css +12 -3
- package/dist/shell/app-header.module.css +49 -9
- package/dist/shell/app-main.module.css +24 -4
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -51
- package/dist/shell/app-mobile-nav.module.css +99 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.module.css +55 -2
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +3 -3
- package/dist/shell/mobile-nav-model.d.ts +26 -7
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +12 -3
- package/dist/shell/page-aside.module.css +8 -6
- package/dist/shell/page-bar.d.ts +13 -0
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +13 -3
- package/dist/shell/page-bar.module.css +20 -12
- package/dist/shell/page-title.module.css +3 -1
- package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +51 -7
- package/dist/tokens/palette.d.ts +98 -17
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +110 -18
- package/dist/tokens.css +17 -5
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/index.ts +1 -0
- package/src/charts/kinds/BandStrip.tsx +5 -3
- package/src/charts/kinds/Bars.tsx +4 -2
- package/src/charts/kinds/Donut.tsx +4 -2
- package/src/charts/kinds/DualPanel.tsx +4 -2
- package/src/charts/kinds/Heatmap.tsx +5 -2
- package/src/charts/kinds/MirroredBars.tsx +4 -2
- package/src/charts/kinds/MultiLine.tsx +4 -2
- package/src/charts/kinds/StackedArea.tsx +4 -2
- package/src/charts/kinds/ZonedLine.tsx +4 -2
- package/src/charts/primitives/CartesianChart.tsx +4 -1
- package/src/charts/primitives/ChartCard.tsx +12 -7
- package/src/charts/primitives/ChartFrame.tsx +51 -37
- package/src/charts/primitives/chart-frame-layout.ts +172 -13
- package/src/cli/check-theme.ts +13 -0
- package/src/cli/doctor.ts +49 -8
- package/src/cli/help.ts +5 -3
- package/src/cli/index.ts +49 -0
- package/src/cli/init.ts +6 -27
- package/src/cli/sync.ts +11 -22
- package/src/controls/actions.tsx +51 -0
- package/src/dashboard/section.module.css +15 -7
- package/src/dashboard/stat-card.tsx +7 -2
- package/src/dashboard/stat-group.module.css +85 -9
- package/src/dashboard/stat-group.module.css.d.ts +1 -0
- package/src/dashboard/stat-group.tsx +40 -20
- package/src/dashboard/widget-grid.module.css +64 -21
- package/src/dashboard/widget-grid.module.css.d.ts +1 -0
- package/src/dashboard/widget-grid.tsx +30 -12
- package/src/guard/index.ts +79 -11
- package/src/index.ts +9 -0
- package/src/shell/app-brand.module.css +12 -3
- package/src/shell/app-header.module.css +49 -9
- package/src/shell/app-main.module.css +24 -4
- package/src/shell/app-mobile-nav.module.css +99 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +225 -83
- package/src/shell/app-sidebar.module.css +55 -2
- package/src/shell/index.tsx +26 -6
- package/src/shell/mobile-nav-model.ts +37 -9
- package/src/shell/page-aside.module.css +8 -6
- package/src/shell/page-bar.module.css +20 -12
- package/src/shell/page-bar.tsx +39 -5
- package/src/shell/page-title.module.css +3 -1
- package/src/theme/use-basalt-spacing.ts +8 -6
- package/src/tokens/index.ts +90 -6
- package/src/tokens/palette.ts +111 -18
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* page used to cost a tap to open a sheet plus a tap to pick a row. An overlay now exists only
|
|
8
8
|
* where a slot genuinely holds more than one destination (the trailing "More" slot, or an opt-in
|
|
9
9
|
* group slot), and its surface is INFERRED from the row count rather than configured: a content-
|
|
10
|
-
* sized `Menu` that pops out of the tab up to `menuMax` rows, a bottom `Drawer` past it.
|
|
10
|
+
* sized `Menu` that pops out of the tab up to `menuMax` rows, a bottom `Drawer` past it. The menu
|
|
11
|
+
* is bounded by its own `max-height`, so `menuMax` decides taste, never whether it fits.
|
|
11
12
|
*
|
|
12
13
|
* This component paints a finished `MobileNavModel` and owns exactly one piece of state — which
|
|
13
14
|
* slot's surface is open. Every selection decision lives in `mobile-nav-model.ts`, which is pure.
|
|
@@ -45,8 +46,9 @@ import classes from './app-mobile-nav.module.css'
|
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Floating-ui middlewares for a tab menu. `flip: false` is correct, not lazy: flipping a
|
|
48
|
-
* bottom-anchored menu puts it UNDER the footer, off-screen
|
|
49
|
-
*
|
|
49
|
+
* bottom-anchored menu puts it UNDER the footer, off-screen. What guarantees it fits above the bar
|
|
50
|
+
* is `.menuDropdown`'s own `max-height` (see the CSS module) — NOT `menuMax`, which is only the
|
|
51
|
+
* menu-vs-sheet threshold. `shift.padding` is a viewport inset in px, not a layout spacing token.
|
|
50
52
|
*/
|
|
51
53
|
const MENU_MIDDLEWARES = { flip: false, shift: { padding: 8 } }
|
|
52
54
|
|
|
@@ -198,49 +200,36 @@ function settingsRows(items: SettingsMenuItem[] | undefined): ActionRow[] {
|
|
|
198
200
|
}))
|
|
199
201
|
}
|
|
200
202
|
|
|
201
|
-
/**
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The deepest element inside `root` that is CURRENTLY scrolled, or `null`.
|
|
205
|
+
*
|
|
206
|
+
* `common/scroll-parent.ts`'s `scrollParentOf` asks the opposite question — which ANCESTOR scrolls
|
|
207
|
+
* a given element — and this twin deliberately stays local rather than joining it there. That
|
|
208
|
+
* module is a shared vocabulary with several consumers and one meaning ("which box scrolls this");
|
|
209
|
+
* this is a scroll-to-top TARGET search with exactly one call site, a visibility rule and a
|
|
210
|
+
* deepest-wins tiebreak that only make sense for it. Per the consolidation doctrine a new shared
|
|
211
|
+
* export needs a named consumer outside the module that wanted it; this one does not have a second.
|
|
212
|
+
*
|
|
213
|
+
* Deepest wins because nested scrollers nest by specificity: a page scroller holding a thread pane
|
|
214
|
+
* means the pane is the surface the user is looking at. Visibility is part of "actually scrolled" —
|
|
215
|
+
* a hidden box keeps its `scrollTop` forever — but `getClientRects()` is only asked of the handful
|
|
216
|
+
* of nodes that pass the cheap `scrollTop` test, never of the whole tree.
|
|
217
|
+
*/
|
|
218
|
+
function deepestScrolledDescendant(root: ParentNode | null): HTMLElement | null {
|
|
219
|
+
if (root === null) return null
|
|
220
|
+
let best: HTMLElement | null = null
|
|
221
|
+
let bestDepth = -1
|
|
222
|
+
for (const node of root.querySelectorAll<HTMLElement>('*')) {
|
|
223
|
+
if (node.scrollTop <= 0) continue
|
|
224
|
+
if (node.getClientRects().length === 0) continue
|
|
225
|
+
let depth = 0
|
|
226
|
+
for (let parent = node.parentElement; parent !== null; parent = parent.parentElement) depth += 1
|
|
227
|
+
if (depth > bestDepth) {
|
|
228
|
+
best = node
|
|
229
|
+
bestDepth = depth
|
|
212
230
|
}
|
|
213
|
-
item.onClick?.(event)
|
|
214
|
-
}
|
|
215
|
-
const shared = {
|
|
216
|
-
className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
|
|
217
|
-
leftSection: item.icon,
|
|
218
|
-
disabled: Boolean(item.disabled),
|
|
219
|
-
// Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
|
|
220
|
-
// to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
|
|
221
|
-
'aria-disabled': item.disabled || undefined,
|
|
222
|
-
'aria-current': item.active ? ('page' as const) : undefined,
|
|
223
|
-
rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
|
|
224
|
-
onClick,
|
|
225
231
|
}
|
|
226
|
-
|
|
227
|
-
if (Anchor && !item.disabled) {
|
|
228
|
-
return (
|
|
229
|
-
<Menu.Item key={item.key} component={Anchor} {...shared}>
|
|
230
|
-
{item.label}
|
|
231
|
-
</Menu.Item>
|
|
232
|
-
)
|
|
233
|
-
}
|
|
234
|
-
return (
|
|
235
|
-
<Menu.Item
|
|
236
|
-
key={item.key}
|
|
237
|
-
component="a"
|
|
238
|
-
{...(item.href !== undefined && !item.disabled && { href: item.href })}
|
|
239
|
-
{...shared}
|
|
240
|
-
>
|
|
241
|
-
{item.label}
|
|
242
|
-
</Menu.Item>
|
|
243
|
-
)
|
|
232
|
+
return best
|
|
244
233
|
}
|
|
245
234
|
|
|
246
235
|
/** Depth-first: parent, then its children as indented rows — nesting survives to mobile. */
|
|
@@ -251,11 +240,12 @@ const rows = (
|
|
|
251
240
|
): ReactElement[] =>
|
|
252
241
|
items.flatMap((item) => [render(item, depth), ...rows(item.children ?? [], depth + 1, render)])
|
|
253
242
|
|
|
254
|
-
/** An account/settings row, in a `Menu` dropdown.
|
|
255
|
-
|
|
243
|
+
/** An account/settings row, in a `Menu` dropdown. `sectionStart` draws the tail's own top rule
|
|
244
|
+
* (see `tailRule` and `menuSlot`) when this is the first row of the unlabelled tail. */
|
|
245
|
+
const menuActionRow = (row: ActionRow, sectionStart = false) => (
|
|
256
246
|
<Menu.Item
|
|
257
247
|
key={row.key}
|
|
258
|
-
className={classes.menuItem}
|
|
248
|
+
className={sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem}
|
|
259
249
|
leftSection={row.icon}
|
|
260
250
|
rightSection={row.active ? <IconCheck /> : undefined}
|
|
261
251
|
{...(row.danger ? { color: 'red' } : {})}
|
|
@@ -296,20 +286,63 @@ export function MobileNav({
|
|
|
296
286
|
|
|
297
287
|
const close = () => setOpenKey(null)
|
|
298
288
|
|
|
289
|
+
/** Scrolls `target` only if it has somewhere to go. The return value is the whole point — see
|
|
290
|
+
* `scrollToTop`: "I asked it to scroll" and "it scrolled" are different facts. */
|
|
291
|
+
const scrollElementToTop = (target: Element | null | undefined): boolean => {
|
|
292
|
+
if (!target || target.scrollTop <= 0) return false
|
|
293
|
+
target.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' })
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
|
|
299
297
|
/**
|
|
300
298
|
* §2.5 — re-tapping the ACTIVE slot scrolls to top instead of pushing a redundant history entry.
|
|
301
299
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
300
|
+
* RETURNS WHETHER ANYTHING ACTUALLY SCROLLED, and that is the fix for the reported dead tap. The
|
|
301
|
+
* old version resolved `[data-basalt-scrollport]` (= `AppShell.Main`) and scrolled it
|
|
302
|
+
* unconditionally, while the caller had ALREADY suppressed the navigation. On any page whose
|
|
303
|
+
* real scroller is an INNER element — `/agent`'s thread panes, `/data`'s
|
|
304
|
+
* `Table.ScrollContainer`, `/content`'s article body — Main's own `scrollTop` is 0, so the scroll
|
|
305
|
+
* moved nothing and the navigation was gone too: a 100% dead tap, on exactly the pages that have
|
|
306
|
+
* the most to scroll.
|
|
307
|
+
*
|
|
308
|
+
* So the search widens and the answer is honest. The consumer's handle wins, then the declared
|
|
309
|
+
* scrollport, then the deepest inner scroller that is genuinely scrolled, then the document for a
|
|
310
|
+
* shell-less app. A `false` here means the caller must let the navigation through — a redundant
|
|
311
|
+
* same-route navigation is strictly better than silence.
|
|
305
312
|
*/
|
|
306
|
-
const scrollToTop = () => {
|
|
307
|
-
const
|
|
308
|
-
config?.getScrollElement?.() ??
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
const scrollToTop = (): boolean => {
|
|
314
|
+
const declared =
|
|
315
|
+
config?.getScrollElement?.() ?? document.querySelector(`[${SCROLLPORT_ATTRIBUTE}]`)
|
|
316
|
+
if (scrollElementToTop(declared)) return true
|
|
317
|
+
if (scrollElementToTop(deepestScrolledDescendant(declared ?? document.body))) return true
|
|
318
|
+
return scrollElementToTop(document.scrollingElement ?? document.documentElement)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* §2.5 + §2.9 in ONE place, because the three row builders used to disagree about what a tap on
|
|
323
|
+
* a row MEANS. `linkTab` implemented the active-retap rule and `menuRow`/`sheetRow` did not, so
|
|
324
|
+
* a row inside the More surface rendered `aria-current="page"` and then navigated to the route
|
|
325
|
+
* it was already on — the router no-ops, the surface closes, nothing moves. Since More is where
|
|
326
|
+
* most destinations live the moment the bar is full, that was the MORE common dead tap of the
|
|
327
|
+
* two. `sheetRow` was also missing the disabled guard `menuRow` carries.
|
|
328
|
+
*
|
|
329
|
+
* Order is load-bearing: the scroll is ATTEMPTED FIRST and the navigation is suppressed only if
|
|
330
|
+
* something really moved (`scrollToTop`'s doc). Returns `false` when the tap was swallowed by
|
|
331
|
+
* the disabled guard, so a caller owning a surface knows not to dismiss it.
|
|
332
|
+
*/
|
|
333
|
+
const onActivate = (item: SidebarItem, event: MouseEvent<HTMLElement>): boolean => {
|
|
334
|
+
// Mantine emits `data-disabled` as a STYLING hook and the rows are polymorphic
|
|
335
|
+
// (`component="a"`/the consumer's Anchor), so the native `disabled` attribute never applies —
|
|
336
|
+
// and `MenuItem` composes the CALLER's `onClick` before checking `data-disabled` itself. The
|
|
337
|
+
// CSS no longer sets `pointer-events: none` (a row that swallows a tap with no press state is
|
|
338
|
+
// indistinguishable from a broken one), which makes this the ONLY guard, on every path.
|
|
339
|
+
if (item.disabled) {
|
|
340
|
+
event.preventDefault()
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
if (item.active === true && scrollToTop()) event.preventDefault()
|
|
344
|
+
item.onClick?.(event)
|
|
345
|
+
return true
|
|
313
346
|
}
|
|
314
347
|
|
|
315
348
|
const extraRows = [...accountRows(account), ...settingsRows(settingsMenuItems)]
|
|
@@ -326,18 +359,38 @@ export function MobileNav({
|
|
|
326
359
|
)
|
|
327
360
|
const listBlocks = moreBlocks.filter((block): block is SidebarListBlock => block.kind === 'list')
|
|
328
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Whether the More surface's UNLABELLED tail (blocks + account + settings) opens its own band —
|
|
364
|
+
* the guard the deleted `Menu.Divider` used to carry inline. A separator separates: with no
|
|
365
|
+
* destination rows above it, a More slot raised purely by those rows would open a band under the
|
|
366
|
+
* dropdown's top edge with nothing on the other side of it.
|
|
367
|
+
*/
|
|
368
|
+
const tailRule = (slot: SurfaceSlot): boolean =>
|
|
369
|
+
moreBlocks.length + extraRows.length > 0 && slotHasRows(slot)
|
|
370
|
+
|
|
329
371
|
/** A list block raises its nested sheet; a progress block just fires its own handler. */
|
|
330
372
|
const activateBlock = (block: (typeof moreBlocks)[number]) => {
|
|
331
373
|
close()
|
|
332
374
|
if (block.kind === 'list') {
|
|
333
|
-
|
|
375
|
+
// ONE FRAME, deliberately. `close()` starts the Menu's `returnFocus` (it restores focus to
|
|
376
|
+
// the More tab) while the nested Drawer traps focus on mount — in the same handler the two
|
|
377
|
+
// race, and focus lands wherever the loser wrote last, which is how a tap on a block row
|
|
378
|
+
// could leave the sheet open with focus back on the bar behind it. Deferring the open by one
|
|
379
|
+
// rAF lets the return finish before the trap starts.
|
|
380
|
+
requestAnimationFrame(() => setOpenBlockKey(block.key))
|
|
334
381
|
return
|
|
335
382
|
}
|
|
336
383
|
block.onClick?.()
|
|
337
384
|
}
|
|
338
385
|
|
|
339
|
-
const menuBlockRow = (block: (typeof moreBlocks)[number]) => (
|
|
340
|
-
<Menu.Item
|
|
386
|
+
const menuBlockRow = (block: (typeof moreBlocks)[number], sectionStart = false) => (
|
|
387
|
+
<Menu.Item
|
|
388
|
+
key={block.key}
|
|
389
|
+
className={
|
|
390
|
+
sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem
|
|
391
|
+
}
|
|
392
|
+
onClick={() => activateBlock(block)}
|
|
393
|
+
>
|
|
341
394
|
{blockRowLabel(block)}
|
|
342
395
|
</Menu.Item>
|
|
343
396
|
)
|
|
@@ -411,17 +464,25 @@ export function MobileNav({
|
|
|
411
464
|
)
|
|
412
465
|
|
|
413
466
|
/**
|
|
414
|
-
* The trigger for a `menu`/`sheet` slot.
|
|
415
|
-
*
|
|
416
|
-
*
|
|
467
|
+
* The trigger for a `menu`/`sheet` slot.
|
|
468
|
+
*
|
|
469
|
+
* The two paths differ in who owns the ARIA, and the guard below is what keeps them apart. On
|
|
470
|
+
* the MENU path the button is a `Menu.Target` child, so Mantine clones
|
|
471
|
+
* `aria-haspopup`/`aria-expanded`/`aria-controls` onto it (`withRoles` default) and setting them
|
|
472
|
+
* here would double them up (§2.9). On the SHEET path there is no `Menu.Target` and no clone at
|
|
473
|
+
* all, so the trigger announced nothing — a button that opens a dialog and says so only to
|
|
474
|
+
* sighted users. `onClick` is present on exactly the sheet path, which is why it is the
|
|
475
|
+
* discriminator rather than a second parameter that could disagree with reality.
|
|
417
476
|
*/
|
|
418
|
-
const surfaceTab = (slot: SurfaceSlot, onClick?: () => void) => (
|
|
477
|
+
const surfaceTab = (slot: SurfaceSlot, onClick?: () => void, expanded?: boolean) => (
|
|
419
478
|
<UnstyledButton
|
|
420
479
|
className={classes.tab}
|
|
421
480
|
data-active={slot.active || undefined}
|
|
422
481
|
aria-current={slot.active ? 'page' : undefined}
|
|
423
482
|
aria-label={slot.label}
|
|
424
|
-
{...(onClick
|
|
483
|
+
{...(onClick
|
|
484
|
+
? { onClick, 'aria-haspopup': 'dialog' as const, 'aria-expanded': Boolean(expanded) }
|
|
485
|
+
: {})}
|
|
425
486
|
>
|
|
426
487
|
{tabInner(slot)}
|
|
427
488
|
</UnstyledButton>
|
|
@@ -429,14 +490,12 @@ export function MobileNav({
|
|
|
429
490
|
|
|
430
491
|
/** A `link` slot: the consumer's router anchor IS the tab, so preload/back/middle-click all work. */
|
|
431
492
|
const linkTab = (slot: LinkSlot) => {
|
|
493
|
+
// VERIFIED in @tanstack/react-router: `Link` composes the caller's handler FIRST and returns
|
|
494
|
+
// early when `defaultPrevented`, so `onActivate` suppresses navigation without touching
|
|
495
|
+
// preload. A link slot's `active` and its item's `active` are the same fact by construction —
|
|
496
|
+
// every path that builds one reads `isActiveDestination` (`mobile-nav-model.ts`).
|
|
432
497
|
const onClick = (e: MouseEvent<HTMLElement>) => {
|
|
433
|
-
|
|
434
|
-
// early when `defaultPrevented`, so this suppresses navigation without touching preload.
|
|
435
|
-
if (slot.active) {
|
|
436
|
-
e.preventDefault()
|
|
437
|
-
scrollToTop()
|
|
438
|
-
}
|
|
439
|
-
slot.item.onClick?.(e)
|
|
498
|
+
onActivate(slot.item, e)
|
|
440
499
|
}
|
|
441
500
|
const shared = {
|
|
442
501
|
className: classes.tab,
|
|
@@ -465,10 +524,53 @@ export function MobileNav({
|
|
|
465
524
|
)
|
|
466
525
|
}
|
|
467
526
|
|
|
527
|
+
/**
|
|
528
|
+
* One destination row, in a `Menu` dropdown. `depth` indents a nested child.
|
|
529
|
+
*
|
|
530
|
+
* Lives INSIDE the component (it used to be a module-level const) for one reason: it needs
|
|
531
|
+
* `onActivate`, which needs `scrollToTop`, which needs `config` and `reduceMotion`. Recreating
|
|
532
|
+
* it per render costs what `sheetRow` already costs and buys all three row builders one handler.
|
|
533
|
+
*/
|
|
534
|
+
const menuRow = (item: SidebarItem, depth: number): ReactElement => {
|
|
535
|
+
const shared = {
|
|
536
|
+
className: depth > 0 ? `${classes.menuItem} ${classes.menuItemNested}` : classes.menuItem,
|
|
537
|
+
leftSection: item.icon,
|
|
538
|
+
disabled: Boolean(item.disabled),
|
|
539
|
+
// Mantine emits `data-disabled` (a STYLING hook) and nothing else, so the ARIA state is ours
|
|
540
|
+
// to set — a disabled destination still renders (§2.3 rule 11), so it has to announce itself.
|
|
541
|
+
'aria-disabled': item.disabled || undefined,
|
|
542
|
+
'aria-current': item.active ? ('page' as const) : undefined,
|
|
543
|
+
rightSection: item.count ? <NavCountBadge count={item.count} /> : undefined,
|
|
544
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
545
|
+
onActivate(item, event)
|
|
546
|
+
},
|
|
547
|
+
}
|
|
548
|
+
const Anchor = item.Anchor
|
|
549
|
+
if (Anchor && !item.disabled) {
|
|
550
|
+
return (
|
|
551
|
+
<Menu.Item key={item.key} component={Anchor} {...shared}>
|
|
552
|
+
{item.label}
|
|
553
|
+
</Menu.Item>
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
return (
|
|
557
|
+
<Menu.Item
|
|
558
|
+
key={item.key}
|
|
559
|
+
component="a"
|
|
560
|
+
{...(item.href !== undefined && !item.disabled && { href: item.href })}
|
|
561
|
+
{...shared}
|
|
562
|
+
>
|
|
563
|
+
{item.label}
|
|
564
|
+
</Menu.Item>
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
|
|
468
568
|
/** One destination row, in the sheet. 44px minimum, per the touch-target floor. */
|
|
469
569
|
const sheetRow = (item: SidebarItem, depth: number): ReactElement => {
|
|
470
570
|
const onClick = (e: MouseEvent<HTMLElement>) => {
|
|
471
|
-
|
|
571
|
+
// The disabled guard `menuRow` always had, now shared — a dead row must not dismiss the
|
|
572
|
+
// sheet either, or the tap reads as "it did something" while going nowhere.
|
|
573
|
+
if (!onActivate(item, e)) return
|
|
472
574
|
close()
|
|
473
575
|
}
|
|
474
576
|
const shared = {
|
|
@@ -522,7 +624,12 @@ export function MobileNav({
|
|
|
522
624
|
onChange={(opened) => setOpenKey(opened ? slot.key : null)}
|
|
523
625
|
position={slot.isMore ? 'top-end' : 'top'}
|
|
524
626
|
offset={8}
|
|
525
|
-
|
|
627
|
+
// Only the More dropdown widens. `mobileNavMenuWidth` (232px) is sized for a section slot's
|
|
628
|
+
// short destination labels; More also carries block rows like `Awaiting action · 3` and
|
|
629
|
+
// account rows like `Billing & payment`, which wrap or clip at that width. A viewport-
|
|
630
|
+
// relative cap rather than a second token: it is a fraction of the phone it opens on, and it
|
|
631
|
+
// is the only surface in the bar whose contents are not the consumer's nav labels.
|
|
632
|
+
width={slot.isMore ? 'min(78vw, 300px)' : step.mobileNavMenuWidth}
|
|
526
633
|
withinPortal
|
|
527
634
|
// AppShell itself sits at z-index 100 (`getDefaultZIndex('app')`), so the menu must clear it.
|
|
528
635
|
zIndex={400}
|
|
@@ -550,25 +657,50 @@ export function MobileNav({
|
|
|
550
657
|
: []),
|
|
551
658
|
...rows(group.items, 0, menuRow),
|
|
552
659
|
])}
|
|
553
|
-
{/*
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
660
|
+
{/* The `Menu.Divider` node that used to sit here is gone: section rhythm is SPACE opened
|
|
661
|
+
by each group's own heading (`.menuDropdown .menuLabel:not(:first-child)` in the CSS
|
|
662
|
+
module, which explains why it is space and not a line — `theme/divider-law.test.ts`
|
|
663
|
+
bans a hairline under a heading in the chrome lane). That is what makes the popover
|
|
664
|
+
read as sections instead of as one long list, which is the structure the More surface
|
|
665
|
+
needed a whole Drawer to get. The unlabelled tail (blocks + account + settings) has no
|
|
666
|
+
heading to open its band, so its FIRST row carries `.menuSectionStart` instead. The old
|
|
667
|
+
guard survives verbatim in `tailRule`: a separator separates, so with nothing above it
|
|
668
|
+
— a More slot raised purely by those rows — no band opens at all. */}
|
|
669
|
+
{slot.isMore
|
|
670
|
+
? moreBlocks.map((block, index) => menuBlockRow(block, tailRule(slot) && index === 0))
|
|
671
|
+
: null}
|
|
672
|
+
{slot.isMore
|
|
673
|
+
? extraRows.map((row, index) =>
|
|
674
|
+
menuActionRow(row, tailRule(slot) && moreBlocks.length === 0 && index === 0),
|
|
675
|
+
)
|
|
676
|
+
: null}
|
|
561
677
|
</Menu.Dropdown>
|
|
562
678
|
</Menu>
|
|
563
679
|
)
|
|
564
680
|
|
|
565
681
|
const sheetSlot = (slot: SurfaceSlot) => (
|
|
566
682
|
<Fragment key={slot.key}>
|
|
567
|
-
{
|
|
683
|
+
{/* A real TOGGLE, not a setter. It used to be `() => setOpenKey(slot.key)`, so re-tapping an
|
|
684
|
+
open sheet's own tab did nothing at all — while the menu path, which gets Mantine's
|
|
685
|
+
disclosure behaviour for free, closed on the second tap. Same trigger, same gesture, two
|
|
686
|
+
different answers. */}
|
|
687
|
+
{surfaceTab(
|
|
688
|
+
slot,
|
|
689
|
+
() => setOpenKey(openKey === slot.key ? null : slot.key),
|
|
690
|
+
openKey === slot.key,
|
|
691
|
+
)}
|
|
568
692
|
<Drawer
|
|
569
693
|
opened={openKey === slot.key}
|
|
570
694
|
onClose={close}
|
|
571
695
|
position="bottom"
|
|
696
|
+
// The bar is inside `AppShell.Footer` at z-index 100 and Mantine's modal default is 200, so
|
|
697
|
+
// the Drawer's OVERLAY covered the bar. Combined with `lockScroll` and a 220ms exit, that
|
|
698
|
+
// overlay stayed hit-testable after `onClose`: with a sheet open, a tap on another tab
|
|
699
|
+
// dismissed the sheet and did nothing else, and a quick second tap was eaten by the exiting
|
|
700
|
+
// overlay. Matching the Menu's 400 puts the bar and its surfaces on one layer, and a
|
|
701
|
+
// shorter exit shrinks the window in which a closing overlay can still swallow a tap.
|
|
702
|
+
zIndex={400}
|
|
703
|
+
overlayProps={{ zIndex: 400 }}
|
|
572
704
|
// NO `size` prop — `size="auto"` used to be here, but it is a no-op on a bottom Drawer in
|
|
573
705
|
// Mantine 9.3.0 (see the `.sheet` rule in the CSS module for the full trap). The sheet's
|
|
574
706
|
// actual height/max-height comes entirely from that unlayered CSS rule, which overrides
|
|
@@ -589,6 +721,10 @@ export function MobileNav({
|
|
|
589
721
|
transitionProps={{
|
|
590
722
|
transition: 'slide-up',
|
|
591
723
|
duration: reduceMotion ? 0 : 220,
|
|
724
|
+
// Asymmetric on purpose: the entrance may take its time, the EXIT is a hit-test window.
|
|
725
|
+
// Mantine's default exit equals the duration, so a dismissed sheet left 220ms of live
|
|
726
|
+
// overlay over the bar — long enough to eat the next tab tap.
|
|
727
|
+
exitDuration: reduceMotion ? 0 : 120,
|
|
592
728
|
timingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)',
|
|
593
729
|
}}
|
|
594
730
|
>
|
|
@@ -636,6 +772,10 @@ export function MobileNav({
|
|
|
636
772
|
opened={openBlockKey === block.key}
|
|
637
773
|
onClose={() => setOpenBlockKey(null)}
|
|
638
774
|
position="bottom"
|
|
775
|
+
// Same layer as `sheetSlot`'s Drawer and the Menu — see that one's note. This sheet is
|
|
776
|
+
// reached FROM the More surface, so it inherits the same overlay-over-the-bar problem.
|
|
777
|
+
zIndex={400}
|
|
778
|
+
overlayProps={{ zIndex: 400 }}
|
|
639
779
|
padding="md"
|
|
640
780
|
title={block.label}
|
|
641
781
|
classNames={{
|
|
@@ -646,6 +786,8 @@ export function MobileNav({
|
|
|
646
786
|
transitionProps={{
|
|
647
787
|
transition: 'slide-up',
|
|
648
788
|
duration: reduceMotion ? 0 : 220,
|
|
789
|
+
// Same reasoning as `sheetSlot`'s Drawer — a long exit leaves a live overlay over the bar.
|
|
790
|
+
exitDuration: reduceMotion ? 0 : 120,
|
|
649
791
|
timingFunction: 'cubic-bezier(0.32, 0.72, 0, 1)',
|
|
650
792
|
}}
|
|
651
793
|
>
|
|
@@ -142,12 +142,50 @@
|
|
|
142
142
|
|
|
143
143
|
/* Re-apply the bled inset INSIDE the viewport, so rows keep their exact 10px/10px column while the
|
|
144
144
|
* bar floats clear of them in the gutter. Static padding, not Mantine's `offsetScrollbars`: the
|
|
145
|
-
* latter pads only while overflowing, which reflows the rows the moment a section expands.
|
|
145
|
+
* latter pads only while overflowing, which reflows the rows the moment a section expands.
|
|
146
|
+
*
|
|
147
|
+
* `padding-block-end` is the scroll END-STOP: with only the inline inset, an overflowing column
|
|
148
|
+
* cut its last row flush against the viewport edge, which reads as a label whose list is CLIPPED
|
|
149
|
+
* rather than as something that scrolls. One `region-gap` of air below the last row is the same
|
|
150
|
+
* number the search slot opens at the column's other end.
|
|
151
|
+
*
|
|
152
|
+
* The end-stop is necessary and NOT sufficient, and the mask below is the other half. MEASURED at
|
|
153
|
+
* 1512x945 with the playground's nav: `.navScroll` is `flex: 1; min-height: 0` between the search
|
|
154
|
+
* slot and a `flex-shrink: 0` footer, so any nav taller than the leftover column overflows — and
|
|
155
|
+
* the end-stop only pads the BOTTOM OF THE CONTENT, which the reader sees exactly once, at the
|
|
156
|
+
* scroll end. Everywhere else the viewport's own edge cuts whatever row happens to cross it, and
|
|
157
|
+
* `.footer`'s progress block starts one `region-gap` under that cut. Two hard edges 12px apart:
|
|
158
|
+
* the column reads BROKEN rather than scrolled. Nothing else says otherwise, because the
|
|
159
|
+
* ScrollArea is `type="hover"` — at rest there is no bar at all.
|
|
160
|
+
*
|
|
161
|
+
* So: a scroll fade on the viewport's bottom edge, and deliberately NOT the other candidate, a
|
|
162
|
+
* `--vx-divider` seam on `.footer`. A rule there would answer a question nobody asked — the footer
|
|
163
|
+
* boundary is already carried by its own `padding-top` and the account row's inset (see `.footer`
|
|
164
|
+
* below) — and it would answer the real one WRONG: a hairline drawn across a row that is severed
|
|
165
|
+
* mid-glyph makes the severing crisper, not legible. A fade is the only one of the two that says
|
|
166
|
+
* "there is more, and it moves". It also keeps this module out of `theme/divider-law.test.ts`'s
|
|
167
|
+
* inventory, which is the correct outcome and not a dodge: that ledger exists so every hairline is
|
|
168
|
+
* a line someone chose, and this one would have been chosen to stand in for a scrollbar.
|
|
169
|
+
*
|
|
170
|
+
* The ramp is one `region-gap` tall, which is what pairs it with the end-stop instead of fighting
|
|
171
|
+
* it: at the scroll END those 12px hold the end-stop's padding — empty — so the last row lands
|
|
172
|
+
* fully opaque, and a column that does not overflow fades nothing but its own trailing air. Only
|
|
173
|
+
* mid-scroll, where a real row crosses the edge, does the fade have anything to dim.
|
|
174
|
+
*
|
|
175
|
+
* `black`/`transparent` here are alpha, not color: a mask reads only the alpha channel, so these
|
|
176
|
+
* carry no design meaning and deliberately are NOT `--vx-*` tokens. Do not route them through the
|
|
177
|
+
* palette in a token sweep. */
|
|
146
178
|
.navViewport {
|
|
147
179
|
padding-inline-end: var(--vx-space-row-inset-x);
|
|
180
|
+
padding-block-end: var(--vx-space-sidebar-region-gap);
|
|
181
|
+
mask-image: linear-gradient(
|
|
182
|
+
to bottom,
|
|
183
|
+
black calc(100% - var(--vx-space-sidebar-region-gap)),
|
|
184
|
+
transparent
|
|
185
|
+
);
|
|
148
186
|
}
|
|
149
187
|
|
|
150
|
-
/* Sections separate by SPACING only —
|
|
188
|
+
/* Sections separate by SPACING only — 16px between sections, nothing above the first (the brand
|
|
151
189
|
* band's centred `min-height` already opens the region's air, and the optional search slot's own
|
|
152
190
|
* `padding-block` opens more when it renders). */
|
|
153
191
|
.nav > div + div {
|
|
@@ -156,9 +194,24 @@
|
|
|
156
194
|
|
|
157
195
|
/*
|
|
158
196
|
* Footer region — no border; the account row's own top padding supplies the separation.
|
|
197
|
+
*
|
|
198
|
+
* The region owns its own air on both axes, because the `Stack` wrapping it is `gap={0}`
|
|
199
|
+
* (`app-sidebar.tsx`) — the footer's rhythm is single-sourced HERE, so the whole answer is
|
|
200
|
+
* readable in one place rather than split between a prop and a stylesheet. Without it a
|
|
201
|
+
* bottom-placed block (a `kind: 'progress'` block defaults to `'bottom'`) stacked FLUSH on the
|
|
202
|
+
* settings and account rows and on the scroll region above, which is the "cramped" footer.
|
|
203
|
+
*
|
|
204
|
+
* `region-gap` (12) above the cluster, `stack-sm` (8) between its parts — deliberately tighter
|
|
205
|
+
* than the nav's `section-gap` (16): the footer is ONE pinned cluster whose rows belong together,
|
|
206
|
+
* not a list of peer sections.
|
|
159
207
|
*/
|
|
160
208
|
.footer {
|
|
161
209
|
flex-shrink: 0;
|
|
210
|
+
padding-top: var(--vx-space-sidebar-region-gap);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.footer > * + * {
|
|
214
|
+
margin-top: var(--vx-space-stack-sm);
|
|
162
215
|
}
|
|
163
216
|
|
|
164
217
|
/*
|
package/src/shell/index.tsx
CHANGED
|
@@ -291,10 +291,6 @@ function collapseStore(key: string): () => readonly [boolean, (next: boolean) =>
|
|
|
291
291
|
return store
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
// The shared inset for the shell padding and the header's inline padding, so the breadcrumb's
|
|
295
|
-
// left edge and the global actions' right edge land on the card column's edges.
|
|
296
|
-
const SHELL_INSET = 'sm' as const
|
|
297
|
-
|
|
298
294
|
/**
|
|
299
295
|
* The shell's two page-level regions are providers, and both wrap the frame rather than living
|
|
300
296
|
* inside it: `PageBarProvider` owns the header portal and the single-kebab claim, `AsideProvider`
|
|
@@ -334,6 +330,30 @@ function ShellFrame({
|
|
|
334
330
|
// (controls, the search trigger/avatar, nav labels) already track density, so a fixed literal
|
|
335
331
|
// container squeezes progressively worse as density rises.
|
|
336
332
|
const { step } = useBasaltSpacing()
|
|
333
|
+
// THE PAGE GUTTER, responsive — the shared inset for `AppShell`'s own padding and the header's
|
|
334
|
+
// inline padding, so the breadcrumb's left edge and the global actions' right edge land on the
|
|
335
|
+
// card column's edges. It used to be the literal `'sm'` spacing key, which is 13px at EVERY
|
|
336
|
+
// width: 13 on a 360px phone and 13 on a 2560px monitor, with no token behind it. Now it is the
|
|
337
|
+
// `appShellInset` / `appShellInsetMobile` pair (see their entries in `tokens/palette.ts`).
|
|
338
|
+
//
|
|
339
|
+
// On a 390px phone that is 8 a side instead of 13 — 10px more line width, and 10px less vertical
|
|
340
|
+
// chrome, since `padding` is Main's vertical inset too. From `sm` up it is 20, which is the x the
|
|
341
|
+
// sidebar's nav icon column already sits at, so the brand mark, the nav icons and the page's
|
|
342
|
+
// first column all start on one vertical line.
|
|
343
|
+
//
|
|
344
|
+
// VERIFIED against the installed Mantine 9.3 source, because both props take the object by a
|
|
345
|
+
// DIFFERENT road and both had to be checked:
|
|
346
|
+
// - `AppShell padding`: `AppShellMediaStyles/assign-padding-variables.mjs` puts `base` in the
|
|
347
|
+
// `:root` rule and every other key in `get-variables.mjs`'s MIN-width block
|
|
348
|
+
// (`(min-width: <breakpoint>)`), so the pair is mobile-first and needs no max-width twin.
|
|
349
|
+
// - `AppShell.Header px`: a Box style prop, `Box/style-props/parse-style-props.mjs` — same
|
|
350
|
+
// `{ base, … }` shape, same `(min-width: theme.breakpoints.sm)` query, emitted on a generated
|
|
351
|
+
// class. Both resolve `sm` from `theme.breakpoints`, so the two gutters step at one width.
|
|
352
|
+
// A NUMBER on either road is CONVERTED by Mantine's `rem()` — 8 becomes
|
|
353
|
+
// `calc(0.5rem * var(--mantine-scale))` — rather than looked up, which is exactly why these are
|
|
354
|
+
// numbers: a spacing KEY resolves to `var(--mantine-spacing-sm)` and hands the gutter back to
|
|
355
|
+
// Mantine's own scale, which is where the 13px came from.
|
|
356
|
+
const inset = { base: step.appShellInsetMobile, sm: step.appShellInset }
|
|
337
357
|
const aside = useAsideRegion()
|
|
338
358
|
const [storedCollapsed, setStoredCollapsed] = collapseStore(storageKey)()
|
|
339
359
|
// Controlled/uncontrolled seam (item 19): an explicit `collapsed` prop overrides the internal
|
|
@@ -410,7 +430,7 @@ function ShellFrame({
|
|
|
410
430
|
// A plain number, NOT a `calc(... + env(safe-area-inset-bottom))` string: Mantine's own
|
|
411
431
|
// `.footer` rule already adds the inset to both the height and the padding (§2.7).
|
|
412
432
|
footer={{ height: { base: step.mobileNavBarHeight, sm: 0 } }}
|
|
413
|
-
padding={
|
|
433
|
+
padding={inset}
|
|
414
434
|
// `mainClasses.shell` is not decoration — see its rule for why the root has to establish a
|
|
415
435
|
// block formatting context now that Main is offset with margins.
|
|
416
436
|
className={cx(mainClasses.shell, className)}
|
|
@@ -419,7 +439,7 @@ function ShellFrame({
|
|
|
419
439
|
{/* Region seams (docs/DESIGN-SPEC.md §5, §8 #12): Mantine's `[data-with-border]` painted in
|
|
420
440
|
* `--vx-divider` by the theme's `AppShell.extend({ vars })`. No shell module draws a region
|
|
421
441
|
* edge; never opt a section back out of its border here. */}
|
|
422
|
-
<AppShell.Header px={
|
|
442
|
+
<AppShell.Header px={inset}>
|
|
423
443
|
<div className={headerClasses.bar}>
|
|
424
444
|
{/* The header's LEADING ZONE — exactly `--app-shell-navbar-offset` wide, so its trailing
|
|
425
445
|
* edge lands on the sidebar|main seam and the breadcrumb after it starts on Main's own
|