basalt-ui 2.2.0 → 2.4.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 +32 -0
- package/MIGRATING.md +78 -12
- package/agent/rules/basalt-state.md +1 -1
- package/configs/lefthook.yml +4 -1
- package/dist/charts/BandStrip.d.ts +7 -7
- package/dist/charts/BandStrip.d.ts.map +1 -1
- package/dist/charts/Bars.d.ts +4 -4
- package/dist/charts/Bars.d.ts.map +1 -1
- package/dist/charts/CartesianChart.d.ts.map +1 -1
- package/dist/charts/CartesianChart.js +6 -2
- package/dist/charts/ChartCard.d.ts +10 -10
- package/dist/charts/ChartCard.d.ts.map +1 -1
- package/dist/charts/ChartCard.js +2 -1
- package/dist/charts/Donut.d.ts +5 -5
- package/dist/charts/Donut.d.ts.map +1 -1
- package/dist/charts/DualPanel.d.ts +14 -10
- package/dist/charts/DualPanel.d.ts.map +1 -1
- package/dist/charts/DualPanel.js +32 -14
- package/dist/charts/Heatmap.d.ts +15 -15
- package/dist/charts/Heatmap.d.ts.map +1 -1
- package/dist/charts/Heatmap.js +2 -2
- package/dist/charts/Lines.js +2 -2
- package/dist/charts/MirroredBars.d.ts +9 -9
- package/dist/charts/MirroredBars.d.ts.map +1 -1
- package/dist/charts/MirroredBars.js +8 -7
- package/dist/charts/Sparkline.d.ts +6 -6
- package/dist/charts/Sparkline.d.ts.map +1 -1
- package/dist/charts/StackedArea.d.ts +2 -2
- package/dist/charts/StackedArea.d.ts.map +1 -1
- package/dist/charts/compile.d.ts +25 -2
- package/dist/charts/compile.d.ts.map +1 -1
- package/dist/charts/compile.js +204 -34
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +3 -1
- package/dist/charts/marks.d.ts +8 -0
- package/dist/charts/marks.d.ts.map +1 -1
- package/dist/charts/marks.js +22 -1
- package/dist/charts/panel.js +2 -2
- package/dist/charts/types.d.ts +101 -61
- package/dist/charts/types.d.ts.map +1 -1
- package/dist/state/fields.d.ts +19 -3
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +14 -8
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/BandStrip.tsx +12 -10
- package/src/charts/Bars.tsx +4 -4
- package/src/charts/CartesianChart.tsx +7 -2
- package/src/charts/ChartCard.tsx +17 -11
- package/src/charts/Donut.tsx +5 -5
- package/src/charts/DualPanel.tsx +50 -21
- package/src/charts/Heatmap.tsx +14 -14
- package/src/charts/Lines.tsx +2 -2
- package/src/charts/MirroredBars.tsx +16 -15
- package/src/charts/Sparkline.tsx +6 -6
- package/src/charts/StackedArea.tsx +2 -2
- package/src/charts/compile.ts +285 -36
- package/src/charts/index.ts +1 -0
- package/src/charts/marks.ts +35 -1
- package/src/charts/panel.tsx +2 -2
- package/src/charts/types.ts +105 -57
- package/src/state/fields.ts +55 -19
package/src/charts/types.ts
CHANGED
|
@@ -24,35 +24,57 @@ export type ChartSeries<T> = {
|
|
|
24
24
|
* series whose `getValue` is always `null` is a legend-only entry (a zone or reference key). */
|
|
25
25
|
getValue: (d: T) => number | null
|
|
26
26
|
/** The legend swatch shape. Default: the kind's own mark (`line` in `Lines`, `bar` in `Bars`). */
|
|
27
|
-
mark?: 'line' | 'bar' | 'area'
|
|
27
|
+
mark?: 'line' | 'bar' | 'area' | undefined
|
|
28
28
|
/** Default `'solid'`; `'dashed'` is `VX.dashArray` on the line, the legend and the tooltip. */
|
|
29
|
-
dash?: 'solid' | 'dashed'
|
|
29
|
+
dash?: 'solid' | 'dashed' | undefined
|
|
30
30
|
/** Interpolation between points. Default `'monotone'`. A piecewise-constant quantity (a price
|
|
31
31
|
* tier, a config value) is a step: a smoothed curve draws values that never existed. */
|
|
32
|
-
curve?: CurveName
|
|
33
|
-
strokeWidth?: number
|
|
32
|
+
curve?: CurveName | undefined
|
|
33
|
+
strokeWidth?: number | undefined
|
|
34
|
+
/**
|
|
35
|
+
* What a `null` value does to the line (and its `area`). `'break'` (default) leaves a gap;
|
|
36
|
+
* `'connect'` draws through the series' non-null points only, skipping the null rows, so a series
|
|
37
|
+
* that is null on most shared x values (one lift in a wide table) is one continuous line. Points,
|
|
38
|
+
* markers, the cursor and the tooltip still read real values: a null row has no tooltip row.
|
|
39
|
+
*/
|
|
40
|
+
gaps?: 'break' | 'connect' | undefined
|
|
34
41
|
/** Dims the plotted stroke (a faint companion line). Strokes only. */
|
|
35
|
-
strokeOpacity?: number
|
|
42
|
+
strokeOpacity?: number | undefined
|
|
36
43
|
/** Bars and areas. */
|
|
37
|
-
fillOpacity?: number
|
|
38
|
-
/** `Lines`: a soft gradient fill under the line — `true` in the series color,
|
|
39
|
-
|
|
44
|
+
fillOpacity?: number | undefined
|
|
45
|
+
/** `Lines`: a soft gradient fill under the line — `true` in the series color, a color ref, or
|
|
46
|
+
* `'zones'`: a vertical gradient coloured by the chart's `zones` on this series' axis, hard stops
|
|
47
|
+
* at the zone boundaries (no zone in reach: the series color). Linear axes only. */
|
|
48
|
+
area?: boolean | 'zones' | (string & {}) | undefined
|
|
40
49
|
/** Which y axis the series is measured against. `'right'` needs the chart's `y2`. */
|
|
41
|
-
axis?: 'left' | 'right'
|
|
50
|
+
axis?: 'left' | 'right' | undefined
|
|
42
51
|
/** Legend grouping: series → overlay → reference. Default `'series'`. */
|
|
43
|
-
role?: 'series' | 'overlay' | 'reference'
|
|
52
|
+
role?: 'series' | 'overlay' | 'reference' | undefined
|
|
44
53
|
/** `false` keeps it out of the legend (a companion line folded under `parent`). Default `true`. */
|
|
45
|
-
legend?: boolean
|
|
54
|
+
legend?: boolean | undefined
|
|
46
55
|
/** `false` draws and legends it but never lists it as a tooltip row. Default `true`. */
|
|
47
|
-
tooltip?: boolean
|
|
56
|
+
tooltip?: boolean | undefined
|
|
48
57
|
/** A companion (an MA line) names its parent, so a legend toggle and hover keep the pair. */
|
|
49
|
-
parent?: string
|
|
58
|
+
parent?: string | undefined
|
|
50
59
|
/** A short qualifier after the label in the legend and the tooltip row, in muted text. */
|
|
51
|
-
note?: string
|
|
60
|
+
note?: string | undefined
|
|
52
61
|
/** Per-series tooltip value format; receives the datum so a row can read more than the value. */
|
|
53
|
-
formatValue?: (v: number, d: T) => string
|
|
62
|
+
formatValue?: ((v: number, d: T) => string) | undefined
|
|
63
|
+
/**
|
|
64
|
+
* A cursor dot for a series whose mark the consumer draws itself (`marks`, so `getValue` stays
|
|
65
|
+
* `null`): the y of the dot at each datum, `null` for none. The dot shows while the cursor is on
|
|
66
|
+
* that datum's x and takes the series color. A series drawn from `plot.rows` already has one.
|
|
67
|
+
*/
|
|
68
|
+
cursorValue?: ((d: T) => number | null) | undefined
|
|
54
69
|
/** A marker at a point (a PR star, a status dot); `null` for none. */
|
|
55
|
-
getMarker?:
|
|
70
|
+
getMarker?:
|
|
71
|
+
| ((d: T) => {
|
|
72
|
+
color?: string | undefined
|
|
73
|
+
r?: number | undefined
|
|
74
|
+
fillOpacity?: number | undefined
|
|
75
|
+
ring?: boolean | undefined
|
|
76
|
+
} | null)
|
|
77
|
+
| undefined
|
|
56
78
|
}
|
|
57
79
|
|
|
58
80
|
/** One y axis. */
|
|
@@ -66,23 +88,33 @@ export type AxisConfig<T> = {
|
|
|
66
88
|
| readonly [number, number]
|
|
67
89
|
| 'auto'
|
|
68
90
|
| ((data: readonly T[], visible: readonly ChartSeries<T>[]) => readonly [number, number])
|
|
91
|
+
| undefined
|
|
69
92
|
/** When `'auto'`: the raw upper bound is at least this; padding applies after. */
|
|
70
|
-
autoMaxFloor?: number
|
|
93
|
+
autoMaxFloor?: number | undefined
|
|
71
94
|
/** When `'auto'`: the lower bound is at most this. Default `0` (a zero baseline); `Infinity` pads
|
|
72
95
|
* down from the data minimum instead. Ignored on a log axis. */
|
|
73
|
-
autoMinCeil?: number
|
|
96
|
+
autoMinCeil?: number | undefined
|
|
74
97
|
/** When `'auto'`: padding multiplier away from the data. Default `1.1`. */
|
|
75
|
-
autoPad?: number
|
|
98
|
+
autoPad?: number | undefined
|
|
76
99
|
/** A tick count, or the exact tick values. Default: one per ~48px of plot height, ≤ 5. */
|
|
77
|
-
ticks?: number | readonly number[]
|
|
100
|
+
ticks?: number | readonly number[] | undefined
|
|
78
101
|
/** Tick and tooltip formatter. Default: compact numbers (`12.5k`). */
|
|
79
|
-
format?: (v: number) => string
|
|
102
|
+
format?: ((v: number) => string) | undefined
|
|
80
103
|
/** Horizontal grid rules. Default: on for `y`, off for `y2`. */
|
|
81
|
-
grid?: boolean
|
|
104
|
+
grid?: boolean | undefined
|
|
82
105
|
/** Round the domain outward to nice ticks. Default `false` (a fixed domain stays fixed). */
|
|
83
|
-
nice?: boolean
|
|
106
|
+
nice?: boolean | undefined
|
|
84
107
|
/** Default `'linear'`. A log axis floors at the smallest positive visible value. */
|
|
85
|
-
scale?: 'linear' | 'log'
|
|
108
|
+
scale?: 'linear' | 'log' | undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A rule along a band's boundary, painted with the band. A bound at the plot edge draws none. */
|
|
112
|
+
export type ZoneEdge<S extends string> = {
|
|
113
|
+
side: S | 'both'
|
|
114
|
+
color: string
|
|
115
|
+
dashed?: boolean | undefined
|
|
116
|
+
/** Default `1`. */
|
|
117
|
+
width?: number | undefined
|
|
86
118
|
}
|
|
87
119
|
|
|
88
120
|
/** A horizontal band on one y axis. `±Infinity` reaches the plot edge. */
|
|
@@ -90,7 +122,10 @@ export type ZoneSpec = {
|
|
|
90
122
|
from: number
|
|
91
123
|
to: number
|
|
92
124
|
fill: string
|
|
93
|
-
axisSide?: 'left' | 'right'
|
|
125
|
+
axisSide?: 'left' | 'right' | undefined
|
|
126
|
+
/** A rule on the band's visual `top` (the larger bound) and/or `bottom` (the smaller), whichever
|
|
127
|
+
* of `from`/`to` that is. */
|
|
128
|
+
edge?: ZoneEdge<'top' | 'bottom'> | undefined
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
/**
|
|
@@ -99,18 +134,27 @@ export type ZoneSpec = {
|
|
|
99
134
|
* from one hour to another, both hours included).
|
|
100
135
|
*/
|
|
101
136
|
export type XZoneSpec<X extends ChartX = ChartX> = {
|
|
102
|
-
from?: X
|
|
103
|
-
to?: X
|
|
137
|
+
from?: X | undefined
|
|
138
|
+
to?: X | undefined
|
|
104
139
|
fill: string
|
|
105
|
-
align?: 'center' | 'edge'
|
|
140
|
+
align?: 'center' | 'edge' | undefined
|
|
141
|
+
/** A rule on the band's `start` (`from`) and/or `end` (`to`) boundary; an omitted bound has none. */
|
|
142
|
+
edge?: ZoneEdge<'start' | 'end'> | undefined
|
|
106
143
|
}
|
|
107
144
|
|
|
108
145
|
/** A horizontal reference line — annotation only, no domain role beyond staying inside the plot. */
|
|
109
146
|
export type RefLine = {
|
|
110
147
|
value: number
|
|
111
148
|
color: string
|
|
112
|
-
dashed?: boolean
|
|
113
|
-
axis?: 'left' | 'right'
|
|
149
|
+
dashed?: boolean | undefined
|
|
150
|
+
axis?: 'left' | 'right' | undefined
|
|
151
|
+
/**
|
|
152
|
+
* Names the line in the legend (its color, dashed as `dashed`; a toggle hides the line), so a
|
|
153
|
+
* threshold needs no always-`null` series. `mark` is the swatch shape. Default `'line'`. The
|
|
154
|
+
* toggle state is keyed by the line's index in `refLines`: reordering the array while a line is
|
|
155
|
+
* toggled off moves the "off" to whichever line now sits at that index.
|
|
156
|
+
*/
|
|
157
|
+
legend?: { label: string; mark?: 'line' | 'bar' | undefined } | undefined
|
|
114
158
|
}
|
|
115
159
|
|
|
116
160
|
/** A fill between the line and a value, on one side of it, tracking the line. */
|
|
@@ -128,50 +172,54 @@ export type ChartTooltipRow = {
|
|
|
128
172
|
label: string
|
|
129
173
|
value: string
|
|
130
174
|
/** The row swatch; omitted, the row has none. */
|
|
131
|
-
color?: string
|
|
175
|
+
color?: string | undefined
|
|
176
|
+
/** The swatch shape; `'dashed'` for a reference or dashed series. Default `'solid'`. */
|
|
177
|
+
swatch?: 'solid' | 'dashed' | undefined
|
|
132
178
|
}
|
|
133
179
|
|
|
134
180
|
export type ChartTooltipConfig<T, X extends ChartX = ChartX> = {
|
|
135
181
|
/** The tooltip title. Default: the x value through `formatX`'s long form (a date reads
|
|
136
182
|
* `Mon Sep 1 2026`). */
|
|
137
|
-
formatHeader?: (x: X, d: T) => string
|
|
183
|
+
formatHeader?: ((x: X, d: T) => string) | undefined
|
|
138
184
|
/** Rows after the per-series rows (a total, a zone badge, a context line). `visible`/`hidden` are
|
|
139
185
|
* the legend's split, so a row never names a series the plot no longer draws. */
|
|
140
|
-
rows?:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
186
|
+
rows?:
|
|
187
|
+
| ((
|
|
188
|
+
d: T,
|
|
189
|
+
ctx: { visible: readonly ChartSeries<T>[]; hidden: readonly ChartSeries<T>[] },
|
|
190
|
+
) => readonly ChartTooltipRow[])
|
|
191
|
+
| undefined
|
|
144
192
|
/** Show this chart's tooltip while it FOLLOWS a sibling's cursor. Default `false`: only the chart
|
|
145
193
|
* under the pointer or keyboard focus shows a tooltip and a live region. */
|
|
146
|
-
onFollow?: boolean
|
|
194
|
+
onFollow?: boolean | undefined
|
|
147
195
|
}
|
|
148
196
|
|
|
149
197
|
export type ChartLegendConfig = {
|
|
150
198
|
/** Default `'bottom'`. `'header'` draws the legend at the top of the chart body, spaced as the
|
|
151
199
|
* enclosing `ChartCard`'s header legend row (in the chart's own markup, so it is in the server
|
|
152
200
|
* HTML and the header never grows); outside a card it is `'top'`. */
|
|
153
|
-
placement?: 'top' | 'bottom' | 'header'
|
|
201
|
+
placement?: 'top' | 'bottom' | 'header' | undefined
|
|
154
202
|
/** Visually separate roles: series | overlay | reference. */
|
|
155
|
-
groups?: boolean
|
|
203
|
+
groups?: boolean | undefined
|
|
156
204
|
/** Clicking an entry hides that series. Default: on whenever there are two or more entries. */
|
|
157
|
-
toggle?: boolean
|
|
205
|
+
toggle?: boolean | undefined
|
|
158
206
|
}
|
|
159
207
|
|
|
160
208
|
/** A height per container class, `base` below `regular`. A plain number is fixed. */
|
|
161
209
|
export type ResponsiveChartHeight = {
|
|
162
210
|
base: number
|
|
163
|
-
regular?: number
|
|
164
|
-
wide?: number
|
|
211
|
+
regular?: number | undefined
|
|
212
|
+
wide?: number | undefined
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
/** The three "nothing to draw" states, pending → error → empty. */
|
|
168
216
|
export type ChartState = {
|
|
169
|
-
pending?: boolean
|
|
217
|
+
pending?: boolean | undefined
|
|
170
218
|
/** Truthy = the query failed; an `Error`'s message is the label. */
|
|
171
219
|
error?: unknown
|
|
172
220
|
/** `true`, or the empty label itself. The label is the one way to say WHY (`'No runs match the
|
|
173
221
|
* filter'` vs `'No runs yet'`): § 9 fixes the state shape, so there is no reason field. */
|
|
174
|
-
empty?: boolean | string
|
|
222
|
+
empty?: boolean | string | undefined
|
|
175
223
|
}
|
|
176
224
|
|
|
177
225
|
/** A row of `plot.rows(key)`: one per datum, `y: null` kept as a gap. */
|
|
@@ -215,27 +263,27 @@ export type CartesianChartProps<T, X extends ChartX = ChartX> = BasaltProps & {
|
|
|
215
263
|
series: readonly ChartSeries<T>[]
|
|
216
264
|
/** The plot's marks. Draw `plot.rows(key)` / `plot.visible`, never `series` directly. */
|
|
217
265
|
marks: (plot: PlotContext<T, X>) => ChartMarks
|
|
218
|
-
y?: AxisConfig<T>
|
|
266
|
+
y?: AxisConfig<T> | undefined
|
|
219
267
|
/** A right axis; series with `axis: 'right'` and marks bound to `plot.y2` read it. */
|
|
220
|
-
y2?: AxisConfig<T>
|
|
268
|
+
y2?: AxisConfig<T> | undefined
|
|
221
269
|
/** A tick count, or the exact x tick values. Default: by container class. */
|
|
222
|
-
xTicks?: number | readonly X[]
|
|
270
|
+
xTicks?: number | readonly X[] | undefined
|
|
223
271
|
/** Tick label for an x value. Default: `DD.MM` for dates, compact numbers, strings verbatim. */
|
|
224
|
-
formatX?: (x: X) => string
|
|
225
|
-
zones?: readonly ZoneSpec[]
|
|
226
|
-
xZones?: readonly XZoneSpec<X>[]
|
|
227
|
-
refLines?: readonly RefLine[]
|
|
272
|
+
formatX?: ((x: X) => string) | undefined
|
|
273
|
+
zones?: readonly ZoneSpec[] | undefined
|
|
274
|
+
xZones?: readonly XZoneSpec<X>[] | undefined
|
|
275
|
+
refLines?: readonly RefLine[] | undefined
|
|
228
276
|
/** `false` hides the legend. */
|
|
229
|
-
legend?: ChartLegendConfig | false
|
|
277
|
+
legend?: ChartLegendConfig | false | undefined
|
|
230
278
|
/** Default: the height law (measured width × class ratio, 160–420px, ≤ 45vh on touch). */
|
|
231
|
-
height?: number | ResponsiveChartHeight
|
|
232
|
-
state?: ChartState
|
|
279
|
+
height?: number | ResponsiveChartHeight | undefined
|
|
280
|
+
state?: ChartState | undefined
|
|
233
281
|
/** Joins a named cursor group instead of the page-shared default. */
|
|
234
|
-
cursorGroup?: string
|
|
282
|
+
cursorGroup?: string | undefined
|
|
235
283
|
/** Default `'nearest'`. `'leading'` for a series keyed by a bucket's leading edge (a weekly
|
|
236
284
|
* value keyed by its Monday). */
|
|
237
|
-
cursorResolution?: CursorResolution
|
|
238
|
-
tooltip?: ChartTooltipConfig<T, X> | false
|
|
285
|
+
cursorResolution?: CursorResolution | undefined
|
|
286
|
+
tooltip?: ChartTooltipConfig<T, X> | false | undefined
|
|
239
287
|
}
|
|
240
288
|
|
|
241
289
|
/** The x channel of a `plot.rows()` row, as an accessor (a field name does not type-check against a
|
package/src/state/fields.ts
CHANGED
|
@@ -279,6 +279,24 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
|
|
|
279
279
|
readonly step?: undefined
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* `createLocalStore`'s `use()` read options. `fallback` is a CALL-SITE default for a value that
|
|
284
|
+
* arrives as a prop (a leader, a latest date): it applies only while the field is unset (nothing
|
|
285
|
+
* stored), is applied in render AFTER the store read, and is never persisted — `clear()` unsets
|
|
286
|
+
* back to it. `handle.fallback` and `isDefault` still report the definition's.
|
|
287
|
+
*
|
|
288
|
+
* An object fallback (range, multi) must be memoised by the caller: the value returned while unset
|
|
289
|
+
* IS that object, so a fresh literal per render changes identity every render.
|
|
290
|
+
*/
|
|
291
|
+
export type FieldReadOptions<F extends AnyField> = { fallback?: FieldValue<F> }
|
|
292
|
+
|
|
293
|
+
/** A `createLocalStore` field: the shared handle, plus `use({ fallback })`. */
|
|
294
|
+
export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
|
|
295
|
+
use(
|
|
296
|
+
read?: FieldReadOptions<F>,
|
|
297
|
+
): readonly [FieldValue<F>, (next: FieldValue<F>, opts?: FieldSetOptions) => void]
|
|
298
|
+
}
|
|
299
|
+
|
|
282
300
|
/**
|
|
283
301
|
* The binding every basalt control takes instead of `value`/`onChange` (C2). One field, both lanes,
|
|
284
302
|
* one resolution law. Handed out by `createSearchStore().field.<name>` /
|
|
@@ -771,7 +789,7 @@ type StoreRecord = Record<string, unknown>
|
|
|
771
789
|
export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
|
|
772
790
|
|
|
773
791
|
/** One field's `use()`, untyped. @internal — see `FieldCodec`. */
|
|
774
|
-
export type FieldUse = () => readonly [unknown, FieldWrite]
|
|
792
|
+
export type FieldUse = (read?: FieldReadOptions<AnyField>) => readonly [unknown, FieldWrite]
|
|
775
793
|
|
|
776
794
|
/** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
|
|
777
795
|
export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
|
|
@@ -798,7 +816,12 @@ export type StoreCore = {
|
|
|
798
816
|
setLabels(map: Record<string, Record<string, string> | undefined>): void
|
|
799
817
|
optionsFor(entry: StoreEntry): readonly FieldOption[]
|
|
800
818
|
/** The resolution law, once: URL ⊳ localStorage ⊳ fallback. `search: null` skips the URL lane. */
|
|
801
|
-
resolve(
|
|
819
|
+
resolve(
|
|
820
|
+
entry: StoreEntry,
|
|
821
|
+
search: StoreRecord | null,
|
|
822
|
+
record: StoreRecord,
|
|
823
|
+
fallback?: unknown,
|
|
824
|
+
): unknown
|
|
802
825
|
writeField(setRecord: (next: StoreRecord) => void, name: string, value: unknown): void
|
|
803
826
|
/**
|
|
804
827
|
* The IN-MEMORY lane's `use()` for one field — a field with neither the URL nor the mirror.
|
|
@@ -841,14 +864,19 @@ function assignDefined(into: StoreRecord, from: StoreRecord): void {
|
|
|
841
864
|
* The resolution law, in one place: URL ⊳ localStorage ⊳ fallback (C4). `search: null` skips the
|
|
842
865
|
* URL lane — what the local lane and `linkSearch` pass.
|
|
843
866
|
*/
|
|
844
|
-
function resolveField(
|
|
867
|
+
function resolveField(
|
|
868
|
+
entry: StoreEntry,
|
|
869
|
+
search: StoreRecord | null,
|
|
870
|
+
record: StoreRecord,
|
|
871
|
+
fallback?: unknown,
|
|
872
|
+
): unknown {
|
|
845
873
|
const fromUrl = search === null ? null : entry.codec.fromSearch(search)
|
|
846
874
|
if (fromUrl !== null) return fromUrl
|
|
847
875
|
if (entry.codec.lane.persist) {
|
|
848
876
|
const stored = entry.codec.decode(record[entry.name])
|
|
849
877
|
if (stored !== null) return stored
|
|
850
878
|
}
|
|
851
|
-
return entry.codec.fallback
|
|
879
|
+
return fallback === undefined ? entry.codec.fallback : fallback
|
|
852
880
|
}
|
|
853
881
|
|
|
854
882
|
/**
|
|
@@ -865,7 +893,7 @@ function resolveField(entry: StoreEntry, search: StoreRecord | null, record: Sto
|
|
|
865
893
|
function createMemoryLane(): {
|
|
866
894
|
subscribe: (cb: () => void) => () => void
|
|
867
895
|
getVersion: () => number
|
|
868
|
-
resolve: (entry: StoreEntry) => unknown
|
|
896
|
+
resolve: (entry: StoreEntry, fallback?: unknown) => unknown
|
|
869
897
|
write: (name: string, value: unknown) => void
|
|
870
898
|
remove: (name: string) => void
|
|
871
899
|
clear: () => void
|
|
@@ -889,9 +917,10 @@ function createMemoryLane(): {
|
|
|
889
917
|
getVersion: () => version,
|
|
890
918
|
// `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
|
|
891
919
|
// as a legal value, so it can never collide with a written one.
|
|
892
|
-
resolve: (entry) => {
|
|
920
|
+
resolve: (entry, fallback) => {
|
|
893
921
|
const stored = values.get(entry.name)
|
|
894
|
-
|
|
922
|
+
if (stored !== undefined) return stored
|
|
923
|
+
return fallback === undefined ? entry.codec.fallback : fallback
|
|
895
924
|
},
|
|
896
925
|
write: (name, value) => {
|
|
897
926
|
values.set(name, value)
|
|
@@ -1036,11 +1065,14 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
1036
1065
|
},
|
|
1037
1066
|
readRecord,
|
|
1038
1067
|
memoryUse(entry) {
|
|
1039
|
-
return () => {
|
|
1068
|
+
return (read) => {
|
|
1040
1069
|
// Subscribing to the VERSION (never to the value) is what re-renders every other mount of
|
|
1041
1070
|
// this field when one of them writes, with no snapshot-stability trap.
|
|
1042
1071
|
useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion)
|
|
1043
|
-
return [
|
|
1072
|
+
return [
|
|
1073
|
+
memory.resolve(entry, read?.fallback),
|
|
1074
|
+
(next: unknown) => memory.write(entry.name, next),
|
|
1075
|
+
] as const
|
|
1044
1076
|
}
|
|
1045
1077
|
},
|
|
1046
1078
|
readMemoryValue: (entry) => memory.resolve(entry),
|
|
@@ -1074,7 +1106,7 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
1074
1106
|
|
|
1075
1107
|
/** `Pick<SearchStore<S>, 'field' | 'readStored' | 'labels'>` — the same handles, no router. */
|
|
1076
1108
|
export type LocalStore<S extends Record<string, AnyField>> = {
|
|
1077
|
-
readonly field: { [K in keyof S]:
|
|
1109
|
+
readonly field: { [K in keyof S]: LocalFieldHandle<S[K]> }
|
|
1078
1110
|
readStored: () => Partial<StoredValues<S>>
|
|
1079
1111
|
/**
|
|
1080
1112
|
* Option labels for enum / multi / range fields — the same chainable contract
|
|
@@ -1116,20 +1148,24 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
|
|
|
1116
1148
|
}): LocalStore<S> {
|
|
1117
1149
|
const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version })
|
|
1118
1150
|
|
|
1119
|
-
const persistedUse =
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
core.
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1151
|
+
const persistedUse =
|
|
1152
|
+
(entry: StoreEntry): FieldUse =>
|
|
1153
|
+
(read) => {
|
|
1154
|
+
const [record, setRecord] = core.usePersistedRecord()
|
|
1155
|
+
return [
|
|
1156
|
+
// The call-site fallback is applied here, in render, after the store read — never inside
|
|
1157
|
+
// the snapshot — so React's out-of-render snapshot reads cannot disagree with it.
|
|
1158
|
+
core.resolve(entry, null, record, read?.fallback),
|
|
1159
|
+
(next: unknown) => core.writeField(setRecord, entry.name, next),
|
|
1160
|
+
] as const
|
|
1161
|
+
}
|
|
1126
1162
|
|
|
1127
|
-
const field = {} as { [K in keyof S]:
|
|
1163
|
+
const field = {} as { [K in keyof S]: LocalFieldHandle<S[K]> }
|
|
1128
1164
|
for (const entry of core.entries) {
|
|
1129
1165
|
const persisted = entry.codec.lane.persist
|
|
1130
1166
|
const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
|
|
1131
1167
|
const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
|
|
1132
|
-
field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as
|
|
1168
|
+
field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as LocalFieldHandle<
|
|
1133
1169
|
S[keyof S]
|
|
1134
1170
|
>
|
|
1135
1171
|
}
|