basalt-ui 1.30.2 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +31 -0
- package/MIGRATING.md +172 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +1 -1
- package/configs/oxlint-plugin.js +254 -4
- package/configs/oxlint-plugin.test.ts +145 -2
- package/configs/oxlint.json +2 -1
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +41 -43
- package/dist/agent-chat/thread-workspace.d.ts +2 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +3 -4
- package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
- package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
- package/dist/agent-chat/use-keyboard-inset.js +34 -0
- package/dist/charts/cursor/touch-pin.d.ts +45 -0
- package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
- package/dist/charts/cursor/touch-pin.js +79 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
- package/dist/charts/hooks/use-discrete-cursor.js +198 -0
- package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
- package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
- package/dist/charts/hooks/use-hysteresis.js +12 -0
- package/dist/charts/hooks/useBandPlot.d.ts +7 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
- package/dist/charts/hooks/useBandPlot.js +62 -18
- package/dist/charts/hooks/useChartCursor.d.ts +40 -11
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +102 -11
- package/dist/charts/index.d.ts +0 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +0 -10
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +54 -41
- package/dist/charts/kinds/Donut.d.ts +9 -3
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +110 -48
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +145 -131
- package/dist/charts/kinds/Heatmap.d.ts +7 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +37 -16
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +83 -68
- package/dist/charts/kinds/donut-layout.d.ts +47 -0
- package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
- package/dist/charts/kinds/donut-layout.js +52 -0
- package/dist/charts/layout/auto-margin.d.ts +7 -2
- package/dist/charts/layout/auto-margin.d.ts.map +1 -1
- package/dist/charts/layout/auto-margin.js +12 -3
- package/dist/charts/layout/end-labels.d.ts +64 -0
- package/dist/charts/layout/end-labels.d.ts.map +1 -0
- package/dist/charts/layout/end-labels.js +76 -0
- package/dist/charts/primitives/Axes.d.ts +32 -4
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +62 -16
- package/dist/charts/primitives/CartesianChart.d.ts +7 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +271 -125
- package/dist/charts/primitives/ChartCard.d.ts +0 -14
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +74 -21
- package/dist/charts/primitives/ChartFrame.d.ts +28 -22
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +133 -34
- package/dist/charts/primitives/ChartLegend.d.ts +19 -15
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +212 -71
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +4 -3
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +37 -26
- package/dist/charts/primitives/HoverOverlay.module.css +13 -0
- package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/dist/charts/primitives/chart-card-context.d.ts +29 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +13 -0
- package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +76 -29
- package/dist/charts/primitives/chart-layout.d.ts +112 -0
- package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
- package/dist/charts/primitives/chart-layout.js +175 -0
- package/dist/charts/primitives/chart-tier.d.ts +13 -11
- package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
- package/dist/charts/primitives/chart-tier.js +42 -13
- package/dist/charts/series.d.ts +4 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/ticks.d.ts +27 -8
- package/dist/charts/utils/ticks.d.ts.map +1 -1
- package/dist/charts/utils/ticks.js +35 -11
- package/dist/common/errors.d.ts +11 -1
- package/dist/common/errors.d.ts.map +1 -1
- package/dist/common/errors.js +16 -2
- package/dist/common/hit-floor.d.ts +10 -0
- package/dist/common/hit-floor.d.ts.map +1 -0
- package/dist/common/hit-floor.js +6 -0
- package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
- package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/common/use-measured-widths.d.ts +37 -0
- package/dist/common/use-measured-widths.d.ts.map +1 -0
- package/dist/common/use-measured-widths.js +51 -0
- package/dist/common/use-media-query.d.ts +6 -0
- package/dist/common/use-media-query.d.ts.map +1 -0
- package/dist/common/use-media-query.js +40 -0
- package/dist/common/validate.d.ts +9 -0
- package/dist/common/validate.d.ts.map +1 -1
- package/dist/common/validate.js +9 -4
- package/dist/content/article-layout.d.ts +4 -2
- package/dist/content/article-layout.d.ts.map +1 -1
- package/dist/content/article-layout.js +43 -3
- package/dist/content/article-layout.module.css +45 -4
- package/dist/content/article-layout.module.css.d.ts +3 -0
- package/dist/content/code-block.d.ts.map +1 -1
- package/dist/content/code-block.js +40 -2
- package/dist/content/code-block.module.css +32 -2
- package/dist/content/prose.module.css +13 -8
- package/dist/controls/actions.d.ts +29 -6
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +158 -27
- package/dist/controls/actions.module.css +14 -1
- package/dist/controls/actions.module.css.d.ts +1 -0
- package/dist/controls/controls.module.css +8 -31
- package/dist/controls/enum-filter.js +2 -2
- package/dist/controls/filter-context.d.ts +10 -0
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/filter-context.js +2 -0
- package/dist/controls/filter-pill.d.ts.map +1 -1
- package/dist/controls/filter-pill.js +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +0 -2
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/sync-button.module.css +1 -1
- package/dist/dashboard/section.d.ts.map +1 -1
- package/dist/dashboard/section.js +1 -0
- package/dist/dashboard/section.module.css +1 -26
- package/dist/dashboard/stat-card.d.ts +1 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +4 -1
- package/dist/dashboard/stat-card.module.css +6 -9
- package/dist/dashboard/stat-group.module.css +16 -4
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-header.d.ts +14 -4
- package/dist/dashboard/widget-header.d.ts.map +1 -1
- package/dist/dashboard/widget-header.js +23 -6
- package/dist/dashboard/widget-header.module.css +145 -133
- package/dist/dashboard/widget-header.module.css.d.ts +2 -0
- package/dist/data/data-table.d.ts +23 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +339 -141
- package/dist/data/data-table.module.css +94 -0
- package/dist/data/data-table.module.css.d.ts +11 -0
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual-list.js +10 -0
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.js +3 -3
- package/dist/forms/form-layout.module.css +30 -5
- package/dist/forms/form-layout.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +8 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +67 -2
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +22 -2
- package/dist/shell/app-brand.d.ts.map +1 -1
- package/dist/shell/app-brand.js +1 -0
- package/dist/shell/app-brand.module.css +2 -2
- package/dist/shell/app-header.module.css +18 -8
- package/dist/shell/app-main.module.css +54 -3
- package/dist/shell/app-main.module.css.d.ts +1 -0
- package/dist/shell/app-mobile-nav.module.css +16 -4
- package/dist/shell/app-sidebar.module.css +4 -6
- package/dist/shell/index.d.ts +12 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +57 -16
- package/dist/shell/mobile-nav-model.d.ts +1 -1
- package/dist/shell/page-aside.d.ts +22 -3
- package/dist/shell/page-aside.d.ts.map +1 -1
- package/dist/shell/page-aside.js +27 -13
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +10 -5
- package/dist/shell/page-bar.module.css +10 -34
- package/dist/shell/use-breakpoint.d.ts +3 -4
- package/dist/shell/use-breakpoint.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.js +3 -25
- package/dist/shell/use-size-class.d.ts +6 -0
- package/dist/shell/use-size-class.d.ts.map +1 -0
- package/dist/shell/use-size-class.js +19 -0
- package/dist/state/persisted-or-local.d.ts +4 -1
- package/dist/state/persisted-or-local.d.ts.map +1 -1
- package/dist/state/persisted-or-local.js +4 -3
- package/dist/state/persisted.d.ts +10 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +44 -14
- package/dist/styles.css +41 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +11 -3
- package/dist/theme/controls.module.css +7 -0
- package/dist/theme/ctl-theme.d.ts +1 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -1
- package/dist/theme/ctl-theme.js +12 -4
- package/dist/theme/floating.module.css +36 -0
- package/dist/theme/floating.module.css.d.ts +3 -0
- package/dist/theme/index.d.ts +2 -2
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +34 -5
- package/dist/theme/nav-link.module.css +7 -0
- package/dist/tokens/index.d.ts +7 -16
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +23 -28
- package/dist/tokens/palette.d.ts +5 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +8 -30
- package/dist/tokens/size-classes.d.ts +40 -0
- package/dist/tokens/size-classes.d.ts.map +1 -0
- package/dist/tokens/size-classes.js +29 -0
- package/dist/tokens.css +8 -4
- package/llms.txt +3 -3
- package/package.json +17 -17
- package/src/agent-chat/composer.tsx +74 -47
- package/src/agent-chat/thread-workspace.tsx +8 -11
- package/src/agent-chat/use-keyboard-inset.ts +68 -0
- package/src/charts/cursor/touch-pin.ts +138 -0
- package/src/charts/hooks/use-discrete-cursor.ts +335 -0
- package/src/charts/hooks/use-hysteresis.ts +26 -0
- package/src/charts/hooks/useBandPlot.ts +90 -27
- package/src/charts/hooks/useChartCursor.ts +211 -26
- package/src/charts/index.ts +0 -8
- package/src/charts/kinds/BandStrip.tsx +11 -1
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +19 -4
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +15 -3
- package/src/charts/kinds/donut-layout.ts +82 -0
- package/src/charts/layout/auto-margin.ts +24 -5
- package/src/charts/layout/end-labels.ts +135 -0
- package/src/charts/primitives/Axes.tsx +112 -21
- package/src/charts/primitives/CartesianChart.tsx +282 -68
- package/src/charts/primitives/ChartCard.tsx +141 -48
- package/src/charts/primitives/ChartFrame.tsx +243 -60
- package/src/charts/primitives/ChartLegend.tsx +257 -73
- package/src/charts/primitives/ChartTooltip.tsx +8 -3
- package/src/charts/primitives/Crosshair.tsx +2 -2
- package/src/charts/primitives/HoverOverlay.module.css +13 -0
- package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/src/charts/primitives/HoverOverlay.tsx +15 -4
- package/src/charts/primitives/chart-card-context.ts +37 -0
- package/src/charts/primitives/chart-frame-layout.ts +214 -177
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- package/src/charts/series.ts +4 -1
- package/src/charts/utils/ticks.ts +95 -19
- package/src/common/errors.ts +25 -1
- package/src/common/hit-floor.ts +9 -0
- package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
- package/src/common/use-measured-widths.ts +88 -0
- package/src/common/use-media-query.ts +56 -0
- package/src/common/validate.ts +17 -4
- package/src/content/article-layout.module.css +45 -4
- package/src/content/article-layout.module.css.d.ts +3 -0
- package/src/content/article-layout.tsx +60 -5
- package/src/content/code-block.module.css +32 -2
- package/src/content/code-block.tsx +47 -3
- package/src/content/prose.module.css +13 -8
- package/src/controls/actions.module.css +14 -1
- package/src/controls/actions.module.css.d.ts +1 -0
- package/src/controls/actions.tsx +223 -75
- package/src/controls/controls.module.css +8 -31
- package/src/controls/enum-filter.tsx +2 -2
- package/src/controls/filter-context.tsx +11 -0
- package/src/controls/filter-pill.tsx +1 -0
- package/src/controls/index.ts +0 -1
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/sync-button.module.css +1 -1
- package/src/dashboard/section.module.css +1 -26
- package/src/dashboard/section.tsx +1 -0
- package/src/dashboard/stat-card.module.css +6 -9
- package/src/dashboard/stat-card.tsx +6 -3
- package/src/dashboard/stat-group.module.css +16 -4
- package/src/dashboard/widget-grid.tsx +7 -6
- package/src/dashboard/widget-header.module.css +145 -133
- package/src/dashboard/widget-header.module.css.d.ts +2 -0
- package/src/dashboard/widget-header.tsx +45 -11
- package/src/data/data-table.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +503 -164
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +30 -5
- package/src/forms/form-layout.module.css.d.ts +1 -0
- package/src/forms/form-layout.tsx +12 -10
- package/src/guard/index.ts +183 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/provider/index.tsx +53 -1
- package/src/shell/app-brand.module.css +2 -2
- package/src/shell/app-brand.tsx +1 -0
- package/src/shell/app-header.module.css +18 -8
- package/src/shell/app-main.module.css +54 -3
- package/src/shell/app-main.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.module.css +16 -4
- package/src/shell/app-sidebar.module.css +4 -6
- package/src/shell/index.tsx +200 -129
- package/src/shell/mobile-nav-model.ts +1 -1
- package/src/shell/page-aside.tsx +70 -21
- package/src/shell/page-bar.module.css +10 -34
- package/src/shell/page-bar.tsx +10 -5
- package/src/shell/use-breakpoint.ts +9 -45
- package/src/shell/use-size-class.ts +31 -0
- package/src/state/persisted-or-local.ts +10 -4
- package/src/state/persisted.ts +76 -17
- package/src/styles.css +41 -0
- package/src/surfaces.ts +9 -1
- package/src/theme/controls.module.css +7 -0
- package/src/theme/ctl-theme.tsx +15 -3
- package/src/theme/floating.module.css +36 -0
- package/src/theme/floating.module.css.d.ts +3 -0
- package/src/theme/index.ts +46 -8
- package/src/theme/nav-link.module.css +7 -0
- package/src/tokens/index.ts +20 -30
- package/src/tokens/palette.ts +6 -28
- package/src/tokens/size-classes.ts +46 -0
- package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
- /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
package/src/shell/page-aside.tsx
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* WHERE it renders is decided by context and viewport, never by a prop:
|
|
14
14
|
*
|
|
15
15
|
* - **Inside `BasaltShell`, from `sm` up** the panel portals into `AppShell.Aside`, folding to a
|
|
16
|
-
* `appShellAsideRailWidth` rail with one expand button.
|
|
16
|
+
* `appShellAsideRailWidth` rail with one expand button. It DOCKS (pushes main) only while main
|
|
17
|
+
* keeps its floor width; otherwise it opens as an overlay over main and starts folded. The overlay
|
|
18
|
+
* is a persistent panel closed by its own fold button, or by Escape (the shell's keyboard escape
|
|
19
|
+
* hatch — `shell/index.tsx`'s `asideOverlay` effect) — it is not click-away dismissable.
|
|
17
20
|
* - **Below `sm`, with a `PageBar` that renders a row 2**, the panel PROJECTS into that row: it
|
|
18
21
|
* registers its title and glyph with the page-bar slot, renders no node of its own, and row 2
|
|
19
22
|
* draws one `Panel` pill opening a `FilterSheet` its children portal into. One node at a time —
|
|
@@ -43,18 +46,17 @@
|
|
|
43
46
|
* read stays in JS — through `useSyncExternalStore`, so SSR, hydration and the first paint agree.
|
|
44
47
|
* Recorded in `docs/CONTROLS-SPEC.md` §1 (C9) and `docs/ASIDE-SPEC.md` §0.
|
|
45
48
|
*/
|
|
46
|
-
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
|
49
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
|
47
50
|
import type { ReactNode } from 'react'
|
|
48
51
|
import { createPortal } from 'react-dom'
|
|
49
|
-
import { useMantineTheme } from '@mantine/core'
|
|
50
52
|
import { cx } from '../common/props'
|
|
51
53
|
import type { BasaltProps, SlotStylesProps } from '../common/props'
|
|
52
54
|
import { assertRequiredProps } from '../common/validate'
|
|
53
55
|
import { usePersistedOrLocal } from '../state/persisted-or-local'
|
|
54
56
|
import { FilterSetScope } from '../controls/filter-context'
|
|
55
57
|
import { useAsidePanelSlot } from './page-bar'
|
|
56
|
-
import { useIsomorphicLayoutEffect } from '
|
|
57
|
-
import {
|
|
58
|
+
import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
|
|
59
|
+
import { useSizeClass } from './use-size-class'
|
|
58
60
|
import classes from './page-aside.module.css'
|
|
59
61
|
|
|
60
62
|
type AsideRegion = {
|
|
@@ -70,6 +72,14 @@ type AsideRegion = {
|
|
|
70
72
|
folded: boolean
|
|
71
73
|
claim: () => () => void
|
|
72
74
|
publishFolded: (folded: boolean) => void
|
|
75
|
+
/**
|
|
76
|
+
* Bumped by `requestClose` — never 0 again once a close has been requested, so `PageAside` can
|
|
77
|
+
* treat "changed since mount" as "fold now" without a ref of its own. `ShellFrame` is the one
|
|
78
|
+
* caller, on Escape while the aside renders as an overlay.
|
|
79
|
+
*/
|
|
80
|
+
closeSignal: number
|
|
81
|
+
/** Asks the claiming `PageAside` to fold — the keyboard escape hatch for the overlay form. */
|
|
82
|
+
requestClose: () => void
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
const NO_REGION: AsideRegion = {
|
|
@@ -80,6 +90,8 @@ const NO_REGION: AsideRegion = {
|
|
|
80
90
|
folded: false,
|
|
81
91
|
claim: () => () => {},
|
|
82
92
|
publishFolded: () => {},
|
|
93
|
+
closeSignal: 0,
|
|
94
|
+
requestClose: () => {},
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
const AsideContext = createContext<AsideRegion>(NO_REGION)
|
|
@@ -92,6 +104,7 @@ export function AsideProvider({ children }: { children: ReactNode }): ReactNode
|
|
|
92
104
|
const [target, setTarget] = useState<HTMLElement | null>(null)
|
|
93
105
|
const [claims, setClaims] = useState(0)
|
|
94
106
|
const [folded, setFolded] = useState(false)
|
|
107
|
+
const [closeSignal, setCloseSignal] = useState(0)
|
|
95
108
|
|
|
96
109
|
const claim = useCallback(() => {
|
|
97
110
|
setClaims((n) => n + 1)
|
|
@@ -102,6 +115,8 @@ export function AsideProvider({ children }: { children: ReactNode }): ReactNode
|
|
|
102
115
|
}
|
|
103
116
|
}, [])
|
|
104
117
|
|
|
118
|
+
const requestClose = useCallback(() => setCloseSignal((n) => n + 1), [])
|
|
119
|
+
|
|
105
120
|
const value: AsideRegion = {
|
|
106
121
|
target,
|
|
107
122
|
setTarget,
|
|
@@ -110,17 +125,28 @@ export function AsideProvider({ children }: { children: ReactNode }): ReactNode
|
|
|
110
125
|
folded,
|
|
111
126
|
claim,
|
|
112
127
|
publishFolded: setFolded,
|
|
128
|
+
closeSignal,
|
|
129
|
+
requestClose,
|
|
113
130
|
}
|
|
114
131
|
|
|
115
132
|
return <AsideContext.Provider value={value}>{children}</AsideContext.Provider>
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
/** Internal — the region's state, read by `BasaltShell` to size `AppShell.Aside
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
/** Internal — the region's state, read by `BasaltShell` to size `AppShell.Aside` and to request a
|
|
136
|
+
* close (the Escape hatch on the overlay form). */
|
|
137
|
+
export function useAsideRegion(): Pick<AsideRegion, 'claimed' | 'folded' | 'requestClose'> {
|
|
138
|
+
const { claimed, folded, requestClose } = useContext(AsideContext)
|
|
139
|
+
return { claimed, folded, requestClose }
|
|
122
140
|
}
|
|
123
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Internal — whether an OPEN aside would push the main column (expanded, main keeps its floor)
|
|
144
|
+
* rather than overlay it. Provided by `ShellFrame`, which alone knows the navbar width, from the
|
|
145
|
+
* same render that computes it: a state published after mount would make the first pass read
|
|
146
|
+
* "cannot dock" and fold a default-open aside for one commit.
|
|
147
|
+
*/
|
|
148
|
+
export const AsideDocksContext = createContext(false)
|
|
149
|
+
|
|
124
150
|
/** Internal — the node inside `AppShell.Aside` the active page's panel portals into. */
|
|
125
151
|
export function AsideOutlet({ className }: { className?: string }) {
|
|
126
152
|
const { setTarget } = useContext(AsideContext)
|
|
@@ -136,7 +162,10 @@ export type PageAsideProps = BasaltProps &
|
|
|
136
162
|
title: string
|
|
137
163
|
/** Persists the fold at `basalt:aside:<persistKey>`. Omit for an unpersisted fold. */
|
|
138
164
|
persistKey?: string
|
|
139
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* The fold while the user has not chosen one — an explicit (persisted or toggled) choice always
|
|
167
|
+
* wins. @default open where the aside docks beside main, folded where it would overlay it
|
|
168
|
+
*/
|
|
140
169
|
defaultFolded?: boolean
|
|
141
170
|
children: ReactNode
|
|
142
171
|
}
|
|
@@ -214,26 +243,25 @@ export function PageAside(props: PageAsideProps): ReactNode {
|
|
|
214
243
|
assertRequiredProps('PageAside', props, ['title'], {
|
|
215
244
|
title: 'it names the region — it is the header text AND the `aria-label` on the landmark.',
|
|
216
245
|
})
|
|
217
|
-
const { title, persistKey, defaultFolded
|
|
218
|
-
const { target, inShell, claim, publishFolded } = useContext(AsideContext)
|
|
246
|
+
const { title, persistKey, defaultFolded, children, className, style, classNames } = props
|
|
247
|
+
const { target, inShell, claim, publishFolded, closeSignal } = useContext(AsideContext)
|
|
248
|
+
const docks = useContext(AsideDocksContext)
|
|
219
249
|
// Destructured, not held as one object: the hook returns a fresh literal every render, and the
|
|
220
250
|
// claim effect below would then re-run (claim → release → claim) forever. `claimPanel` is a
|
|
221
251
|
// `useCallback` on the provider, so it is the stable half.
|
|
222
252
|
const { host: panelHost, claim: claimPanel, target: panelTarget } = useAsidePanelSlot()
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
// hydration pass and the first client paint agreeing on which single node exists.
|
|
229
|
-
const desktop = useMediaQueryMatches(`(min-width: ${theme.breakpoints.sm})`, true)
|
|
253
|
+
// THE declared C9 exception (see this file's header): the read is JS because the two projections
|
|
254
|
+
// are two portal targets under two filter surfaces, and a CSS twin would double-mount stateful
|
|
255
|
+
// children. `useSizeClass` (`useSyncExternalStore`) keeps SSR, the hydration pass and the first
|
|
256
|
+
// client paint agreeing on which single node exists.
|
|
257
|
+
const isCompact = useSizeClass() === 'compact'
|
|
230
258
|
const [folded, setFolded] = usePersistedOrLocal({
|
|
231
259
|
scope: 'aside',
|
|
232
260
|
persistKey,
|
|
233
|
-
initial: defaultFolded,
|
|
261
|
+
initial: defaultFolded ?? !docks,
|
|
234
262
|
})
|
|
235
263
|
|
|
236
|
-
const portalled = inShell &&
|
|
264
|
+
const portalled = inShell && !isCompact
|
|
237
265
|
// Below `sm` the panel goes into the page bar's row 2 IF there is one. Without a row 2 there is
|
|
238
266
|
// nowhere to put a trigger, so the honest in-flow form (wave 1) stays.
|
|
239
267
|
const projected = !portalled && panelHost
|
|
@@ -268,6 +296,27 @@ export function PageAside(props: PageAsideProps): ReactNode {
|
|
|
268
296
|
publishFolded(folded)
|
|
269
297
|
}, [portalled, folded, publishFolded])
|
|
270
298
|
|
|
299
|
+
// The Escape hatch (`shell/index.tsx`'s `asideOverlay` effect): `closeSignal` is monotonic and
|
|
300
|
+
// shared across every `PageAside` the shell ever mounts, so "past the initial 0" is the wrong
|
|
301
|
+
// question for any instance mounted AFTER the first Escape elsewhere in the app's lifetime — it
|
|
302
|
+
// would read the ALREADY-bumped signal as "a close was requested" on its very first commit and
|
|
303
|
+
// fold before the user ever asked. `closeSignalBaseline` captures the value THIS instance mounted
|
|
304
|
+
// with, so only a change AFTER that point — a close requested while this instance is live — folds
|
|
305
|
+
// it; a later-mounted instance still respects its own `defaultFolded`/persisted state. Guarded on
|
|
306
|
+
// `portalled`, matching where `ShellFrame` can even observe an overlay to escape out of.
|
|
307
|
+
//
|
|
308
|
+
// The baseline ADVANCES to every `closeSignal` it acts on — not just at mount. Without that, a
|
|
309
|
+
// portalled false→true transition (the aside leaving and rejoining the shell, e.g. a viewport
|
|
310
|
+
// resize crossing `sm`) re-runs this effect with `closeSignal` still sitting on the value the
|
|
311
|
+
// FIRST Escape already bumped it to, which the baseline — frozen at its pre-Escape value — reads
|
|
312
|
+
// as "a close was just requested" all over again and folds an aside the user had since reopened.
|
|
313
|
+
const closeSignalBaseline = useRef(closeSignal)
|
|
314
|
+
useIsomorphicLayoutEffect(() => {
|
|
315
|
+
if (!portalled || closeSignal === closeSignalBaseline.current) return
|
|
316
|
+
closeSignalBaseline.current = closeSignal
|
|
317
|
+
setFolded(true)
|
|
318
|
+
}, [portalled, closeSignal])
|
|
319
|
+
|
|
271
320
|
useIsomorphicLayoutEffect(() => {
|
|
272
321
|
if (!projected) return
|
|
273
322
|
return claimPanel(panelClaim)
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
.row1 {
|
|
49
|
+
/* Fills the header's elastic outlet, so `.row1End` below is the ROOM the action fold measures. */
|
|
50
|
+
width: 100%;
|
|
49
51
|
display: flex;
|
|
50
52
|
flex-wrap: nowrap;
|
|
51
53
|
align-items: center;
|
|
@@ -188,42 +190,16 @@
|
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
/*
|
|
191
|
-
* Law C15 —
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* so a hit area drawn there would fight it. `pointer-events` stays default so the overlay is the
|
|
195
|
-
* one receiving the tap.
|
|
196
|
-
*
|
|
197
|
-
* `overflow: visible` is LOAD-BEARING, not tidying. Mantine's Button and ActionIcon roots both set
|
|
198
|
-
* `overflow: hidden` (`Button.css`, `ActionIcon.css`), which clips an overlay that by definition
|
|
199
|
-
* extends past the 30px box — so without this the 36px target simply does not exist and a tap 3px
|
|
200
|
-
* above the control misses. What `overflow: hidden` buys those roots is clipping the loading
|
|
201
|
-
* overlay's blur, and that overlay is only painted under `[data-loading]`, which is excluded below.
|
|
193
|
+
* Law C15 — controls in a home adopt the shared `[data-basalt-hit]` overlay (`styles.css`) through
|
|
194
|
+
* `data-basalt-hit-scope` on `.row1`/`.row2`; `--vx-hit-gap` caps the expansion at the real gap so
|
|
195
|
+
* neighbours' hit boxes never overlap. The VISIBLE box stays the 30px `ctl` tier.
|
|
202
196
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
.row1 :is(button, a):not([data-loading]),
|
|
210
|
-
.row2 :is(button, a):not([data-loading]) {
|
|
211
|
-
overflow: visible;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
.row1 :is(button, a)::after,
|
|
215
|
-
.row2 :is(button, a)::after {
|
|
216
|
-
content: '';
|
|
217
|
-
position: absolute;
|
|
218
|
-
top: 50%;
|
|
219
|
-
left: 50%;
|
|
220
|
-
translate: -50% -50%;
|
|
221
|
-
width: 100%;
|
|
222
|
-
height: 100%;
|
|
223
|
-
min-width: var(--vx-space-touch-control-height);
|
|
224
|
-
min-height: var(--vx-space-touch-control-height);
|
|
225
|
-
}
|
|
197
|
+
.row1,
|
|
198
|
+
.row2 {
|
|
199
|
+
--vx-hit-gap: var(--vx-space-control-gap);
|
|
200
|
+
}
|
|
226
201
|
|
|
202
|
+
@media (max-width: 52.49375em) {
|
|
227
203
|
/*
|
|
228
204
|
* Row 2's phone form: two declared lines, tabs then pills — not a wrap (law C7). `.pills` folds
|
|
229
205
|
* every overflow into `Filters (n)` at any width, EXCEPT when it holds nothing visible: below `sm`
|
package/src/shell/page-bar.tsx
CHANGED
|
@@ -28,13 +28,18 @@ import { createPortal } from 'react-dom'
|
|
|
28
28
|
import { cx } from '../common/props'
|
|
29
29
|
import type { BasaltProps, SlotStylesProps } from '../common/props'
|
|
30
30
|
import { CtlSlot } from '../theme'
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
BarActionRow,
|
|
33
|
+
BarExtrasProvider,
|
|
34
|
+
PAGE_BAR_END_ATTR,
|
|
35
|
+
globalActionAsBarAction,
|
|
36
|
+
} from '../controls/actions'
|
|
32
37
|
import { FilterPill } from '../controls/filter-pill'
|
|
33
38
|
import { FilterSheet } from '../controls/filter-sheet'
|
|
34
39
|
import { SyncButton } from '../controls/sync-button'
|
|
35
40
|
import type { ActionGroupProps, BarAction, GlobalAction } from '../controls/actions'
|
|
36
41
|
import type { SyncButtonProps } from '../controls/sync-button'
|
|
37
|
-
import { useIsomorphicLayoutEffect } from '
|
|
42
|
+
import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
|
|
38
43
|
import classes from './page-bar.module.css'
|
|
39
44
|
import asideClasses from './page-aside.module.css'
|
|
40
45
|
|
|
@@ -388,7 +393,7 @@ export function PageBar({
|
|
|
388
393
|
}, [inShell, hasRow2, claimPanelHost])
|
|
389
394
|
|
|
390
395
|
const row1 = hasRow1 ? (
|
|
391
|
-
<div className={cx(classes.row1, classNames?.row1)}>
|
|
396
|
+
<div className={cx(classes.row1, classNames?.row1)} data-basalt-hit-scope>
|
|
392
397
|
{hasLead && (
|
|
393
398
|
<div className={classes.lead}>
|
|
394
399
|
{icon !== undefined && (
|
|
@@ -399,7 +404,7 @@ export function PageBar({
|
|
|
399
404
|
{title !== undefined && <h1 className={classes.title}>{title}</h1>}
|
|
400
405
|
</div>
|
|
401
406
|
)}
|
|
402
|
-
<div className={classes.row1End}>
|
|
407
|
+
<div className={classes.row1End} {...{ [PAGE_BAR_END_ATTR]: '' }}>
|
|
403
408
|
{(actions !== undefined || filtersEndActions.length > 0 || sync !== undefined) && (
|
|
404
409
|
<CtlSlot>
|
|
405
410
|
{/* THE row-1 group: the only `host: 'page'` instance, so the shell's `mobile: 'more'`
|
|
@@ -430,7 +435,7 @@ export function PageBar({
|
|
|
430
435
|
const hasPills = filters !== undefined || panel !== null || filtersEndActions.length > 0
|
|
431
436
|
|
|
432
437
|
const row2 = hasRow2 ? (
|
|
433
|
-
<div className={cx(classes.row2, classNames?.row2)}>
|
|
438
|
+
<div className={cx(classes.row2, classNames?.row2)} data-basalt-hit-scope>
|
|
434
439
|
{tabs !== undefined && (
|
|
435
440
|
<div className={classes.tabs} data-basalt-page-bar-line="tabs">
|
|
436
441
|
<CtlSlot>{tabs}</CtlSlot>
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `useBreakpoint` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `matchMedia` inside a `useState` initializer, so on the server it returns its `fallback` and on
|
|
6
|
-
* the client's very first render it returns the REAL match; when that render is a hydration pass
|
|
7
|
-
* the two disagree silently (a server-rendered desktop layout hydrating on a phone against a
|
|
8
|
-
* client tree that already moved). `useSyncExternalStore`'s `getServerSnapshot` makes React render
|
|
9
|
-
* the server's answer during hydration and correct itself immediately after — the documented,
|
|
10
|
-
* warning-free version of the same fix. `page-aside.tsx`'s own former private
|
|
11
|
-
* `useMediaQueryMatches` carried this exact rationale; it now lives here and page-aside composes
|
|
12
|
-
* this hook instead (C5 consolidation — one media-query hook, not two).
|
|
2
|
+
* `useBreakpoint` — DEPRECATED (on notice in `MIGRATING.md`, removed next minor): use
|
|
3
|
+
* `useSizeClass` from `./use-size-class`. One media-query hook over the theme's own breakpoints,
|
|
4
|
+
* built on the shared `useMediaQuery` (`common/use-media-query`) — SSR/hydration-safe.
|
|
13
5
|
*
|
|
14
6
|
* `edge` defaults to `'min'` (`"at least this wide"`); pass `'max'` for `"narrower than"`. The
|
|
15
7
|
* `fallback` (what the server, and a shim with no `matchMedia`, sees) defaults to the answer that
|
|
16
8
|
* assumes the NARROW side of the query — `false` for `min`, `true` for `max` — since a component
|
|
17
|
-
* library cannot know a consumer's real device mix;
|
|
18
|
-
* `page-aside.tsx`'s desktop-first portal fallback) overrides it via `options.fallback`.
|
|
9
|
+
* library cannot know a consumer's real device mix; `options.fallback` overrides it.
|
|
19
10
|
*
|
|
20
11
|
* @example
|
|
21
12
|
* import { useBreakpoint } from 'basalt-ui'
|
|
@@ -24,43 +15,16 @@
|
|
|
24
15
|
* const isNarrow = !useBreakpoint('sm') // < theme.breakpoints.sm
|
|
25
16
|
* const isWide = useBreakpoint('lg') // >= theme.breakpoints.lg
|
|
26
17
|
*/
|
|
27
|
-
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
|
28
18
|
import { useMantineTheme } from '@mantine/core'
|
|
19
|
+
import { useMediaQuery } from '../common/use-media-query'
|
|
29
20
|
|
|
30
21
|
export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
31
22
|
|
|
32
|
-
/** `window.matchMedia`, or `null` where there is no window and on a shim that does not ship it. */
|
|
33
|
-
function mediaQueryList(query: string): MediaQueryList | null {
|
|
34
|
-
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null
|
|
35
|
-
return window.matchMedia(query)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
23
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
24
|
+
* @deprecated Use `useSizeClass` — removed next minor (see `MIGRATING.md`). `theme.breakpoints` is
|
|
25
|
+
* now derived: `sm` moved 48em → 52.5em and `md`/`lg`/`xl` all sit at 75em (`xs`/`sm` collapse,
|
|
26
|
+
* `md`/`lg`/`xl` collapse).
|
|
42
27
|
*/
|
|
43
|
-
export function useMediaQueryMatches(query: string, fallback: boolean): boolean {
|
|
44
|
-
// ONE `MediaQueryList` per query, held across renders — `matchMedia()` allocates a fresh live
|
|
45
|
-
// object on every call, and `getSnapshot` runs on every render AND after every store
|
|
46
|
-
// notification, so calling it there would churn an object per read while `subscribe` listened
|
|
47
|
-
// on a different instance than `getSnapshot` measured.
|
|
48
|
-
const list = useMemo(() => mediaQueryList(query), [query])
|
|
49
|
-
const subscribe = useCallback(
|
|
50
|
-
(onStoreChange: () => void) => {
|
|
51
|
-
if (list === null) return () => {}
|
|
52
|
-
list.addEventListener('change', onStoreChange)
|
|
53
|
-
return () => {
|
|
54
|
-
list.removeEventListener('change', onStoreChange)
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
[list],
|
|
58
|
-
)
|
|
59
|
-
const getSnapshot = useCallback(() => list?.matches ?? fallback, [list, fallback])
|
|
60
|
-
const getServerSnapshot = useCallback(() => fallback, [fallback])
|
|
61
|
-
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
28
|
export function useBreakpoint(
|
|
65
29
|
name: BreakpointName,
|
|
66
30
|
edge: 'min' | 'max' = 'min',
|
|
@@ -69,5 +33,5 @@ export function useBreakpoint(
|
|
|
69
33
|
const theme = useMantineTheme()
|
|
70
34
|
const bp = theme.breakpoints[name]
|
|
71
35
|
const fallback = options?.fallback ?? edge === 'max'
|
|
72
|
-
return
|
|
36
|
+
return useMediaQuery(`(${edge}-width: ${bp})`, fallback)
|
|
73
37
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useSizeClass` — the viewport axis of the responsive law (`docs/DESIGN-CORE.md` § Layout,
|
|
3
|
+
* elevation, shapes):
|
|
4
|
+
* `'compact' | 'medium' | 'expanded'`, for the JS reads CSS cannot express (portal targets,
|
|
5
|
+
* mount-one-of-two branches). Shell-only — components size by container, not by this.
|
|
6
|
+
*
|
|
7
|
+
* Built on `useMediaQuery` (`common/use-media-query`), one call per class boundary. The server
|
|
8
|
+
* snapshot is `<BasaltProvider sizeClassHint>` (default `'compact'`), so SSR, the hydration pass
|
|
9
|
+
* and the first client paint agree, and the real class lands one commit later — no first-frame
|
|
10
|
+
* flash from a `matchMedia` read inside a `useState` initializer.
|
|
11
|
+
*/
|
|
12
|
+
import { createContext, useContext } from 'react'
|
|
13
|
+
import { useMediaQuery } from '../common/use-media-query'
|
|
14
|
+
import { SIZE_CLASSES, toEm } from '../tokens/size-classes'
|
|
15
|
+
|
|
16
|
+
export type SizeClass = keyof typeof SIZE_CLASSES
|
|
17
|
+
|
|
18
|
+
/** Provided by `BasaltProvider`'s `sizeClassHint`; the server snapshot. `undefined` = no provider. */
|
|
19
|
+
export const SizeClassHintContext = createContext<SizeClass | undefined>(undefined)
|
|
20
|
+
|
|
21
|
+
export function useSizeClass(): SizeClass {
|
|
22
|
+
const hint = useContext(SizeClassHintContext) ?? 'compact'
|
|
23
|
+
const isMedium = useMediaQuery(`(min-width: ${toEm(SIZE_CLASSES.medium)})`, hint !== 'compact')
|
|
24
|
+
const isExpanded = useMediaQuery(
|
|
25
|
+
`(min-width: ${toEm(SIZE_CLASSES.expanded)})`,
|
|
26
|
+
hint === 'expanded',
|
|
27
|
+
)
|
|
28
|
+
if (isExpanded) return 'expanded'
|
|
29
|
+
if (isMedium) return 'medium'
|
|
30
|
+
return 'compact'
|
|
31
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Not exported from `../state` — this is an internal hook, not part of the `./state` surface.
|
|
15
15
|
*/
|
|
16
|
-
import { useMemo, useState } from 'react'
|
|
16
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
17
17
|
import { createPersistedState } from './persisted'
|
|
18
18
|
|
|
19
19
|
/** The key an unpersisted caller parks on — never read, because that branch returns local state. */
|
|
@@ -28,10 +28,14 @@ export function usePersistedOrLocal<T>({
|
|
|
28
28
|
scope: string
|
|
29
29
|
/** Persist under `basalt:<scope>:<persistKey>`. Omitted → local state, nothing written. */
|
|
30
30
|
persistKey: string | undefined
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The value while the user has not chosen one — unset, NOT "written as this". It may change
|
|
33
|
+
* between renders (a size-class default) and the flag follows until a set call pins it.
|
|
34
|
+
*/
|
|
32
35
|
initial: T
|
|
33
36
|
}): readonly [T, (next: T) => void] {
|
|
34
|
-
|
|
37
|
+
// Boxed so "never set" (`null`) stays distinct from a set value of any `T`, falsy ones included.
|
|
38
|
+
const [local, setLocal] = useState<{ value: T } | null>(null)
|
|
35
39
|
// `createPersistedState` is a per-key module FACTORY, so it is memoized rather than called during
|
|
36
40
|
// render — the same reason `shell/index.tsx` memoizes its collapse store.
|
|
37
41
|
const usePersisted = useMemo(
|
|
@@ -44,7 +48,9 @@ export function usePersistedOrLocal<T>({
|
|
|
44
48
|
[scope, persistKey, initial],
|
|
45
49
|
)
|
|
46
50
|
const [persisted, setPersisted] = usePersisted()
|
|
51
|
+
// Stable across renders like the persisted setter — a value-only setter, so no updater form.
|
|
52
|
+
const setLocalValue = useCallback((next: T) => setLocal({ value: next }), [])
|
|
47
53
|
|
|
48
54
|
if (persistKey !== undefined) return [persisted, setPersisted] as const
|
|
49
|
-
return [local,
|
|
55
|
+
return [local === null ? initial : local.value, setLocalValue] as const
|
|
50
56
|
}
|
package/src/state/persisted.ts
CHANGED
|
@@ -16,6 +16,9 @@ const noopSubscribe =
|
|
|
16
16
|
(_cb: () => void): (() => void) =>
|
|
17
17
|
() => {}
|
|
18
18
|
|
|
19
|
+
// Nothing has been written as far as the server can tell.
|
|
20
|
+
const serverIsSet = (): boolean => false
|
|
21
|
+
|
|
19
22
|
export type PersistedStateOptions<T> = {
|
|
20
23
|
/** localStorage key (will be namespaced as `basalt:<key>`). */
|
|
21
24
|
readonly key: string
|
|
@@ -81,40 +84,59 @@ function isEnvelope(raw: unknown): raw is Envelope {
|
|
|
81
84
|
)
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
/**
|
|
88
|
+
* One parse pass over a raw localStorage string: the value AND whether the store ACCEPTED what it
|
|
89
|
+
* found. `accepted` is false for a miss, garbage, a wrong version with no `migrate`, or a value the
|
|
90
|
+
* schema rejects — every case that falls back to `initial`, so `isSet` never claims a choice that
|
|
91
|
+
* was not honoured.
|
|
92
|
+
*/
|
|
93
|
+
function parseStorage<T>(
|
|
94
|
+
raw: string | null,
|
|
95
|
+
opts: PersistedStateOptions<T>,
|
|
96
|
+
): { value: T; accepted: boolean } {
|
|
97
|
+
const miss = { value: opts.initial, accepted: false }
|
|
98
|
+
if (raw === null) return miss
|
|
87
99
|
try {
|
|
88
100
|
const parsed: unknown = JSON.parse(raw)
|
|
89
|
-
if (!isEnvelope(parsed)) return
|
|
101
|
+
if (!isEnvelope(parsed)) return miss
|
|
90
102
|
|
|
91
103
|
let value: unknown = parsed.value
|
|
92
104
|
|
|
93
105
|
if (parsed.v !== opts.version) {
|
|
94
|
-
|
|
106
|
+
if (!opts.migrate) return miss
|
|
107
|
+
value = opts.migrate(parsed.value, parsed.v)
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
if (opts.schema) {
|
|
98
111
|
const result = opts.schema['~standard'].validate(value)
|
|
99
112
|
// Async schema — can't await in sync storage path; fall back to initial
|
|
100
|
-
if (result instanceof Promise) return
|
|
101
|
-
if (result.issues !== undefined) return
|
|
113
|
+
if (result instanceof Promise) return miss
|
|
114
|
+
if (result.issues !== undefined) return miss
|
|
102
115
|
// Narrowed to SuccessResult<T> — value is T
|
|
103
|
-
return result.value
|
|
116
|
+
return { value: result.value, accepted: true }
|
|
104
117
|
}
|
|
105
118
|
|
|
106
|
-
return value as T
|
|
119
|
+
return { value: value as T, accepted: true }
|
|
107
120
|
} catch {
|
|
108
|
-
return
|
|
121
|
+
return miss
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Values a write could not persist (storage blocked, full, private mode), by namespaced key. An
|
|
127
|
+
* explicit user choice then still wins for the rest of the session instead of snapping back to the
|
|
128
|
+
* default; a successful write or a `storage` event for the key drops the entry.
|
|
129
|
+
*/
|
|
130
|
+
const unpersisted = new Map<string, { value: unknown }>()
|
|
131
|
+
|
|
112
132
|
function writeEnvelope(storageKey: string, version: number, value: unknown): void {
|
|
113
133
|
try {
|
|
114
134
|
const envelope: Envelope = { v: version, value }
|
|
115
135
|
window.localStorage.setItem(storageKey, JSON.stringify(envelope))
|
|
136
|
+
unpersisted.delete(storageKey)
|
|
116
137
|
} catch {
|
|
117
|
-
//
|
|
138
|
+
// Storage full or blocked: keep the value in memory rather than lose the choice.
|
|
139
|
+
unpersisted.set(storageKey, { value })
|
|
118
140
|
}
|
|
119
141
|
}
|
|
120
142
|
|
|
@@ -137,6 +159,25 @@ function writeStorage<T>(opts: PersistedStateOptions<T>, next: T): void {
|
|
|
137
159
|
export function createPersistedState<T>(
|
|
138
160
|
opts: PersistedStateOptions<T>,
|
|
139
161
|
): () => readonly [T, (next: T) => void] {
|
|
162
|
+
const useStore = createPersistedStore(opts)
|
|
163
|
+
return function usePersistedState() {
|
|
164
|
+
const [value, setValue] = useStore()
|
|
165
|
+
return [value, setValue] as const
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `createPersistedState` plus a third tuple element: `isSet`, false until a value is written or a
|
|
171
|
+
* stored one is accepted (a stale-version or schema-invalid envelope is NOT set).
|
|
172
|
+
* `value` alone cannot say — an unset key and an explicit write of `initial` both read as `initial`,
|
|
173
|
+
* and a default that depends on context (the shell's size-class collapse default) may apply to the
|
|
174
|
+
* first only.
|
|
175
|
+
*
|
|
176
|
+
* @internal Not exported from the `basalt-ui/state` barrel.
|
|
177
|
+
*/
|
|
178
|
+
export function createPersistedStore<T>(
|
|
179
|
+
opts: PersistedStateOptions<T>,
|
|
180
|
+
): () => readonly [T, (next: T) => void, boolean] {
|
|
140
181
|
const storageKey = `basalt:${opts.key}`
|
|
141
182
|
|
|
142
183
|
const subscribe = (cb: () => void): (() => void) => {
|
|
@@ -148,7 +189,10 @@ export function createPersistedState<T>(
|
|
|
148
189
|
// when the last one leaves (cleanup semantics preserved).
|
|
149
190
|
if (channel.handler === null) {
|
|
150
191
|
channel.handler = (e: StorageEvent): void => {
|
|
151
|
-
if (e.key
|
|
192
|
+
if (e.key !== storageKey) return
|
|
193
|
+
// Another tab's write (or removal) is newer than anything this tab failed to persist.
|
|
194
|
+
unpersisted.delete(storageKey)
|
|
195
|
+
notify(storageKey)
|
|
152
196
|
}
|
|
153
197
|
window.addEventListener('storage', channel.handler)
|
|
154
198
|
}
|
|
@@ -171,9 +215,12 @@ export function createPersistedState<T>(
|
|
|
171
215
|
// the object/array case (chat history, form drafts).
|
|
172
216
|
let cachedRaw: string | null = null
|
|
173
217
|
let cachedValue: T = opts.initial
|
|
218
|
+
let cachedIsSet = false
|
|
174
219
|
let primed = false
|
|
175
220
|
|
|
176
221
|
const getSnapshot = (): T => {
|
|
222
|
+
const override = unpersisted.get(storageKey)
|
|
223
|
+
if (override !== undefined) return override.value as T
|
|
177
224
|
let raw: string | null
|
|
178
225
|
try {
|
|
179
226
|
raw = window.localStorage.getItem(storageKey)
|
|
@@ -181,11 +228,17 @@ export function createPersistedState<T>(
|
|
|
181
228
|
raw = null
|
|
182
229
|
}
|
|
183
230
|
if (primed && raw === cachedRaw) return cachedValue
|
|
231
|
+
const parsed = parseStorage(raw, opts)
|
|
184
232
|
cachedRaw = raw
|
|
185
|
-
cachedValue =
|
|
233
|
+
cachedValue = parsed.value
|
|
234
|
+
cachedIsSet = parsed.accepted
|
|
186
235
|
primed = true
|
|
187
236
|
return cachedValue
|
|
188
237
|
}
|
|
238
|
+
const getIsSet = (): boolean => {
|
|
239
|
+
getSnapshot()
|
|
240
|
+
return unpersisted.has(storageKey) || cachedIsSet
|
|
241
|
+
}
|
|
189
242
|
const getServerSnapshot = (): T => opts.initial
|
|
190
243
|
|
|
191
244
|
const setState = (next: T): void => {
|
|
@@ -198,15 +251,21 @@ export function createPersistedState<T>(
|
|
|
198
251
|
// Detect SSR once at creation time — the environment doesn't change between renders.
|
|
199
252
|
const isServer = typeof window === 'undefined'
|
|
200
253
|
|
|
201
|
-
return function
|
|
254
|
+
return function usePersistedStore(): readonly [T, (next: T) => void, boolean] {
|
|
255
|
+
// SSR guard — subscribe must not reference `window` on the server.
|
|
256
|
+
const listen = isServer ? noopSubscribe : subscribe
|
|
202
257
|
const value = useSyncExternalStore<T>(
|
|
203
|
-
|
|
204
|
-
isServer ? noopSubscribe : subscribe,
|
|
258
|
+
listen,
|
|
205
259
|
isServer ? getServerSnapshot : getSnapshot,
|
|
206
260
|
getServerSnapshot,
|
|
207
261
|
)
|
|
262
|
+
const isSet = useSyncExternalStore<boolean>(
|
|
263
|
+
listen,
|
|
264
|
+
isServer ? serverIsSet : getIsSet,
|
|
265
|
+
serverIsSet,
|
|
266
|
+
)
|
|
208
267
|
|
|
209
|
-
return [value, setState] as const
|
|
268
|
+
return [value, setState, isSet] as const
|
|
210
269
|
}
|
|
211
270
|
}
|
|
212
271
|
|