basalt-ui 1.29.2 → 1.30.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/CHANGELOG.md +9 -0
- package/MIGRATING.md +693 -368
- 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/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +2 -2
- package/dist/charts/primitives/ChartFrame.d.ts +23 -3
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +4 -4
- package/dist/charts/primitives/chart-frame-layout.d.ts +55 -0
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +16 -0
- 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/primitives/ChartCard.tsx +12 -7
- package/src/charts/primitives/ChartFrame.tsx +30 -15
- package/src/charts/primitives/chart-frame-layout.ts +86 -0
- 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
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
align-items: stretch;
|
|
10
10
|
width: 100%;
|
|
11
11
|
height: 100%;
|
|
12
|
-
|
|
12
|
+
/* NOT `--mantine-color-body`: that is the EXACT fill the scrollport above paints (both resolve
|
|
13
|
+
* to `--vx-surface-bg`), so the only thing separating the bar from the page was the region
|
|
14
|
+
* seam's 9%-alpha hairline — invisible on a phone, which is the "it could also have a border"
|
|
15
|
+
* report. `--vx-surface-panel` is a derived step above bg on both schemes and is the same
|
|
16
|
+
* relation `.sheet` already uses, so the edge is a real tonal one with no new token, no drop
|
|
17
|
+
* shadow (DESIGN-CORE pins depth to three tokens, none of them an upward-cast footer shadow)
|
|
18
|
+
* and no `withBorder` on the shell region (basalt-mantine.md forbids drawing that ourselves). */
|
|
19
|
+
background-color: var(--vx-surface-panel);
|
|
13
20
|
-webkit-tap-highlight-color: transparent;
|
|
14
21
|
/* NO padding-bottom / env() here: Mantine's own AppShell `.footer` rule already does
|
|
15
22
|
* `height: calc(var(--app-shell-footer-height) + env(safe-area-inset-bottom))` +
|
|
@@ -90,7 +97,8 @@
|
|
|
90
97
|
height: 8px;
|
|
91
98
|
border-radius: 999px;
|
|
92
99
|
background-color: var(--vx-accent);
|
|
93
|
-
|
|
100
|
+
/* The halo punches the dot out of whatever the BAR paints, so it tracks `.bar`'s fill. */
|
|
101
|
+
box-shadow: 0 0 0 2px var(--vx-surface-panel);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
/* `text-overflow: clip`, never an ellipsis — a truncated word with a "…" in a 72px tab reads as
|
|
@@ -106,10 +114,25 @@
|
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/* The menu pops UP out of its tab and is configured `flip: false` — flipping a bottom-anchored
|
|
109
|
-
* menu would put it under the footer
|
|
117
|
+
* menu would put it under the footer, so something has to guarantee it fits. That something is the
|
|
118
|
+
* `max-height` below, not `menuMax` (§2.2 as it used to be written). */
|
|
110
119
|
.menuDropdown {
|
|
111
|
-
padding:
|
|
112
|
-
|
|
120
|
+
padding: var(--vx-space-stack-xs);
|
|
121
|
+
/* THIS is what guarantees the menu never renders below the fold — not `menuMax`, which is only
|
|
122
|
+
* the menu-vs-sheet taste threshold (`mobile-nav-model.ts`'s module doc has the accounting).
|
|
123
|
+
*
|
|
124
|
+
* `min(62dvh, 440px)`, down from `min(72dvh, 560px)`. The old pair fit — but fitting was never
|
|
125
|
+
* the whole ask: MEASURED in headless Chrome at 390x844 the dropdown rendered 300x560 at
|
|
126
|
+
* y=221..781, i.e. 66% of the screen, which reads as a panel rather than as the "subtle, small
|
|
127
|
+
* menu" the More surface is supposed to be. At 440 the same phone gets 52%.
|
|
128
|
+
*
|
|
129
|
+
* The accounting, at both ends of the supported range. 390x844: headroom above the bar is 772px,
|
|
130
|
+
* 62dvh resolves to 523px, so the 440px term binds — 57% of the headroom. 320x568: headroom is
|
|
131
|
+
* ~496px, 62dvh resolves to 352px, so THAT term binds — 71% of the headroom. Either way the
|
|
132
|
+
* binding term is one of the two written here and it lands under the headroom, which is the
|
|
133
|
+
* property that matters: the cap, not `menuMax`, is the guarantee. Past it the dropdown scrolls
|
|
134
|
+
* inside itself instead of growing, so a long More list costs scroll, never fold. */
|
|
135
|
+
max-height: min(62dvh, 440px);
|
|
113
136
|
overflow-y: auto;
|
|
114
137
|
overscroll-behavior: contain;
|
|
115
138
|
}
|
|
@@ -135,13 +158,16 @@
|
|
|
135
158
|
/*
|
|
136
159
|
* A disabled destination still RENDERS in the menu (§2.3 rule 11) — it just must not be reachable.
|
|
137
160
|
* Mantine's `Menu.css` styles `[data-disabled]` with a dimmed color and nothing else, and the row
|
|
138
|
-
* is polymorphic (`component="a"`), so the native `disabled` attribute does not apply either
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
161
|
+
* is polymorphic (`component="a"`), so the native `disabled` attribute does not apply either.
|
|
162
|
+
*
|
|
163
|
+
* `pointer-events: none` USED to be the guard here and is deliberately gone. It made the row
|
|
164
|
+
* swallow a tap with zero feedback — no `:active`, no press state, nothing — which on a phone is
|
|
165
|
+
* indistinguishable from a button that is simply broken, and "I tap and nothing happens" is
|
|
166
|
+
* literally the report this change answers. The guard is now entirely in the handlers (`menuRow`,
|
|
167
|
+
* `sheetRow`), which cover the pointer path and every path a pointer never takes; the row still
|
|
168
|
+
* depresses under a finger and still goes nowhere, which is the honest version of dead.
|
|
142
169
|
*/
|
|
143
170
|
.menuItem[data-disabled] {
|
|
144
|
-
pointer-events: none;
|
|
145
171
|
cursor: not-allowed;
|
|
146
172
|
}
|
|
147
173
|
|
|
@@ -151,7 +177,10 @@
|
|
|
151
177
|
padding-inline-start: calc(var(--mantine-spacing-md) + 12px) !important;
|
|
152
178
|
}
|
|
153
179
|
|
|
154
|
-
/* Overflow section heading
|
|
180
|
+
/* Overflow section heading, in the menu AND in the sheet — inherits the theme's mono micro-label.
|
|
181
|
+
* The inset matches the rows' own (`--vx-space-row-inset-x`) so the heading and the labels under
|
|
182
|
+
* it share one left edge; the vertical padding is asymmetric on purpose — a heading belongs to
|
|
183
|
+
* what follows it, so it sits closer to its own rows than to the section above. */
|
|
155
184
|
.menuLabel {
|
|
156
185
|
font-family: var(--basalt-font-mono);
|
|
157
186
|
font-size: var(--vx-text-micro);
|
|
@@ -159,6 +188,65 @@
|
|
|
159
188
|
text-transform: uppercase;
|
|
160
189
|
letter-spacing: 0.08em;
|
|
161
190
|
color: var(--vx-faint);
|
|
191
|
+
padding: 6px var(--vx-space-row-inset-x) 2px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
* The popover's SECTION RHYTHM — what replaced the single `Menu.Divider`, and it is SPACE, not a
|
|
196
|
+
* line.
|
|
197
|
+
*
|
|
198
|
+
* The Drawer's bulk was never its row height (40px is a WCAG 2.5.5 floor pinned by
|
|
199
|
+
* `theme/density-relations.test.ts`); it was the stack rhythm around the rows — a 12px section
|
|
200
|
+
* gap, 18px of Drawer padding and a header row. A popover with one hairline through the middle and
|
|
201
|
+
* no rhythm at all is the other extreme, so each group opens its own band instead.
|
|
202
|
+
*
|
|
203
|
+
* A `border-top: 1px solid var(--vx-divider)` here was the obvious move and is deliberately NOT
|
|
204
|
+
* what shipped: `theme/divider-law.test.ts` inventories every horizontal hairline in the package
|
|
205
|
+
* and classifies it as `between-rows`, `region-boundary` or (inside `content/**` only)
|
|
206
|
+
* `prose-typography` — and it says in as many words that a rule under a HEADING in the chrome lane
|
|
207
|
+
* is none of the three, and that the fix is to delete it and let the type scale and the space do
|
|
208
|
+
* the work. A mono uppercase micro-label at `--vx-faint` over its own band of air is already a
|
|
209
|
+
* section boundary; a line under it would be the second one.
|
|
210
|
+
*
|
|
211
|
+
* `:not(:first-child)` keeps the first group flush with the dropdown's own padding instead of
|
|
212
|
+
* opening a band under the top edge with nothing above it — the same "a separator separates"
|
|
213
|
+
* property the guarded `Menu.Divider` used to carry in JSX. Scoped to `.menuDropdown` because
|
|
214
|
+
* `.menuLabel` is also the SHEET's group heading (`sheetGroupLabel`), where each label is its
|
|
215
|
+
* group Stack's first child and the rhythm is the Drawer's own to draw.
|
|
216
|
+
*
|
|
217
|
+
* The band is 6/4, down from 8/8 — the second half of the same "still too big" verdict the
|
|
218
|
+
* `max-height` above answers, and the cheaper half. MEASURED per heading at 8/8: 8px margin + 8px
|
|
219
|
+
* padding-top + 2px padding-bottom + a 17px line = a 35px box, and there are FIVE of them in the
|
|
220
|
+
* shipped More surface — 175px, 28% of the whole dropdown spent on five words. 6/4 takes each
|
|
221
|
+
* banded heading to 29px and hands 24px back to the rows. It does NOT touch the row height (40px
|
|
222
|
+
* is the WCAG 2.5.5 floor named at the top of this comment) and it does not add the line the
|
|
223
|
+
* paragraph above rules out: this is the same space that argument asked to carry the boundary,
|
|
224
|
+
* spent tighter.
|
|
225
|
+
*/
|
|
226
|
+
.menuDropdown .menuLabel:not(:first-child) {
|
|
227
|
+
margin-top: 6px;
|
|
228
|
+
padding-top: 4px;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* The same band, on the first row of the UNLABELLED tail (blocks + account + settings) — the one
|
|
232
|
+
* boundary in the More surface with no heading to open it. Margin rather than padding: a row is a
|
|
233
|
+
* touch target with a `min-height` floor and symmetric inset, so growing one side of it would make
|
|
234
|
+
* that row read as a different size from its siblings. */
|
|
235
|
+
.menuDropdown .menuSectionStart {
|
|
236
|
+
margin-top: 8px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/*
|
|
240
|
+
* A sticky heading over a scrolling dropdown must be OPAQUE. `theme/index.ts` documents the same
|
|
241
|
+
* fix for the Drawer's header: basalt dropped that background to `transparent`, which reads fine
|
|
242
|
+
* until rows actually scroll under it and then shows every one of them straight through the title.
|
|
243
|
+
* `--vx-surface-overlay` is the dropdown's own fill, so the heading matches the box it sits in.
|
|
244
|
+
*/
|
|
245
|
+
.menuDropdown .menuLabel {
|
|
246
|
+
position: sticky;
|
|
247
|
+
top: 0;
|
|
248
|
+
z-index: 1;
|
|
249
|
+
background-color: var(--vx-surface-overlay);
|
|
162
250
|
}
|
|
163
251
|
|
|
164
252
|
/*
|
|
@@ -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
|
>
|