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.
Files changed (107) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/MIGRATING.md +299 -33
  4. package/agent/rules/basalt-controls.md +17 -12
  5. package/agent/rules/basalt-state.md +5 -0
  6. package/configs/oxlint-plugin.js +251 -66
  7. package/configs/oxlint-plugin.test.ts +243 -0
  8. package/configs/oxlint.json +5 -5
  9. package/dist/controls/enum-filter.d.ts +11 -2
  10. package/dist/controls/enum-filter.d.ts.map +1 -1
  11. package/dist/controls/enum-filter.js +4 -2
  12. package/dist/controls/filter-context.d.ts +7 -2
  13. package/dist/controls/filter-context.d.ts.map +1 -1
  14. package/dist/controls/index.d.ts +1 -0
  15. package/dist/controls/index.d.ts.map +1 -1
  16. package/dist/controls/index.js +2 -0
  17. package/dist/controls/multi-select-filter.js +2 -2
  18. package/dist/controls/number-filter.d.ts +26 -0
  19. package/dist/controls/number-filter.d.ts.map +1 -0
  20. package/dist/controls/number-filter.js +143 -0
  21. package/dist/controls/range-filter.d.ts +19 -5
  22. package/dist/controls/range-filter.d.ts.map +1 -1
  23. package/dist/controls/range-filter.js +22 -7
  24. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  25. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  26. package/dist/controls/search-filter.js +1 -1
  27. package/dist/controls/toggle-filter.js +1 -1
  28. package/dist/dashboard/index.d.ts +1 -1
  29. package/dist/dashboard/index.d.ts.map +1 -1
  30. package/dist/dashboard/index.js +3 -1
  31. package/dist/dashboard/stat-card.d.ts +52 -1
  32. package/dist/dashboard/stat-card.d.ts.map +1 -1
  33. package/dist/dashboard/stat-card.js +42 -15
  34. package/dist/dashboard/stat-card.module.css +71 -0
  35. package/dist/dashboard/stat-card.module.css.d.ts +4 -0
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +8 -0
  38. package/dist/guard/grace.test.d.ts.map +1 -1
  39. package/dist/guard/index.d.ts +1 -1
  40. package/dist/guard/index.d.ts.map +1 -1
  41. package/dist/guard/index.js +6 -10
  42. package/dist/index.d.ts +2 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/router-tanstack/field.d.ts +1 -1
  45. package/dist/router-tanstack/field.d.ts.map +1 -1
  46. package/dist/router-tanstack/index.d.ts +1 -1
  47. package/dist/router-tanstack/index.d.ts.map +1 -1
  48. package/dist/router-tanstack/search-store.d.ts +4 -1
  49. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  50. package/dist/router-tanstack/search-store.js +91 -7
  51. package/dist/shell/page-bar.d.ts +1 -1
  52. package/dist/state/fields.d.ts +209 -36
  53. package/dist/state/fields.d.ts.map +1 -1
  54. package/dist/state/fields.js +68 -32
  55. package/dist/state/persisted.d.ts +12 -0
  56. package/dist/state/persisted.d.ts.map +1 -1
  57. package/dist/state/persisted.js +36 -18
  58. package/dist/state/persisted.test.d.ts +2 -0
  59. package/dist/state/persisted.test.d.ts.map +1 -0
  60. package/dist/state.d.ts +1 -1
  61. package/dist/state.d.ts.map +1 -1
  62. package/dist/surfaces.d.ts +1 -1
  63. package/dist/surfaces.d.ts.map +1 -1
  64. package/dist/surfaces.js +4 -3
  65. package/dist/widget-header/widget-header.d.ts +31 -1
  66. package/dist/widget-header/widget-header.d.ts.map +1 -1
  67. package/dist/widget-header/widget-header.js +13 -1
  68. package/dist/widget-header/widget-header.module.css +15 -0
  69. package/dist/widget-header/widget-header.module.css.d.ts +1 -0
  70. package/llms.txt +3 -3
  71. package/package.json +1 -1
  72. package/src/controls/controls.router.test.tsx +416 -0
  73. package/src/controls/enum-filter.tsx +14 -3
  74. package/src/controls/filter-context.tsx +7 -2
  75. package/src/controls/index.ts +1 -0
  76. package/src/controls/multi-select-filter.tsx +2 -2
  77. package/src/controls/number-filter.tsx +291 -0
  78. package/src/controls/props-contract.test.tsx +31 -0
  79. package/src/controls/range-filter.tsx +64 -11
  80. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  81. package/src/controls/search-filter.tsx +1 -1
  82. package/src/controls/toggle-filter.tsx +1 -1
  83. package/src/dashboard/index.ts +6 -1
  84. package/src/dashboard/stat-card.module.css +71 -0
  85. package/src/dashboard/stat-card.module.css.d.ts +4 -0
  86. package/src/dashboard/stat-card.test.tsx +231 -0
  87. package/src/dashboard/stat-card.tsx +81 -0
  88. package/src/data/data-table.tsx +8 -0
  89. package/src/guard/check-source.test.ts +35 -2
  90. package/src/guard/grace.test.ts +14 -5
  91. package/src/guard/index.ts +30 -17
  92. package/src/index.ts +5 -0
  93. package/src/router-tanstack/field.ts +4 -0
  94. package/src/router-tanstack/index.ts +4 -0
  95. package/src/router-tanstack/search-store.router.test.tsx +232 -11
  96. package/src/router-tanstack/search-store.test.ts +160 -0
  97. package/src/router-tanstack/search-store.ts +177 -11
  98. package/src/shell/page-bar.tsx +1 -1
  99. package/src/state/fields.test.tsx +254 -0
  100. package/src/state/fields.ts +328 -70
  101. package/src/state/persisted.test.ts +91 -0
  102. package/src/state/persisted.ts +76 -26
  103. package/src/state.ts +6 -0
  104. package/src/surfaces.ts +4 -3
  105. package/src/widget-header/widget-header.module.css +15 -0
  106. package/src/widget-header/widget-header.module.css.d.ts +1 -0
  107. package/src/widget-header/widget-header.tsx +46 -1
@@ -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. `C` widened to `boolean` (a `RangeField<P>` in a control's props) keeps
94
- * `'custom'`, so a widened handle can still carry a custom window.
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>) => void]
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; min?: number; max?: number; int?: boolean },
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; max?: number },
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: f.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
- return single(name, f, decode)
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
- /** Codec shell for every kind that owns exactly one URL param. */
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: f.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): () => readonly [unknown, (next: unknown) => void]
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
- ? { from: v.from, to: v.to }
772
- : { window: v.preset }
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
- // A GETTER, not a snapshot, and installed with defineProperty rather than declared in the
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 use = entry.codec.lane.persist ? persistedUse(entry) : core.memoryUse(entry)
879
- field[entry.name as keyof S] = core.handle(entry, use) as unknown as FieldHandle<S[keyof S]>
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> = {