basalt-ui 1.31.0 → 1.32.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 +34 -0
- package/MIGRATING.md +212 -20
- package/agent/rules/basalt-mantine.md +4 -4
- package/configs/oxlint-plugin.js +240 -59
- package/configs/oxlint-plugin.test.ts +329 -1
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +18 -66
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +5 -26
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +10 -68
- package/dist/charts/kinds/band-chrome.d.ts +31 -0
- package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
- package/dist/charts/kinds/band-chrome.js +71 -0
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +49 -76
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +5 -23
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +23 -40
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +5 -133
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +16 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +24 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +17 -1
- package/dist/charts/primitives/chart-card-context.d.ts +17 -13
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -1
- package/dist/charts/primitives/chart-card-context.js +10 -5
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +25 -31
- package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
- package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
- package/dist/charts/primitives/legend-disclosure.js +153 -0
- package/dist/controls/controls.module.css +5 -0
- package/dist/controls/sync-button.module.css +1 -0
- package/dist/dashboard/stat-group.module.css +19 -64
- package/dist/dashboard/widget-grid.module.css +11 -38
- package/dist/data/data-table-state.d.ts +83 -0
- package/dist/data/data-table-state.d.ts.map +1 -0
- package/dist/data/data-table-state.js +139 -0
- package/dist/data/data-table.d.ts +40 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +179 -277
- package/dist/forms/form-layout.d.ts +6 -1
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.module.css +6 -13
- package/dist/guard/index.d.ts +2 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +10 -14
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +12 -10
- package/dist/shell/aside-docking.d.ts +31 -0
- package/dist/shell/aside-docking.d.ts.map +1 -0
- package/dist/shell/aside-docking.js +19 -0
- package/dist/shell/index.d.ts +0 -11
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +13 -14
- package/dist/shell/page-bar.d.ts +24 -0
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.d.ts +1 -1
- package/dist/theme/floating.module.css +1 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +2 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +4 -0
- package/llms.txt +2 -2
- package/package.json +2 -1
- package/src/charts/kinds/BandStrip.tsx +18 -64
- package/src/charts/kinds/DualPanel.tsx +4 -24
- package/src/charts/kinds/MirroredBars.tsx +12 -66
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/primitives/CartesianChart.tsx +79 -122
- package/src/charts/primitives/ChartCard.tsx +5 -27
- package/src/charts/primitives/ChartFrame.tsx +37 -76
- package/src/charts/primitives/ChartLegend.tsx +7 -154
- package/src/charts/primitives/ChartTooltip.tsx +16 -0
- package/src/charts/primitives/HoverOverlay.tsx +52 -21
- package/src/charts/primitives/chart-card-context.ts +25 -16
- package/src/charts/primitives/chart-frame-layout.ts +44 -56
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/controls/controls.module.css +5 -0
- package/src/controls/sync-button.module.css +1 -0
- package/src/dashboard/stat-group.module.css +19 -64
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.tsx +134 -218
- package/src/forms/form-layout.module.css +6 -13
- package/src/forms/form-layout.tsx +6 -1
- package/src/guard/index.ts +38 -34
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +18 -17
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +15 -37
- package/src/shell/page-bar.tsx +24 -0
- package/src/shell/use-breakpoint.ts +2 -2
- package/src/theme/floating.module.css +1 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
- package/src/tokens/index.ts +5 -0
package/src/guard/index.ts
CHANGED
|
@@ -152,7 +152,7 @@ const CSS_WIDTH_MEDIA_QUERY = new RegExp(
|
|
|
152
152
|
* An `@container` condition — the declared NAME (optional: `@container (min-width: …)` with no
|
|
153
153
|
* name is legal CSS, scoping to the nearest ancestor container regardless of its name) plus every
|
|
154
154
|
* raw boundary across the WHOLE at-rule, chained `and (...)` clauses included
|
|
155
|
-
* (`@container basalt-
|
|
155
|
+
* (`@container basalt-grid (min-width: 800px) and (max-width: 1199.9px)` —
|
|
156
156
|
* `stat-group.module.css` ships exactly this shape). Capturing only the first parenthesized clause
|
|
157
157
|
* would silently skip every boundary after the first `and`; group 2 here is the full run of
|
|
158
158
|
* `(...)`/`and` text so `CSS_CONTAINER_LITERAL`'s own `matchAll` below finds all of them.
|
|
@@ -177,30 +177,23 @@ const CSS_NON_COARSE_POINTER_QUERY =
|
|
|
177
177
|
* The `@container` names the container-class axis declares — a consumer container carrying any
|
|
178
178
|
* other name has invented its own container-query breakpoint instead of using the framework's.
|
|
179
179
|
* `basalt-card` is the one shipped today (`src/dashboard/section.module.css`);
|
|
180
|
-
* `basalt-form`/`basalt-
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* `basalt-
|
|
185
|
-
*
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
*/
|
|
189
|
-
const DECLARED_CONTAINER_NAMES = new Set([
|
|
190
|
-
'basalt-card',
|
|
191
|
-
'basalt-form',
|
|
192
|
-
'basalt-main',
|
|
193
|
-
'basalt-grid',
|
|
194
|
-
])
|
|
180
|
+
* `basalt-form`/`basalt-grid` are the declared remaining two, reserved here so a consumer adopting
|
|
181
|
+
* one ahead of the framework does not immediately trip this guard. `basalt-main` was a third until
|
|
182
|
+
* 1.32.1 dropped it: nothing ever declared it, so the guard accepted a query that never matched.
|
|
183
|
+
*
|
|
184
|
+
* `basalt-grid` is also what `StatGroup`/`WidgetGrid` declare (`src/dashboard/*.module.css`) —
|
|
185
|
+
* they shipped as `basalt-stat-group`/`basalt-widget-grid` until 1.32.0 moved them onto this table.
|
|
186
|
+
*/
|
|
187
|
+
const DECLARED_CONTAINER_NAMES = new Set(['basalt-card', 'basalt-form', 'basalt-grid'])
|
|
195
188
|
|
|
196
189
|
/**
|
|
197
190
|
* `CONTAINER_CLASSES` (`src/tokens/size-classes.ts`) boundaries, duplicated per the `CTL_THEME_TAGS`
|
|
198
191
|
* convention in the oxlint plugin — this module is dependency-free by contract (file header) and
|
|
199
192
|
* must not import `../tokens`. Pinned against the real table by `check-source.test.ts`. Judged only
|
|
200
|
-
* against `basalt-card
|
|
201
|
-
* the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate
|
|
202
|
-
* "scope this rule to the container, whatever its width" (`widget-header.module.css`'s
|
|
203
|
-
* — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
|
|
193
|
+
* against `basalt-card` and unnamed queries: the other declared names have no shipped numeric table
|
|
194
|
+
* yet. `0` is the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate
|
|
195
|
+
* idiom for "scope this rule to the container, whatever its width" (`widget-header.module.css`'s
|
|
196
|
+
* `cqi` clamp) — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
|
|
204
197
|
*/
|
|
205
198
|
const CONTAINER_CLASS_LITERALS_PX = new Set([0, 240, 239.9, 480, 479.9, 800, 799.9])
|
|
206
199
|
|
|
@@ -229,7 +222,14 @@ function isSanctionedShellMediaQuery(relPath: string, query: string): boolean {
|
|
|
229
222
|
// the comparison/chained spellings too, with no shape-specific regex of its own. All four units
|
|
230
223
|
// are captured (not `em` only) so a `px` width — never a `SIZE_CLASS_LITERALS` member — correctly
|
|
231
224
|
// fails the check below, rather than `.every()` vacuously passing on an empty match.
|
|
232
|
-
|
|
225
|
+
//
|
|
226
|
+
// But `query` is the whole at-rule prelude up to the width condition, so it can also carry a
|
|
227
|
+
// NON-width clause — the shell's own landscape rule is `(max-height: 480px) and (orientation:
|
|
228
|
+
// landscape) and (max-width: 52.49375em)`, and reading its `480px` height as an off-table width
|
|
229
|
+
// flagged the framework's own sanctioned implementation. Only the parenthesized clauses that name
|
|
230
|
+
// `width` are read.
|
|
231
|
+
const widthClauses = query.match(/\([^()]*\bwidth\b[^()]*\)/g) ?? []
|
|
232
|
+
const widths = widthClauses.flatMap((c) => c.match(new RegExp(CSS_WIDTH_NUM, 'g')) ?? [])
|
|
233
233
|
return widths.length > 0 && widths.every((w) => SIZE_CLASS_LITERALS.has(w))
|
|
234
234
|
}
|
|
235
235
|
|
|
@@ -687,9 +687,12 @@ const PROSE_CONTEXT_TAG = /<(?:Prose|ArticleLayout|Modal|Drawer)(?![\w])/
|
|
|
687
687
|
* read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a 12-line
|
|
688
688
|
* regex window has no import graph, so this lane matches by name and accepts the missed warn on a
|
|
689
689
|
* consumer's own `PanelRow` — the same direction every approximation in this scan already leans.
|
|
690
|
+
*
|
|
691
|
+
* The name set is the plugin's `CONTROL_HOST_TAGS` ∪ `BASALT_HOST_TAGS`; `oxlint-plugin.test.ts` ›
|
|
692
|
+
* lane parity asserts the two agree (1.32.1 — `FormRow`/`FormGroup` had drifted out of this lane).
|
|
690
693
|
*/
|
|
691
694
|
const CONTROL_HOST_TAG =
|
|
692
|
-
/<(?:SettingsRow|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
|
|
695
|
+
/<(?:SettingsRow|FormRow|FormGroup|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
|
|
693
696
|
|
|
694
697
|
/**
|
|
695
698
|
* How far ABOVE a control the host-tag window reaches, in lines.
|
|
@@ -860,8 +863,9 @@ export type GraceEntry = { since: string; promote: string; why: string }
|
|
|
860
863
|
export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
|
|
861
864
|
'raw-selection-control': {
|
|
862
865
|
since: '1.26.0',
|
|
863
|
-
promote: '1.
|
|
866
|
+
promote: '1.33.0',
|
|
864
867
|
why:
|
|
868
|
+
"Extended 1.32.0 -> 1.33.0 at the 1.32.0 release, the last extension: basalt's own tree is at 0 (consumer-loop r1 dogfood) and argo is at 2, but RE-MEASURED 2026-09-30 the fleet carries ~33 (image-gen 11, rb 7, image-share 6, weatherorb 5, email-gateway 4, argo 2). The 1.32.0 fleet upgrade clears them while they warn, together with raw-breakpoint, so 1.33.0 promotes all four ids against a measured-clean fleet. " +
|
|
865
869
|
'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
|
|
866
870
|
'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
|
|
867
871
|
'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
|
|
@@ -880,15 +884,15 @@ export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
|
|
|
880
884
|
},
|
|
881
885
|
'raw-media-query': {
|
|
882
886
|
since: '1.31.0',
|
|
883
|
-
promote: '1.
|
|
887
|
+
promote: '1.33.0',
|
|
884
888
|
why:
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
'
|
|
889
|
+
"The CSS-text twin of the oxlint plugin's `basalt/raw-breakpoint` — ONE LAW, TWO LANES, ONE " +
|
|
890
|
+
'PROMOTION, so it moves with that rule and never alone (`configs/oxlint-plugin.test.ts` › twin lanes share one promotion pins the pair). New in ' +
|
|
891
|
+
'the wave-11 responsive/touch guards; reaching CSS is a NEW file type for this check, same ' +
|
|
892
|
+
"reasoning as `css-raw-surface`, with incumbents in basalt's own tree at ship time, so it " +
|
|
893
|
+
'shipped warn. Extended 1.32.0 -> 1.33.0 with its twin: 1.31.0 named ' +
|
|
894
|
+
'no reachable container primitive, and 1.32.0 is the first minor that documents one ' +
|
|
895
|
+
'(`CONTAINER_CLASSES` + the declared `@container` names, MIGRATING § `basalt/raw-breakpoint`).',
|
|
892
896
|
},
|
|
893
897
|
}
|
|
894
898
|
|
|
@@ -2195,7 +2199,7 @@ export const GUARD_RULES = {
|
|
|
2195
2199
|
// JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
|
|
2196
2200
|
appliesTo: (relPath) => !relPath.endsWith('.css'),
|
|
2197
2201
|
message:
|
|
2198
|
-
'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
|
|
2202
|
+
'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, a FormRow / FormGroup, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
|
|
2199
2203
|
},
|
|
2200
2204
|
'inline-font-size': {
|
|
2201
2205
|
kind: 'inline-font-size',
|
|
@@ -2209,7 +2213,7 @@ export const GUARD_RULES = {
|
|
|
2209
2213
|
// CSS-only: a width @media, an @container name/literal or a non-coarse pointer query has no JSX
|
|
2210
2214
|
// spelling — the AST-side twin of this law is the oxlint plugin's basalt/raw-breakpoint.
|
|
2211
2215
|
appliesTo: (relPath) => relPath.endsWith('.css'),
|
|
2212
|
-
message: `Raw viewport/container breakpoint in CSS — a width @media belongs to shell/** against SIZE_CLASSES (useSizeClass() is the JS seam); an @container name must be one of ${[...DECLARED_CONTAINER_NAMES].join(' / ')} at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')}; and a pointer query other than (pointer: coarse) or (pointer: fine) has no framework meaning. Same law the oxlint plugin enforces as basalt/raw-breakpoint over JSX; this is the CSS half.`,
|
|
2216
|
+
message: `Raw viewport/container breakpoint in CSS — a width @media belongs to shell/** against SIZE_CLASSES (useSizeClass() is the JS seam); an @container name must be one of ${[...DECLARED_CONTAINER_NAMES].join(' / ')}, and a basalt-card or unnamed @container query must sit at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')} (the other declared names have no numeric table yet, so their literals are not judged); and a pointer query other than (pointer: coarse) or (pointer: fine) has no framework meaning. Same law the oxlint plugin enforces as basalt/raw-breakpoint over JSX; this is the CSS half.`,
|
|
2213
2217
|
},
|
|
2214
2218
|
} as const satisfies Record<GuardKind, GuardRule>
|
|
2215
2219
|
|
|
@@ -2476,7 +2480,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2476
2480
|
}
|
|
2477
2481
|
// Literal boundaries are judged against `basalt-card`'s own table (see
|
|
2478
2482
|
// `CONTAINER_CLASS_LITERALS_PX`'s doc) — an unnamed query has no OTHER table to judge
|
|
2479
|
-
// against, so it is judged against this one too; the other
|
|
2483
|
+
// against, so it is judged against this one too; the other declared names stay
|
|
2480
2484
|
// unjudged until they ship their own.
|
|
2481
2485
|
if (name !== '' && name !== 'basalt-card') continue
|
|
2482
2486
|
for (const lit of body.matchAll(CSS_CONTAINER_LITERAL)) {
|
|
@@ -205,6 +205,9 @@ export function NotificationCenter({ maxHeight = 320, className, style }: Notifi
|
|
|
205
205
|
</Group>
|
|
206
206
|
|
|
207
207
|
{/* View switch */}
|
|
208
|
+
{/* theme-allow control-outside-home — the center's own inbox/all view, rendered inside
|
|
209
|
+
`NotificationBell`'s Popover (an overlay, a declared non-home the AST cannot see across
|
|
210
|
+
the component boundary). */}
|
|
208
211
|
<SegmentedControl
|
|
209
212
|
fullWidth
|
|
210
213
|
size="xs"
|
package/src/provider/index.tsx
CHANGED
|
@@ -242,8 +242,13 @@ export function composeInjectedCss(
|
|
|
242
242
|
|
|
243
243
|
// ── Host attribute ────────────────────────────────────────────────────────────────────────────────
|
|
244
244
|
|
|
245
|
-
/**
|
|
246
|
-
|
|
245
|
+
/** A module-level mount count across every `BasaltProvider`; each call returns the new count. */
|
|
246
|
+
function createMountCounter(): { mount: () => number; unmount: () => number } {
|
|
247
|
+
let count = 0
|
|
248
|
+
return { mount: () => ++count, unmount: () => --count }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const hostAttributeMounts = createMountCounter()
|
|
247
252
|
|
|
248
253
|
/**
|
|
249
254
|
* `data-basalt-host` on `<html>` — the one place a `display-mode` query is read; CSS keys off it.
|
|
@@ -261,17 +266,16 @@ export function useHostAttribute(): void {
|
|
|
261
266
|
document.documentElement.setAttribute('data-basalt-host', isPwa ? 'pwa' : 'web')
|
|
262
267
|
}, [isPwa])
|
|
263
268
|
|
|
264
|
-
// Ref-counted across every mounted `BasaltProvider
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// effect keyed on `[isPwa]` would decrement (and possibly zero out) this counter on every
|
|
269
|
-
// `isPwa` change, not only on a genuine unmount.
|
|
269
|
+
// Ref-counted across every mounted `BasaltProvider`: a second provider (a nested route wrapping
|
|
270
|
+
// its own, two trees on one page) unmounting must not rip the attribute out from under a first
|
|
271
|
+
// one still mounted; only the LAST unmount removes it. Deliberately a SEPARATE effect from the one
|
|
272
|
+
// above: sharing one keyed on `[isPwa]` would decrement this counter on every `isPwa` change.
|
|
270
273
|
useIsomorphicLayoutEffect(() => {
|
|
271
|
-
|
|
274
|
+
hostAttributeMounts.mount()
|
|
272
275
|
return () => {
|
|
273
|
-
|
|
274
|
-
|
|
276
|
+
if (hostAttributeMounts.unmount() === 0) {
|
|
277
|
+
document.documentElement.removeAttribute('data-basalt-host')
|
|
278
|
+
}
|
|
275
279
|
}
|
|
276
280
|
}, [])
|
|
277
281
|
}
|
|
@@ -374,21 +378,18 @@ function BasaltBridge({
|
|
|
374
378
|
* misuse, not a false positive from React 18 StrictMode's double-invoke (that mounts/unmounts the
|
|
375
379
|
* SAME instance in sequence, so the count returns to 0 before a second instance could ever exist).
|
|
376
380
|
*/
|
|
377
|
-
|
|
381
|
+
const providerMounts = createMountCounter()
|
|
378
382
|
|
|
379
383
|
function useDuplicateProviderGuard(): void {
|
|
380
384
|
useEffect(() => {
|
|
381
|
-
|
|
382
|
-
if (mountedProviderCount > 1 && isDev()) {
|
|
385
|
+
if (providerMounts.mount() > 1 && isDev()) {
|
|
383
386
|
console.warn(
|
|
384
387
|
'[basalt] BasaltProvider: more than one instance is mounted at once — nested/duplicate ' +
|
|
385
388
|
'BasaltProviders double-mount MantineProvider, the palette <style> and ' +
|
|
386
389
|
'ConnectivityProvider. Mount exactly one, at the app root.',
|
|
387
390
|
)
|
|
388
391
|
}
|
|
389
|
-
return () =>
|
|
390
|
-
mountedProviderCount--
|
|
391
|
-
}
|
|
392
|
+
return () => void providerMounts.unmount()
|
|
392
393
|
}, [])
|
|
393
394
|
}
|
|
394
395
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aside docking (`docs/DESIGN-CORE.md` § Layout, elevation, shapes) — the pure decision `ShellFrame`
|
|
3
|
+
* sizes `AppShell.Aside` from. An open aside pushes main only in `expanded` while main keeps
|
|
4
|
+
* `MAIN_MIN_WIDTH` beside the navbar; otherwise the region reserves just its rail and an open panel
|
|
5
|
+
* overlays main. Unclaimed, the region is zero-wide.
|
|
6
|
+
*/
|
|
7
|
+
import { toEm } from '../tokens/size-classes'
|
|
8
|
+
import type { SizeClass } from './use-size-class'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Main keeps at least this much width, or an open aside overlays it instead of docking. Not
|
|
12
|
+
* derived from a token: it is the readable floor for a page body, independent of density.
|
|
13
|
+
*/
|
|
14
|
+
const MAIN_MIN_WIDTH = 720
|
|
15
|
+
|
|
16
|
+
export type AsideDockingInput = {
|
|
17
|
+
claimed: boolean
|
|
18
|
+
folded: boolean
|
|
19
|
+
sizeClass: SizeClass
|
|
20
|
+
/** The `asideDockQuery` media query's result. */
|
|
21
|
+
roomToDock: boolean
|
|
22
|
+
asideWidth: number
|
|
23
|
+
asideRailWidth: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AsideDocking = {
|
|
27
|
+
/** An open aside pushes main rather than overlaying it. */
|
|
28
|
+
docks: boolean
|
|
29
|
+
/** An open aside renders over main (reserving only its rail). */
|
|
30
|
+
overlay: boolean
|
|
31
|
+
/** The width `AppShell` reserves for the region. */
|
|
32
|
+
width: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The `min-width` query that holds while navbar + open aside + `MAIN_MIN_WIDTH` fit side by side. */
|
|
36
|
+
export function asideDockQuery(input: { navbarWidth: number; asideWidth: number }): string {
|
|
37
|
+
return `(min-width: ${toEm(input.navbarWidth + input.asideWidth + MAIN_MIN_WIDTH)})`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The room-to-dock query's SSR/no-`matchMedia` fallback — an `'expanded'` `sizeClassHint` means the
|
|
42
|
+
* server already believes there is room, matching `useSizeClass`'s own hint-seeded guard
|
|
43
|
+
* (`use-size-class.ts`), so the first client paint doesn't fold a default-open aside for one commit.
|
|
44
|
+
*/
|
|
45
|
+
export function roomToDockServerFallback(sizeClassHint: SizeClass | undefined): boolean {
|
|
46
|
+
return sizeClassHint === 'expanded'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveAsideDocking(input: AsideDockingInput): AsideDocking {
|
|
50
|
+
const docks = input.sizeClass === 'expanded' && input.roomToDock
|
|
51
|
+
const open = input.claimed && !input.folded
|
|
52
|
+
const reserved = open && docks ? input.asideWidth : input.asideRailWidth
|
|
53
|
+
return { docks, overlay: open && !docks, width: input.claimed ? reserved : 0 }
|
|
54
|
+
}
|
package/src/shell/index.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import { blockRowCount, projectMobileNav } from './mobile-nav-model'
|
|
|
34
34
|
import { AppBreadcrumbs } from './app-breadcrumbs'
|
|
35
35
|
import { PageBarBandOutlet, PageBarOutlet, PageBarProvider, usePageKebabClaimed } from './page-bar'
|
|
36
36
|
import { AsideDocksContext, AsideOutlet, AsideProvider, useAsideRegion } from './page-aside'
|
|
37
|
+
import { asideDockQuery, resolveAsideDocking, roomToDockServerFallback } from './aside-docking'
|
|
37
38
|
import { OverflowMenu, globalActionAsBarAction, globalActionMobile } from '../controls/actions'
|
|
38
39
|
import type { GlobalAction } from '../controls/actions'
|
|
39
40
|
import type { AccountMenuItem, BasaltAccountProps } from './account-types'
|
|
@@ -47,9 +48,7 @@ import type {
|
|
|
47
48
|
} from './nav-types'
|
|
48
49
|
import { CtlSlot, useBasaltSpacing } from '../theme'
|
|
49
50
|
import { createPersistedStore } from '../state/persisted'
|
|
50
|
-
import { toEm } from '../tokens/size-classes'
|
|
51
51
|
import { SizeClassHintContext, useSizeClass } from './use-size-class'
|
|
52
|
-
import type { SizeClass } from './use-size-class'
|
|
53
52
|
import brandClasses from './app-brand.module.css'
|
|
54
53
|
import headerClasses from './app-header.module.css'
|
|
55
54
|
import mainClasses from './app-main.module.css'
|
|
@@ -303,25 +302,6 @@ function collapseStore(key: string): CollapseStore {
|
|
|
303
302
|
return store
|
|
304
303
|
}
|
|
305
304
|
|
|
306
|
-
/**
|
|
307
|
-
* Main keeps at least this much width, or an open aside overlays it instead of docking. Not
|
|
308
|
-
* derived from a token: it is the readable floor for a page body, independent of density.
|
|
309
|
-
*/
|
|
310
|
-
const MAIN_MIN_WIDTH = 720
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* The room-to-dock `min-width` query's SSR/no-`matchMedia` fallback — an `'expanded'`
|
|
314
|
-
* `sizeClassHint` means the server already believes there is room, matching `useSizeClass`'s own
|
|
315
|
-
* hint-seeded guard one level up (`use-size-class.ts`). Extracted as its own pure function (rather
|
|
316
|
-
* than the inline `sizeClassHint === 'expanded'` it replaces) purely so `index.test.tsx` can pin it
|
|
317
|
-
* without needing to render `BasaltShell` through a real SSR pass — `AppShell`'s + `PageAside`'s own
|
|
318
|
-
* effects/refs/portals make that path unobservable through rendered markup, and the previous
|
|
319
|
-
* hardcoded `false` fallback here shipped with no test at all for exactly that reason.
|
|
320
|
-
*/
|
|
321
|
-
export function roomToDockServerFallback(sizeClassHint: SizeClass | undefined): boolean {
|
|
322
|
-
return sizeClassHint === 'expanded'
|
|
323
|
-
}
|
|
324
|
-
|
|
325
305
|
/**
|
|
326
306
|
* The shell's two page-level regions are providers, and both wrap the frame rather than living
|
|
327
307
|
* inside it: `PageBarProvider` owns the header portal and the single-kebab claim, `AsideProvider`
|
|
@@ -395,25 +375,23 @@ function ShellFrame({
|
|
|
395
375
|
const isCollapseControlled = collapsedProp !== undefined
|
|
396
376
|
const uncontrolledCollapsed = collapseIsSet ? storedCollapsed : sizeClass === 'medium'
|
|
397
377
|
const collapsed = isCollapseControlled ? collapsedProp : uncontrolledCollapsed
|
|
398
|
-
// Aside docking (`
|
|
399
|
-
// `
|
|
400
|
-
// just its rail and an open panel overlays main. Provided through `AsideDocksContext` in the SAME
|
|
401
|
-
// render, so `PageAside` defaults its fold from the right value on its first pass.
|
|
378
|
+
// Aside docking (`aside-docking.ts`), provided through `AsideDocksContext` in the SAME render so
|
|
379
|
+
// `PageAside` defaults its fold from the right value on its first pass.
|
|
402
380
|
const navbarWidth = collapsed ? step.appShellNavbarRailWidth : step.appShellNavbarWidth
|
|
403
|
-
// The SSR/first-paint fallback mirrors `useSizeClass`'s own hint-seeded guard: an `expanded` hint
|
|
404
|
-
// from `<BasaltProvider sizeClassHint>` means the server already believes there is room to dock, so
|
|
405
|
-
// the first client paint agrees instead of defaulting to "cannot dock" and folding a default-open
|
|
406
|
-
// aside for one commit.
|
|
407
381
|
const sizeClassHint = useContext(SizeClassHintContext)
|
|
408
382
|
const roomToDock = useMediaQuery(
|
|
409
|
-
|
|
383
|
+
asideDockQuery({ navbarWidth, asideWidth: step.appShellAsideWidth }),
|
|
410
384
|
roomToDockServerFallback(sizeClassHint),
|
|
411
385
|
)
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
386
|
+
const docking = resolveAsideDocking({
|
|
387
|
+
claimed: aside.claimed,
|
|
388
|
+
folded: aside.folded,
|
|
389
|
+
sizeClass,
|
|
390
|
+
roomToDock,
|
|
391
|
+
asideWidth: step.appShellAsideWidth,
|
|
392
|
+
asideRailWidth: step.appShellAsideRailWidth,
|
|
393
|
+
})
|
|
394
|
+
const asideOverlay = docking.overlay
|
|
417
395
|
// Keyboard escape hatch for the overlay form only — the docked form never blocks content, so
|
|
418
396
|
// there is nothing to escape out of. `requestClose` is a stable callback from `AsideProvider`
|
|
419
397
|
// (destructured, not read as `aside.requestClose`, so the effect deps name it directly rather
|
|
@@ -460,7 +438,7 @@ function ShellFrame({
|
|
|
460
438
|
)
|
|
461
439
|
|
|
462
440
|
return (
|
|
463
|
-
<AsideDocksContext.Provider value={docks}>
|
|
441
|
+
<AsideDocksContext.Provider value={docking.docks}>
|
|
464
442
|
<AppShell
|
|
465
443
|
h="100dvh"
|
|
466
444
|
// Mantine's DEFAULT layout, not `alt`: the header spans the full viewport width on top and
|
|
@@ -484,7 +462,7 @@ function ShellFrame({
|
|
|
484
462
|
// prop for it on purpose; the ROUTE decides, the same way it decides its page bar.
|
|
485
463
|
aside={{
|
|
486
464
|
// An overlay reserves the rail only — `<AppShell.Aside>` below widens itself over main.
|
|
487
|
-
width:
|
|
465
|
+
width: docking.width,
|
|
488
466
|
breakpoint: 'sm',
|
|
489
467
|
// Below `sm` there is no region at all — `PageAside` renders its content in the page
|
|
490
468
|
// flow instead, one node, no responsive twin (law C9).
|
package/src/shell/page-bar.tsx
CHANGED
|
@@ -346,6 +346,30 @@ function useMeasuredHeightVar(active: boolean): (el: HTMLDivElement | null) => v
|
|
|
346
346
|
return attach
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/**
|
|
350
|
+
* The page's control bar — row 1 (title/icon, `actions`, `sync`) and row 2 (`tabs`, `filters`,
|
|
351
|
+
* `filtersEnd`). Inside `BasaltShell` both rows portal into shell chrome; with no shell it renders
|
|
352
|
+
* ONE sticky bar in flow, `title` leading, and needs no `PageBarProvider`.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* // Shell-less (linewatch-shaped): the store is local, the bar is the page's first child.
|
|
356
|
+
* const filters = createLocalStore({ key: 'outages', fields: { q: field.string({ max: 60 }) } })
|
|
357
|
+
*
|
|
358
|
+
* function OutagesPage() {
|
|
359
|
+
* const [q] = filters.field.q.use()
|
|
360
|
+
* return (
|
|
361
|
+
* <>
|
|
362
|
+
* <PageBar
|
|
363
|
+
* title="Outages"
|
|
364
|
+
* actions={{ primary: { key: 'export', label: 'Export', onClick: exportCsv } }}
|
|
365
|
+
* filters={<FilterSet><SearchFilter field={filters.field.q} placeholder="Find a line" /></FilterSet>}
|
|
366
|
+
* classNames={{ root: classes.bleed }} // your gutter bleed — see `PageBarSlot`
|
|
367
|
+
* />
|
|
368
|
+
* <OutageTable query={q} />
|
|
369
|
+
* </>
|
|
370
|
+
* )
|
|
371
|
+
* }
|
|
372
|
+
*/
|
|
349
373
|
export function PageBar({
|
|
350
374
|
title,
|
|
351
375
|
icon,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed
|
|
2
|
+
* `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed in 1.33.0): use
|
|
3
3
|
* `useSizeClass` from `./use-size-class`. One media-query hook over the theme's own breakpoints,
|
|
4
4
|
* built on the shared `useMediaQuery` (`common/use-media-query`) — SSR/hydration-safe.
|
|
5
5
|
*
|
|
@@ -21,7 +21,7 @@ import { useMediaQuery } from '../common/use-media-query'
|
|
|
21
21
|
export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* @deprecated Use `useSizeClass` — removed
|
|
24
|
+
* @deprecated Use `useSizeClass` — removed in 1.33.0 (see `MIGRATING.md`). `theme.breakpoints` is
|
|
25
25
|
* now derived: `sm` moved 48em → 52.5em and `md`/`lg`/`xl` all sit at 75em (`xs`/`sm` collapse,
|
|
26
26
|
* `md`/`lg`/`xl` collapse).
|
|
27
27
|
*/
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* `.sheetBody` is the Modal body; it reaches the trailing button row `overlays.confirm`
|
|
43
43
|
* renders (Mantine `Group`), stretching the buttons to full width at the `--vx-hit` floor.
|
|
44
44
|
*/
|
|
45
|
+
/* theme-allow raw-media-query — the bottom sheet IS the compact size class's overlay form */
|
|
45
46
|
@media (max-width: 52.49375em) {
|
|
46
47
|
.sheetInner {
|
|
47
48
|
align-items: flex-end;
|
|
@@ -178,6 +178,8 @@ export function DeriveControls({ resetIcon }: DeriveControlsProps) {
|
|
|
178
178
|
<Text size="xs" fw={500} mb={4}>
|
|
179
179
|
Neutral family
|
|
180
180
|
</Text>
|
|
181
|
+
{/* theme-allow control-outside-home — a theme-lab setting (a settings row, a declared
|
|
182
|
+
non-home), not a page filter: it edits the derive config, never page data. */}
|
|
181
183
|
<SegmentedControl
|
|
182
184
|
size="xs"
|
|
183
185
|
fullWidth
|
package/src/tokens/index.ts
CHANGED
|
@@ -52,6 +52,11 @@ export type { RadiusValues } from './palette'
|
|
|
52
52
|
export { deriveSpacing } from './palette'
|
|
53
53
|
export type { SpaceValues } from './palette'
|
|
54
54
|
|
|
55
|
+
// The two responsive tables — MIGRATING recipes and the raw-breakpoint guard messages send
|
|
56
|
+
// consumers here for them; `migrating-imports.test.ts` pins that every documented import resolves.
|
|
57
|
+
export { CONTAINER_CLASSES, resolveContainerClass, SIZE_CLASSES } from './size-classes'
|
|
58
|
+
export type { ContainerClass } from './size-classes'
|
|
59
|
+
|
|
55
60
|
/** A per-theme color pair: a hue keeps its identity but shifts shade across schemes. */
|
|
56
61
|
export type ColorPair = { light: string; dark: string }
|
|
57
62
|
|