basalt-ui 1.26.0 → 1.27.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 +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
package/src/state/fields.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* consumer.
|
|
11
11
|
*/
|
|
12
12
|
import { useSyncExternalStore } from 'react'
|
|
13
|
-
import { createPersistedState, readPersistedValue } from './persisted'
|
|
13
|
+
import { createPersistedState, readPersistedValue, writePersistedValue } from './persisted'
|
|
14
14
|
|
|
15
15
|
// ── Store fields — the vocabulary both store factories share ──────────────────────────────────
|
|
16
16
|
|
|
@@ -48,6 +48,28 @@ export type ResolveLane<L> = {
|
|
|
48
48
|
history: L extends { history: 'push' } ? 'push' : 'replace'
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A field's fallback: the value, or a THUNK evaluated at read time (`field.string({ fallback: () =>
|
|
53
|
+
* todayIso() })`). A thunk is re-evaluated on every read while nothing is written, and is never
|
|
54
|
+
* itself persisted — a write stores the value the control produced, like any other.
|
|
55
|
+
*
|
|
56
|
+
* The LOCAL and MEMORY lanes only. `createSearchStore` throws at definition for a thunk on a
|
|
57
|
+
* URL-lane field: `validateSearch` would evaluate it on every navigation and pin the result into the
|
|
58
|
+
* URL, so a deep link would carry a value nobody chose.
|
|
59
|
+
*/
|
|
60
|
+
export type FieldFallback<T> = T | (() => T)
|
|
61
|
+
|
|
62
|
+
/** Resolves a per-preset window at call time. Returns ISO dates — what `toWindow` hands an API. */
|
|
63
|
+
export type RangeWindow = (now: Date) => { from: string; to: string }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Per-preset window resolvers for a range field. A preset WITH one resolves through `toWindow()` to
|
|
67
|
+
* `{ from, to }`; a preset without keeps `{ window: preset }` — which is what lets a derived preset
|
|
68
|
+
* (`3m`, `ytd`) live in the same field as a server-understood one (`7d`) instead of forcing a
|
|
69
|
+
* hand-rolled `presetToParams` beside the store.
|
|
70
|
+
*/
|
|
71
|
+
export type RangeWindows<P extends string> = Partial<Record<P, RangeWindow>>
|
|
72
|
+
|
|
51
73
|
/** The value of a range field: one preset, plus two ISO dates when the preset is `'custom'`. */
|
|
52
74
|
export type RangeValue<P extends string> = {
|
|
53
75
|
preset: P
|
|
@@ -61,14 +83,14 @@ export type RangeParams = { preset?: string; from?: string; to?: string }
|
|
|
61
83
|
export type EnumField<T extends string = string, Ln extends ResolvedLane = ResolvedLane> = {
|
|
62
84
|
readonly kind: 'enum'
|
|
63
85
|
readonly values: readonly T[]
|
|
64
|
-
readonly fallback: T
|
|
86
|
+
readonly fallback: FieldFallback<T>
|
|
65
87
|
readonly lane: Ln
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
export type MultiField<T extends string = string, Ln extends ResolvedLane = ResolvedLane> = {
|
|
69
91
|
readonly kind: 'multi'
|
|
70
92
|
readonly values: readonly T[]
|
|
71
|
-
readonly fallback: readonly T[]
|
|
93
|
+
readonly fallback: FieldFallback<readonly T[]>
|
|
72
94
|
readonly lane: Ln
|
|
73
95
|
}
|
|
74
96
|
|
|
@@ -77,27 +99,44 @@ export type RangeField<
|
|
|
77
99
|
C extends boolean = boolean,
|
|
78
100
|
Pm extends RangeParams = RangeParams,
|
|
79
101
|
Ln extends ResolvedLane = ResolvedLane,
|
|
102
|
+
W extends string = never,
|
|
80
103
|
> = {
|
|
81
104
|
readonly kind: 'range'
|
|
82
105
|
/** The declared presets — `'custom'` is NOT one of them, it is `custom: true`'s consequence. */
|
|
83
106
|
readonly presets: readonly P[]
|
|
84
|
-
readonly fallback: RangeValue<P
|
|
107
|
+
readonly fallback: FieldFallback<RangeValue<P>>
|
|
85
108
|
/** `true` → the preset `'custom'` plus `from`/`to` ISO dates are legal values. */
|
|
86
109
|
readonly custom: C
|
|
87
110
|
readonly params: Pm
|
|
111
|
+
/**
|
|
112
|
+
* The declared window resolvers, keyed by preset — `W` is exactly the presets that HAVE one, so
|
|
113
|
+
* `toWindow`'s return type can drop them from its `{ window }` branch and an API param type
|
|
114
|
+
* accepts the result with no cast.
|
|
115
|
+
*
|
|
116
|
+
* `W` defaults to `never` (`window: {}`), which is what keeps every `RangeField<P>` assignable to
|
|
117
|
+
* the widened `AnyField` a control's props take: a narrower resolver map is assignable to `{}`,
|
|
118
|
+
* and a widened handle's `toWindow` then excludes nothing rather than everything.
|
|
119
|
+
*/
|
|
120
|
+
readonly window: RangeWindows<W>
|
|
88
121
|
readonly lane: Ln
|
|
89
122
|
}
|
|
90
123
|
|
|
91
124
|
/**
|
|
92
125
|
* The preset union a range field's VALUES range over: the declared presets, plus `'custom'` unless
|
|
93
|
-
* the field opted out.
|
|
94
|
-
*
|
|
126
|
+
* the field opted out.
|
|
127
|
+
*
|
|
128
|
+
* `C` widened to `boolean` (a bare `RangeField<P>`) keeps `'custom'` — but a PROP typed that way
|
|
129
|
+
* accepts only a custom-capable handle, because the handle's setter is contravariant in the value:
|
|
130
|
+
* `field.range` without `custom: true` hands out a `RangeField<P, false>`, whose setter refuses a
|
|
131
|
+
* `'custom'` preset. A wrapper that must take any range handle is generic over the flag
|
|
132
|
+
* (`<P extends string, C extends boolean>` + `FieldHandle<RangeField<P, C>>`); one that only ever
|
|
133
|
+
* takes the preset-only shape pins `RangeField<P, false>`.
|
|
95
134
|
*/
|
|
96
135
|
export type RangePresets<P, C> = (P & string) | (C extends false ? never : 'custom')
|
|
97
136
|
|
|
98
137
|
export type NumberField<Ln extends ResolvedLane = ResolvedLane> = {
|
|
99
138
|
readonly kind: 'number'
|
|
100
|
-
readonly fallback: number
|
|
139
|
+
readonly fallback: FieldFallback<number>
|
|
101
140
|
readonly min: number | undefined
|
|
102
141
|
readonly max: number | undefined
|
|
103
142
|
readonly int: boolean
|
|
@@ -106,13 +145,13 @@ export type NumberField<Ln extends ResolvedLane = ResolvedLane> = {
|
|
|
106
145
|
|
|
107
146
|
export type BooleanField<Ln extends ResolvedLane = ResolvedLane> = {
|
|
108
147
|
readonly kind: 'boolean'
|
|
109
|
-
readonly fallback: boolean
|
|
148
|
+
readonly fallback: FieldFallback<boolean>
|
|
110
149
|
readonly lane: Ln
|
|
111
150
|
}
|
|
112
151
|
|
|
113
152
|
export type StringField<Ln extends ResolvedLane = ResolvedLane> = {
|
|
114
153
|
readonly kind: 'string'
|
|
115
|
-
readonly fallback: string
|
|
154
|
+
readonly fallback: FieldFallback<string>
|
|
116
155
|
readonly max: number | undefined
|
|
117
156
|
readonly lane: Ln
|
|
118
157
|
}
|
|
@@ -144,6 +183,31 @@ export type FieldValue<F extends AnyField> = F extends {
|
|
|
144
183
|
? string
|
|
145
184
|
: never
|
|
146
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The second, optional argument of a `FieldHandle` setter.
|
|
188
|
+
*
|
|
189
|
+
* @example field.tab.use()[1]('overview', { patch: { detailDate: undefined } })
|
|
190
|
+
*/
|
|
191
|
+
export type FieldSetOptions = {
|
|
192
|
+
/**
|
|
193
|
+
* Extra search params merged into the SAME navigate as the field's own write — the URL lane only
|
|
194
|
+
* (a local/memory-lane write has no navigate to merge into, and ignores this). For keys the STORE
|
|
195
|
+
* DOES NOT OWN: clearing a sibling param the page put there (`{ detailDate: undefined }`) used to
|
|
196
|
+
* need a second `navigate` beside the setter, which either lost the field's write or produced two
|
|
197
|
+
* history entries. The field's own params always win, so a `patch` cannot corrupt the value being
|
|
198
|
+
* set.
|
|
199
|
+
*
|
|
200
|
+
* A key ANOTHER field of the same store owns is refused — it would reach the URL while the
|
|
201
|
+
* mirror kept the old value, so the next paramless visit and every `linkSearch` link disagree
|
|
202
|
+
* with the page the write happened on. `createSearchStore` throws in dev and logs in production;
|
|
203
|
+
* write that field through its own setter instead.
|
|
204
|
+
*
|
|
205
|
+
* A write from a route that does not validate the field persists only (A1) and drops the patch
|
|
206
|
+
* with it — there is no navigate to merge into. Dev warns once per field.
|
|
207
|
+
*/
|
|
208
|
+
readonly patch?: Record<string, unknown>
|
|
209
|
+
}
|
|
210
|
+
|
|
147
211
|
/** One option of an enum / multi / range field, labelled through `store.labels()`. */
|
|
148
212
|
export type FieldOption = { value: string; label: string }
|
|
149
213
|
|
|
@@ -152,18 +216,54 @@ type RangeHandleExtras<F> = F extends {
|
|
|
152
216
|
kind: 'range'
|
|
153
217
|
presets: readonly (infer P)[]
|
|
154
218
|
custom: infer C
|
|
219
|
+
window: RangeWindows<infer W>
|
|
155
220
|
}
|
|
156
221
|
? {
|
|
157
222
|
/**
|
|
158
223
|
* The API-facing projection of a range value: a preset becomes `{ window }`, a custom range
|
|
159
224
|
* becomes `{ from, to }`. Replaces every hand-rolled `presetToParams`.
|
|
225
|
+
*
|
|
226
|
+
* A preset declared with a `window` resolver (`field.range({ window: { '3m': ... } })`)
|
|
227
|
+
* becomes `{ from, to }` too — the resolver runs at CALL time with the current `Date`, so a
|
|
228
|
+
* derived window (`3m`, `ytd`) is never stale and never leaves the store.
|
|
229
|
+
*
|
|
230
|
+
* Those presets are EXCLUDED from the `{ window }` branch, so the result assigns to an API
|
|
231
|
+
* param type that only knows the server-understood windows with no cast — which is the half
|
|
232
|
+
* that lets a consumer delete the switch rather than move it. A `custom: true` field keeps
|
|
233
|
+
* `'custom'` in the union: a custom preset with no dates resolves to `{ window: 'custom' }`,
|
|
234
|
+
* so that one still needs a guard before it reaches such a type.
|
|
160
235
|
*/
|
|
161
236
|
readonly toWindow: (
|
|
162
237
|
v: RangeValue<RangePresets<P, C>>,
|
|
163
|
-
) => { window: RangePresets<P, C> } | { from: string; to: string }
|
|
238
|
+
) => { window: Exclude<RangePresets<P, C>, W> } | { from: string; to: string }
|
|
164
239
|
}
|
|
165
240
|
: { readonly toWindow?: undefined }
|
|
166
241
|
|
|
242
|
+
/**
|
|
243
|
+
* A number handle's declared bounds, republished from the field — `RangeHandleExtras`' shape for the
|
|
244
|
+
* numeric kind, and there for the same reason: a control cannot ask the field descriptor anything,
|
|
245
|
+
* it only ever sees the handle.
|
|
246
|
+
*
|
|
247
|
+
* The codec has always CLAMPED to these on write, so before this the only way a `NumberFilter` could
|
|
248
|
+
* learn its own limits was for the call site to pass them a second time — a second answer to a
|
|
249
|
+
* question the field already owns, and one that silently stops matching the moment the field moves.
|
|
250
|
+
* With them on the handle the input can bound its own stepper, which turns the clamp from a
|
|
251
|
+
* correction the user watches happen into a value they cannot type in the first place.
|
|
252
|
+
*
|
|
253
|
+
* `int` is the third member because it is the same class of fact: it decides the stepper's grain
|
|
254
|
+
* (`step` 1, decimals refused), and the codec already rejects a non-integer for that field.
|
|
255
|
+
*/
|
|
256
|
+
type NumberHandleExtras<F> = F extends { kind: 'number' }
|
|
257
|
+
? {
|
|
258
|
+
/** The field's `min`, or `undefined` when it declared none. Clamped on write regardless. */
|
|
259
|
+
readonly min: number | undefined
|
|
260
|
+
/** The field's `max`, or `undefined` when it declared none. Clamped on write regardless. */
|
|
261
|
+
readonly max: number | undefined
|
|
262
|
+
/** `field.number({ int: true })` — a non-integer is refused by the codec, not rounded. */
|
|
263
|
+
readonly int: boolean
|
|
264
|
+
}
|
|
265
|
+
: { readonly min?: undefined; readonly max?: undefined; readonly int?: undefined }
|
|
266
|
+
|
|
167
267
|
/**
|
|
168
268
|
* The binding every basalt control takes instead of `value`/`onChange` (C2). One field, both lanes,
|
|
169
269
|
* one resolution law. Handed out by `createSearchStore().field.<name>` /
|
|
@@ -179,12 +279,97 @@ export type FieldHandle<F extends AnyField> = {
|
|
|
179
279
|
* so a control renders on a sibling or child route with no `from`), else localStorage, else the
|
|
180
280
|
* fallback. Writes navigate when the matched route validates the param, then persist.
|
|
181
281
|
*/
|
|
182
|
-
use(): readonly [FieldValue<F>, (next: FieldValue<F
|
|
282
|
+
use(): readonly [FieldValue<F>, (next: FieldValue<F>, opts?: FieldSetOptions) => void]
|
|
283
|
+
/**
|
|
284
|
+
* UNSET the field — what a control's reset calls, and deliberately not `set(fallback)`. The
|
|
285
|
+
* persist lane DELETES the field's key from the mirror, the memory lane drops its value, and the
|
|
286
|
+
* URL lane navigates back to the fallback params (plus dropping the mirror key under it).
|
|
287
|
+
*
|
|
288
|
+
* Writing the fallback instead pins it: a THUNK fallback (`() => todayIso()`) resolves at the
|
|
289
|
+
* moment of the reset, so a reader who pressed `Reset all` today was handed today's date
|
|
290
|
+
* tomorrow, with the field counted as active in `Filters (n)`. Unsetting lets the fallback keep
|
|
291
|
+
* resolving, which is what `FieldFallback`'s contract says it does.
|
|
292
|
+
*
|
|
293
|
+
* Callable outside render (an event handler is the only realistic caller); on the URL lane it
|
|
294
|
+
* navigates through the last render of `use()`, so a handle that no mount has read clears the
|
|
295
|
+
* mirror only.
|
|
296
|
+
*/
|
|
297
|
+
clear(): void
|
|
183
298
|
isDefault(v: FieldValue<F>): boolean
|
|
184
|
-
} & RangeHandleExtras<F>
|
|
299
|
+
} & RangeHandleExtras<F> &
|
|
300
|
+
NumberHandleExtras<F>
|
|
185
301
|
|
|
186
302
|
// ── field.* — declarative field builders ──────────────────────────────────────────────────────
|
|
187
303
|
|
|
304
|
+
/**
|
|
305
|
+
* `field.range`'s options. `C` is the `custom` flag, pinned per overload — never inferred; `W` is
|
|
306
|
+
* inferred from `window`'s KEYS (constrained to the declared presets, so a typo is still an error
|
|
307
|
+
* here) and is what carries the resolved presets into `toWindow`'s return type.
|
|
308
|
+
*/
|
|
309
|
+
type RangeOptions<P extends string, C extends boolean, Pm extends RangeParams, W extends P> = {
|
|
310
|
+
presets: readonly P[]
|
|
311
|
+
fallback: FieldFallback<NoInfer<P>>
|
|
312
|
+
custom?: C
|
|
313
|
+
params?: Pm
|
|
314
|
+
/** Per-preset window resolvers — the presets `toWindow()` answers with `{ from, to }`. */
|
|
315
|
+
window?: RangeWindows<W>
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A time window: THREE URL params (preset + `from` + `to`, each renamable through `params`), so a
|
|
320
|
+
* consumer's existing deep links and loaders keep their shape. `custom: true` additionally
|
|
321
|
+
* allows the preset `'custom'` with two ISO dates. `window` resolves a DERIVED preset (`3m`, `ytd`)
|
|
322
|
+
* to `{ from, to }` through `toWindow()` while it stays one preset in the URL.
|
|
323
|
+
*
|
|
324
|
+
* A second `field.range` in the same store — or a second range store composed into the same
|
|
325
|
+
* route's `validateSearch` — MUST rename `from`/`to` via `params`: every range defaults to the
|
|
326
|
+
* literal param names `'from'`/`'to'`, and two fields silently sharing them makes the later one's
|
|
327
|
+
* `toSearch` overwrite the earlier one's dates. `createStoreCore` throws at definition for the
|
|
328
|
+
* in-store case; the cross-store case is not detectable by either store alone.
|
|
329
|
+
*
|
|
330
|
+
* THREE overloads, one per `custom` shape, because a single `custom?: C` signature is inferred
|
|
331
|
+
* against `AnyField` when the call sits inline in `createSearchStore({ fields })` — the contextual
|
|
332
|
+
* return type wins, `C` widens to `boolean`, and every value type gains a `'custom'` preset the
|
|
333
|
+
* field never allowed. Overloads have no `C` to widen: an omitted or `false` flag picks the first,
|
|
334
|
+
* a literal `true` the second, and a value typed `boolean` the third (widened, as declared).
|
|
335
|
+
*/
|
|
336
|
+
function rangeField<
|
|
337
|
+
const P extends string,
|
|
338
|
+
const Pm extends RangeParams = RangeParams,
|
|
339
|
+
const L extends FieldLane = FieldLane,
|
|
340
|
+
const W extends P = never,
|
|
341
|
+
>(o: RangeOptions<P, false, Pm, W>, lane?: L): RangeField<P, false, Pm, ResolveLane<L>, W>
|
|
342
|
+
function rangeField<
|
|
343
|
+
const P extends string,
|
|
344
|
+
const Pm extends RangeParams = RangeParams,
|
|
345
|
+
const L extends FieldLane = FieldLane,
|
|
346
|
+
const W extends P = never,
|
|
347
|
+
>(o: RangeOptions<P, true, Pm, W>, lane?: L): RangeField<P, true, Pm, ResolveLane<L>, W>
|
|
348
|
+
function rangeField<
|
|
349
|
+
const P extends string,
|
|
350
|
+
const Pm extends RangeParams = RangeParams,
|
|
351
|
+
const L extends FieldLane = FieldLane,
|
|
352
|
+
const W extends P = never,
|
|
353
|
+
>(o: RangeOptions<P, boolean, Pm, W>, lane?: L): RangeField<P, boolean, Pm, ResolveLane<L>, W>
|
|
354
|
+
function rangeField(
|
|
355
|
+
o: RangeOptions<string, boolean, RangeParams, string>,
|
|
356
|
+
lane?: FieldLane,
|
|
357
|
+
): RangeField<string, boolean, RangeParams, ResolvedLane, string> {
|
|
358
|
+
const { fallback } = o
|
|
359
|
+
return {
|
|
360
|
+
kind: 'range',
|
|
361
|
+
presets: o.presets,
|
|
362
|
+
// A thunk fallback stays a thunk — wrapped so the lazy value is read at the same moment every
|
|
363
|
+
// other lane reads one, not once here at definition.
|
|
364
|
+
fallback:
|
|
365
|
+
typeof fallback === 'function' ? () => ({ preset: fallback() }) : { preset: fallback },
|
|
366
|
+
custom: o.custom ?? false,
|
|
367
|
+
params: o.params ?? {},
|
|
368
|
+
window: o.window ?? {},
|
|
369
|
+
lane: resolveLane(lane),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
188
373
|
/**
|
|
189
374
|
* The field vocabulary. Every builder returns plain declarative data — no closures, no router, no
|
|
190
375
|
* storage — which is what lets one field definition drive `createSearchStore` (URL + storage) and
|
|
@@ -206,7 +391,7 @@ export const field = {
|
|
|
206
391
|
/** A closed string enum — a tab, a compare mode, a single-select filter. */
|
|
207
392
|
enum<const T extends string, const L extends FieldLane = FieldLane>(
|
|
208
393
|
values: readonly T[],
|
|
209
|
-
fallback: NoInfer<T
|
|
394
|
+
fallback: FieldFallback<NoInfer<T>>,
|
|
210
395
|
lane?: L,
|
|
211
396
|
): EnumField<T, ResolveLane<L>> {
|
|
212
397
|
return { kind: 'enum', values, fallback, lane: resolveLane(lane) as ResolveLane<L> }
|
|
@@ -215,47 +400,17 @@ export const field = {
|
|
|
215
400
|
/** An any-of set over the same closed enum. Decoding is deduped and canonically re-sorted. */
|
|
216
401
|
multi<const T extends string, const L extends FieldLane = FieldLane>(
|
|
217
402
|
values: readonly T[],
|
|
218
|
-
fallback: readonly NoInfer<T>[] = [],
|
|
403
|
+
fallback: FieldFallback<readonly NoInfer<T>[]> = [],
|
|
219
404
|
lane?: L,
|
|
220
405
|
): MultiField<T, ResolveLane<L>> {
|
|
221
406
|
return { kind: 'multi', values, fallback, lane: resolveLane(lane) as ResolveLane<L> }
|
|
222
407
|
},
|
|
223
408
|
|
|
224
|
-
|
|
225
|
-
* A time window: THREE URL params (preset + `from` + `to`, each renamable through `params`), so a
|
|
226
|
-
* consumer's existing deep links and loaders keep their shape. `custom: true` additionally
|
|
227
|
-
* allows the preset `'custom'` with two ISO dates.
|
|
228
|
-
*
|
|
229
|
-
* A second `field.range` in the same store — or a second range store composed into the same
|
|
230
|
-
* route's `validateSearch` — MUST rename `from`/`to` via `params`: every range defaults to the
|
|
231
|
-
* literal param names `'from'`/`'to'`, and two fields silently sharing them makes the later one's
|
|
232
|
-
* `toSearch` overwrite the earlier one's dates. `createStoreCore` throws at definition for the
|
|
233
|
-
* in-store case; the cross-store case is not detectable by either store alone.
|
|
234
|
-
*/
|
|
235
|
-
range<
|
|
236
|
-
const P extends string,
|
|
237
|
-
const C extends boolean = false,
|
|
238
|
-
const Pm extends RangeParams = RangeParams,
|
|
239
|
-
const L extends FieldLane = FieldLane,
|
|
240
|
-
>(
|
|
241
|
-
o: { presets: readonly P[]; fallback: NoInfer<P>; custom?: C; params?: Pm },
|
|
242
|
-
lane?: L,
|
|
243
|
-
): RangeField<P, C, Pm, ResolveLane<L>> {
|
|
244
|
-
return {
|
|
245
|
-
kind: 'range',
|
|
246
|
-
presets: o.presets,
|
|
247
|
-
fallback: { preset: o.fallback },
|
|
248
|
-
// The only cast left, and it is narrow: `C` is inferred from this very literal, so the
|
|
249
|
-
// runtime boolean IS `C` — `?? false` is what the compiler cannot see through.
|
|
250
|
-
custom: (o.custom ?? false) as C,
|
|
251
|
-
params: (o.params ?? {}) as Pm,
|
|
252
|
-
lane: resolveLane(lane) as ResolveLane<L>,
|
|
253
|
-
}
|
|
254
|
-
},
|
|
409
|
+
range: rangeField,
|
|
255
410
|
|
|
256
411
|
/** A number — pagination, a threshold. Out-of-range input clamps to `min`/`max`. */
|
|
257
412
|
number<const L extends FieldLane = FieldLane>(
|
|
258
|
-
o: { fallback: number
|
|
413
|
+
o: { fallback: FieldFallback<number>; min?: number; max?: number; int?: boolean },
|
|
259
414
|
lane?: L,
|
|
260
415
|
): NumberField<ResolveLane<L>> {
|
|
261
416
|
return {
|
|
@@ -270,7 +425,7 @@ export const field = {
|
|
|
270
425
|
|
|
271
426
|
/** A toggle. Accepts `true`/`false` and the strings `'true'`/`'false'` from a hand-typed URL. */
|
|
272
427
|
boolean<const L extends FieldLane = FieldLane>(
|
|
273
|
-
fallback: boolean
|
|
428
|
+
fallback: FieldFallback<boolean>,
|
|
274
429
|
lane?: L,
|
|
275
430
|
): BooleanField<ResolveLane<L>> {
|
|
276
431
|
return { kind: 'boolean', fallback, lane: resolveLane(lane) as ResolveLane<L> }
|
|
@@ -278,7 +433,7 @@ export const field = {
|
|
|
278
433
|
|
|
279
434
|
/** Free text — a search box. Defaults to `history: 'replace'` like every other field. */
|
|
280
435
|
string<const L extends FieldLane = FieldLane>(
|
|
281
|
-
o?: { fallback?: string
|
|
436
|
+
o?: { fallback?: FieldFallback<string>; max?: number },
|
|
282
437
|
lane?: L,
|
|
283
438
|
): StringField<ResolveLane<L>> {
|
|
284
439
|
return {
|
|
@@ -358,7 +513,24 @@ export type FieldCodec = {
|
|
|
358
513
|
* route does not, and a write there persists only (A1).
|
|
359
514
|
*/
|
|
360
515
|
readonly primary: string
|
|
516
|
+
/**
|
|
517
|
+
* The fallback, ALREADY resolved — a getter, because `field.*({ fallback: () => … })` is a thunk
|
|
518
|
+
* that must run at read time and re-run while nothing is written. Every call site reads it as a
|
|
519
|
+
* plain value, which is why the laziness is invisible past this line.
|
|
520
|
+
*/
|
|
361
521
|
readonly fallback: unknown
|
|
522
|
+
/** Range only: the per-preset window resolvers `toWindow` answers a derived preset through. */
|
|
523
|
+
readonly windows?: RangeWindows<string>
|
|
524
|
+
/**
|
|
525
|
+
* Number only: the declared bounds and grain the codec clamps to, republished so `handle` can put
|
|
526
|
+
* them on the handle. The codec is the only thing that reads the field descriptor, so a control
|
|
527
|
+
* that needs a limit has to be handed it from here — see {@link NumberHandleExtras}.
|
|
528
|
+
*/
|
|
529
|
+
readonly bounds?: {
|
|
530
|
+
readonly min: number | undefined
|
|
531
|
+
readonly max: number | undefined
|
|
532
|
+
readonly int: boolean
|
|
533
|
+
}
|
|
362
534
|
/**
|
|
363
535
|
* Validate a STORED (or caller-supplied) value. `null` = unusable.
|
|
364
536
|
*
|
|
@@ -376,6 +548,14 @@ export type FieldCodec = {
|
|
|
376
548
|
readonly optionValues: readonly string[]
|
|
377
549
|
}
|
|
378
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Resolve a possibly-lazy fallback. `typeof === 'function'` is an exact discriminator here: no field
|
|
553
|
+
* kind has a function as a legal VALUE, so a function can only ever be the thunk form.
|
|
554
|
+
*/
|
|
555
|
+
function resolveFallback(raw: unknown): unknown {
|
|
556
|
+
return typeof raw === 'function' ? (raw as () => unknown)() : raw
|
|
557
|
+
}
|
|
558
|
+
|
|
379
559
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+(?:Z|[+-]\d{2}:\d{2})?)?$/
|
|
380
560
|
|
|
381
561
|
function isIsoDate(raw: unknown): raw is string {
|
|
@@ -467,7 +647,10 @@ export function resolveFieldCodec(
|
|
|
467
647
|
lane: f.lane,
|
|
468
648
|
params: [presetParam, fromParam, toParam],
|
|
469
649
|
primary: presetParam,
|
|
470
|
-
fallback
|
|
650
|
+
get fallback() {
|
|
651
|
+
return resolveFallback(f.fallback)
|
|
652
|
+
},
|
|
653
|
+
windows: f.window,
|
|
471
654
|
decode,
|
|
472
655
|
fromSearch: (search) =>
|
|
473
656
|
decode({
|
|
@@ -507,7 +690,11 @@ export function resolveFieldCodec(
|
|
|
507
690
|
if (f.max !== undefined && value > f.max) value = f.max
|
|
508
691
|
return value
|
|
509
692
|
}
|
|
510
|
-
|
|
693
|
+
// The same three values the clamp above reads, handed on so the CONTROL can bound its input
|
|
694
|
+
// rather than watch the clamp correct it after the fact. Threaded THROUGH `single` rather
|
|
695
|
+
// than spread onto its result: `fallback` is a getter, and a spread would evaluate it once
|
|
696
|
+
// here and freeze a thunk fallback at definition.
|
|
697
|
+
return single(name, f, decode, { bounds: { min: f.min, max: f.max, int: f.int } })
|
|
511
698
|
}
|
|
512
699
|
case 'boolean': {
|
|
513
700
|
return single(name, f, decodeBoolean)
|
|
@@ -525,7 +712,14 @@ export function resolveFieldCodec(
|
|
|
525
712
|
}
|
|
526
713
|
}
|
|
527
714
|
|
|
528
|
-
/**
|
|
715
|
+
/**
|
|
716
|
+
* Codec shell for every kind that owns exactly one URL param.
|
|
717
|
+
*
|
|
718
|
+
* NEVER spread the returned object: `fallback` is a GETTER over a possibly-lazy fallback, and an
|
|
719
|
+
* object spread evaluates it once and copies the result as a plain value — which froze the number
|
|
720
|
+
* kind's thunk fallback at store definition for one minor. Anything a kind needs to add goes
|
|
721
|
+
* through `extra`.
|
|
722
|
+
*/
|
|
529
723
|
function single(
|
|
530
724
|
name: string,
|
|
531
725
|
f: AnyField,
|
|
@@ -535,6 +729,8 @@ function single(
|
|
|
535
729
|
equals?: (a: unknown, b: unknown) => boolean
|
|
536
730
|
/** URL-side decoder when it differs from the storage-side one (multi, legacy layout only). */
|
|
537
731
|
decodeUrl?: (raw: unknown) => unknown
|
|
732
|
+
/** Number only: the declared bounds and grain, republished onto the handle. */
|
|
733
|
+
bounds?: FieldCodec['bounds']
|
|
538
734
|
},
|
|
539
735
|
): FieldCodec {
|
|
540
736
|
const decodeUrl = extra?.decodeUrl ?? decode
|
|
@@ -543,7 +739,10 @@ function single(
|
|
|
543
739
|
lane: f.lane,
|
|
544
740
|
params: [name],
|
|
545
741
|
primary: name,
|
|
546
|
-
fallback
|
|
742
|
+
get fallback() {
|
|
743
|
+
return resolveFallback(f.fallback)
|
|
744
|
+
},
|
|
745
|
+
...(extra?.bounds !== undefined && { bounds: extra.bounds }),
|
|
547
746
|
decode,
|
|
548
747
|
fromSearch: (search) => decodeUrl(search[name]),
|
|
549
748
|
toSearch: (value) => ({ [name]: value }),
|
|
@@ -556,6 +755,17 @@ function single(
|
|
|
556
755
|
|
|
557
756
|
type StoreRecord = Record<string, unknown>
|
|
558
757
|
|
|
758
|
+
/**
|
|
759
|
+
* A field's write, untyped — the shape both factories build and `handle` hands out. `opts` is the
|
|
760
|
+
* public `FieldSetOptions`; only the URL lane can act on it.
|
|
761
|
+
*
|
|
762
|
+
* @internal — see `FieldCodec`.
|
|
763
|
+
*/
|
|
764
|
+
export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
|
|
765
|
+
|
|
766
|
+
/** One field's `use()`, untyped. @internal — see `FieldCodec`. */
|
|
767
|
+
export type FieldUse = () => readonly [unknown, FieldWrite]
|
|
768
|
+
|
|
559
769
|
/** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
|
|
560
770
|
export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
|
|
561
771
|
|
|
@@ -594,19 +804,25 @@ export type StoreCore = {
|
|
|
594
804
|
* Lives here rather than in either factory because BOTH have such a field: `createLocalStore`'s
|
|
595
805
|
* `persist: false`, and `createSearchStore`'s `{ url: false, persist: false }`.
|
|
596
806
|
*/
|
|
597
|
-
memoryUse(entry: StoreEntry):
|
|
807
|
+
memoryUse(entry: StoreEntry): FieldUse
|
|
598
808
|
/** The memory lane's value for one field, resolved — its fallback while nothing was written. */
|
|
599
809
|
readMemoryValue(entry: StoreEntry): unknown
|
|
600
810
|
/** Subscribes to the whole memory lane. The snapshot is a write counter, so it is always stable. */
|
|
601
811
|
useMemoryVersion(): number
|
|
602
812
|
/** Drops every memory value, so the next read resolves to the fallback. What `useReset` calls. */
|
|
603
813
|
resetMemory(): void
|
|
814
|
+
/**
|
|
815
|
+
* Deletes ONE field's key from the localStorage record, so the next read falls through to the
|
|
816
|
+
* live fallback. Not a hook: `FieldHandle.clear()` runs in an event handler, where the persisted
|
|
817
|
+
* setter from `usePersistedRecord()` is not in reach — it writes through the same envelope and
|
|
818
|
+
* wakes the same per-key listeners (`writePersistedValue`).
|
|
819
|
+
*/
|
|
820
|
+
clearField(name: string): void
|
|
821
|
+
/** The memory lane's half of the same: drops ONE field's value. */
|
|
822
|
+
clearMemory(name: string): void
|
|
604
823
|
/** Every stored field, flattened into URL-param shape, `undefined` entries dropped. */
|
|
605
824
|
readStoredFlat(): StoreRecord
|
|
606
|
-
handle(
|
|
607
|
-
entry: StoreEntry,
|
|
608
|
-
use: () => readonly [unknown, (next: unknown) => void],
|
|
609
|
-
): FieldHandle<AnyField>
|
|
825
|
+
handle(entry: StoreEntry, use: FieldUse, clear: () => void): FieldHandle<AnyField>
|
|
610
826
|
}
|
|
611
827
|
|
|
612
828
|
function isRecord(raw: unknown): raw is StoreRecord {
|
|
@@ -650,6 +866,7 @@ function createMemoryLane(): {
|
|
|
650
866
|
getVersion: () => number
|
|
651
867
|
resolve: (entry: StoreEntry) => unknown
|
|
652
868
|
write: (name: string, value: unknown) => void
|
|
869
|
+
remove: (name: string) => void
|
|
653
870
|
clear: () => void
|
|
654
871
|
} {
|
|
655
872
|
const values = new Map<string, unknown>()
|
|
@@ -679,6 +896,10 @@ function createMemoryLane(): {
|
|
|
679
896
|
values.set(name, value)
|
|
680
897
|
bump()
|
|
681
898
|
},
|
|
899
|
+
remove: (name) => {
|
|
900
|
+
if (!values.delete(name)) return
|
|
901
|
+
bump()
|
|
902
|
+
},
|
|
682
903
|
clear: () => {
|
|
683
904
|
if (values.size === 0) return
|
|
684
905
|
values.clear()
|
|
@@ -744,6 +965,14 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
744
965
|
return entry.codec.optionValues.map((value) => ({ value, label: map?.[value] ?? value }))
|
|
745
966
|
}
|
|
746
967
|
|
|
968
|
+
const clearField = (name: string): void => {
|
|
969
|
+
const record = readRecord()
|
|
970
|
+
if (!Object.hasOwn(record, name)) return
|
|
971
|
+
const next = { ...record }
|
|
972
|
+
delete next[name]
|
|
973
|
+
writePersistedValue(o.key, version, fromRecord(next))
|
|
974
|
+
}
|
|
975
|
+
|
|
747
976
|
const writeField = (
|
|
748
977
|
setRecord: (next: StoreRecord) => void,
|
|
749
978
|
name: string,
|
|
@@ -754,30 +983,49 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
754
983
|
setRecord({ ...readRecord(), [name]: value })
|
|
755
984
|
}
|
|
756
985
|
|
|
757
|
-
const handle = (
|
|
758
|
-
entry: StoreEntry,
|
|
759
|
-
use: () => readonly [unknown, (next: unknown) => void],
|
|
760
|
-
): FieldHandle<AnyField> => {
|
|
986
|
+
const handle = (entry: StoreEntry, use: FieldUse, clear: () => void): FieldHandle<AnyField> => {
|
|
761
987
|
const { codec } = entry
|
|
762
988
|
const built: Record<string, unknown> = {
|
|
763
989
|
kind: codec.kind,
|
|
764
|
-
fallback: codec.fallback,
|
|
765
990
|
use,
|
|
991
|
+
clear,
|
|
766
992
|
isDefault: (v: unknown) => codec.equals(v, codec.fallback),
|
|
767
993
|
}
|
|
768
994
|
if (codec.kind === 'range') {
|
|
769
|
-
built['toWindow'] = (v: RangeValue<string>) =>
|
|
770
|
-
v.preset === 'custom' && v.from !== undefined && v.to !== undefined
|
|
771
|
-
|
|
772
|
-
|
|
995
|
+
built['toWindow'] = (v: RangeValue<string>) => {
|
|
996
|
+
if (v.preset === 'custom' && v.from !== undefined && v.to !== undefined) {
|
|
997
|
+
return { from: v.from, to: v.to }
|
|
998
|
+
}
|
|
999
|
+
// `Object.hasOwn`, not a bare lookup: a preset named like an Object.prototype member
|
|
1000
|
+
// (`toString`) would otherwise resolve to that method and be CALLED.
|
|
1001
|
+
const windows = codec.windows
|
|
1002
|
+
if (windows !== undefined && Object.hasOwn(windows, v.preset)) {
|
|
1003
|
+
const resolver = windows[v.preset]
|
|
1004
|
+
if (resolver !== undefined) return resolver(new Date())
|
|
1005
|
+
}
|
|
1006
|
+
return { window: v.preset }
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (codec.kind === 'number') {
|
|
1010
|
+
// Plain properties, not getters: unlike `options` (relabelled by `labels()` after the handles
|
|
1011
|
+
// exist) and `fallback` (possibly a thunk), a field's bounds are fixed at definition.
|
|
1012
|
+
const bounds = codec.bounds
|
|
1013
|
+
built['min'] = bounds?.min
|
|
1014
|
+
built['max'] = bounds?.max
|
|
1015
|
+
built['int'] = bounds?.int ?? false
|
|
773
1016
|
}
|
|
774
|
-
//
|
|
1017
|
+
// GETTERS, not snapshots, and installed with defineProperty rather than declared in the
|
|
775
1018
|
// literal: `labels()` runs on the store after the handles exist, and an object spread would
|
|
776
|
-
// evaluate the getter once at spread time and freeze the unlabelled options.
|
|
1019
|
+
// evaluate the getter once at spread time and freeze the unlabelled options. `fallback` is a
|
|
1020
|
+
// getter for the same class of reason — a lazy fallback is resolved per read, not per handle.
|
|
777
1021
|
Object.defineProperty(built, 'options', {
|
|
778
1022
|
enumerable: true,
|
|
779
1023
|
get: (): readonly FieldOption[] => optionsFor(entry),
|
|
780
1024
|
})
|
|
1025
|
+
Object.defineProperty(built, 'fallback', {
|
|
1026
|
+
enumerable: true,
|
|
1027
|
+
get: (): unknown => codec.fallback,
|
|
1028
|
+
})
|
|
781
1029
|
return built as unknown as FieldHandle<AnyField>
|
|
782
1030
|
}
|
|
783
1031
|
|
|
@@ -802,6 +1050,8 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
802
1050
|
useMemoryVersion: () =>
|
|
803
1051
|
useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion),
|
|
804
1052
|
resetMemory: () => memory.clear(),
|
|
1053
|
+
clearField,
|
|
1054
|
+
clearMemory: (name) => memory.remove(name),
|
|
805
1055
|
setLabels(map) {
|
|
806
1056
|
for (const [name, entry] of Object.entries(map)) {
|
|
807
1057
|
if (entry !== undefined) labels.set(name, entry)
|
|
@@ -848,6 +1098,10 @@ export type LocalStore<S extends Record<string, AnyField>> = {
|
|
|
848
1098
|
* That is the honest home for a value a reader should not be handed back tomorrow (a scratch
|
|
849
1099
|
* comparison, a temporary drill-down) without dropping to `useState` and losing the handle.
|
|
850
1100
|
*
|
|
1101
|
+
* A fallback may be a THUNK (`field.string({ fallback: () => todayIso() })`) — resolved at read
|
|
1102
|
+
* time, re-resolved while nothing is written, and never persisted on its own. Every lane here
|
|
1103
|
+
* qualifies; `createSearchStore` allows it off the URL lane only, and throws otherwise.
|
|
1104
|
+
*
|
|
851
1105
|
* @example
|
|
852
1106
|
* const chart = createLocalStore({
|
|
853
1107
|
* key: 'momentum-chart',
|
|
@@ -875,8 +1129,12 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
|
|
|
875
1129
|
|
|
876
1130
|
const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
|
|
877
1131
|
for (const entry of core.entries) {
|
|
878
|
-
const
|
|
879
|
-
|
|
1132
|
+
const persisted = entry.codec.lane.persist
|
|
1133
|
+
const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
|
|
1134
|
+
const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
|
|
1135
|
+
field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as FieldHandle<
|
|
1136
|
+
S[keyof S]
|
|
1137
|
+
>
|
|
880
1138
|
}
|
|
881
1139
|
|
|
882
1140
|
const store: LocalStore<S> = {
|