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
|
@@ -31,7 +31,15 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { useNavigate, useSearch } from '@tanstack/react-router'
|
|
33
33
|
import { createStoreCore } from '../state'
|
|
34
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
AnyField,
|
|
36
|
+
FieldHandle,
|
|
37
|
+
FieldSetOptions,
|
|
38
|
+
FieldWrite,
|
|
39
|
+
SearchValues,
|
|
40
|
+
StoreEntry,
|
|
41
|
+
StoredValues,
|
|
42
|
+
} from '../state'
|
|
35
43
|
|
|
36
44
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
37
45
|
|
|
@@ -67,7 +75,10 @@ export type SearchStore<S extends Record<string, AnyField>> = {
|
|
|
67
75
|
useValues: () => SearchValues<S>
|
|
68
76
|
/** How many fields differ from their fallback — the `n` in a `Filters (n)` pill. */
|
|
69
77
|
useActiveCount: () => number
|
|
70
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Returns every field to its fallback: one navigate, one storage write. The mirror is UNSET
|
|
80
|
+
* rather than written — see `FieldHandle.clear()` for why writing the fallback pins a thunk.
|
|
81
|
+
*/
|
|
71
82
|
useReset: () => () => void
|
|
72
83
|
}
|
|
73
84
|
|
|
@@ -129,6 +140,48 @@ function warnWriteHasNowhereToGo(input: { key: string; name: string; param: stri
|
|
|
129
140
|
)
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Dev-only, once per param: a `patch` named a search param ANOTHER field of the same store owns.
|
|
145
|
+
* The navigate would put it in the URL while the store's own write path never touched the mirror,
|
|
146
|
+
* so the next paramless visit and every `linkSearch` link resolve the OLD value — the URL and the
|
|
147
|
+
* mirror disagree from that click on, and nothing looks broken until a reader shares a link.
|
|
148
|
+
*
|
|
149
|
+
* Thrown in dev rather than warned: the call is a mistake with a one-line fix (write the other
|
|
150
|
+
* field through its own setter), and the damage is silent. Production keeps the write — a thrown
|
|
151
|
+
* error there would take a page down over a stale param — and logs it once.
|
|
152
|
+
*/
|
|
153
|
+
function patchOwnsFieldMessage(input: {
|
|
154
|
+
key: string
|
|
155
|
+
name: string
|
|
156
|
+
owner: string
|
|
157
|
+
param: string
|
|
158
|
+
}): string {
|
|
159
|
+
const { key, name, owner, param } = input
|
|
160
|
+
return (
|
|
161
|
+
`basalt-ui: createSearchStore('${key}'): set('${name}', …, { patch }) carries \`${param}\`, ` +
|
|
162
|
+
`which this store's field '${owner}' owns — the URL would take it while the localStorage ` +
|
|
163
|
+
'mirror kept the old value, so the next paramless visit and every `linkSearch` link disagree ' +
|
|
164
|
+
`with the page the write happened on. Write it through \`field.${owner}.use()[1]\` instead; ` +
|
|
165
|
+
'a `patch` is for keys the store does NOT own.'
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Dev-only, once per field: a patched write from a route that does not validate the field. The
|
|
171
|
+
* write persists (A1), but the patch has no navigate to merge into and is dropped — the one case
|
|
172
|
+
* where half of a two-part write silently disappears.
|
|
173
|
+
*/
|
|
174
|
+
function warnPatchDropped(input: { key: string; name: string; param: string }): void {
|
|
175
|
+
const { key, name, param } = input
|
|
176
|
+
// oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
|
|
177
|
+
console.warn(
|
|
178
|
+
`[basalt-ui] createSearchStore('${key}'): wrote field '${name}' with a \`patch\` from a route ` +
|
|
179
|
+
`that does not validate \`${param}\` — the field's value persisted (A1), but the patch was ` +
|
|
180
|
+
'DROPPED: there is no navigate to merge it into. Render the control on the route wired to ' +
|
|
181
|
+
'`validateSearch`, or apply that sibling param on the route that owns it. (dev only)',
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
132
185
|
/**
|
|
133
186
|
* The public store plus the one member only the deprecated wrappers need: `markReaderWired`, so a
|
|
134
187
|
* consumer whose only reader is the wrapper's own `useStore()` still counts as wired and never sees
|
|
@@ -140,6 +193,25 @@ export type InternalSearchStore<S extends Record<string, AnyField>> = SearchStor
|
|
|
140
193
|
markReaderWired: () => void
|
|
141
194
|
}
|
|
142
195
|
|
|
196
|
+
/**
|
|
197
|
+
* A thunk fallback belongs to the LOCAL and MEMORY lanes only. On the URL lane `validateSearch`
|
|
198
|
+
* would evaluate it on every navigation and write the result into the URL, so a deep link would
|
|
199
|
+
* carry a value nobody picked and two visits a second apart would disagree. Thrown at definition
|
|
200
|
+
* rather than warned at read time: the field set is static, so there is no run in which this is
|
|
201
|
+
* anything but a mistake.
|
|
202
|
+
*/
|
|
203
|
+
function assertNoLazyUrlFallback(key: string, fields: Record<string, AnyField>): void {
|
|
204
|
+
for (const [name, f] of Object.entries(fields)) {
|
|
205
|
+
if (typeof f.fallback !== 'function' || !f.lane.url) continue
|
|
206
|
+
throw new Error(
|
|
207
|
+
`basalt-ui: createSearchStore('${key}'): field '${name}' has a thunk fallback on the URL ` +
|
|
208
|
+
'lane — `validateSearch` would evaluate it on every navigation and pin the result into the ' +
|
|
209
|
+
'URL. Pass a value instead, or move the field off the URL lane (`{ url: false }`, or ' +
|
|
210
|
+
'`createLocalStore`), where a lazy fallback is resolved per read.',
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
143
215
|
/** The memory-only lane: neither the URL nor the mirror, so the value lives in the store's own
|
|
144
216
|
* session-scoped external store (`StoreCore.memoryUse`) — the lane `createLocalStore` also uses. */
|
|
145
217
|
function isMemoryLane(entry: StoreEntry): boolean {
|
|
@@ -154,6 +226,7 @@ function isMemoryLane(entry: StoreEntry): boolean {
|
|
|
154
226
|
export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
155
227
|
o: CreateSearchStoreOptions<S> & { legacyValueField?: string },
|
|
156
228
|
): InternalSearchStore<S> {
|
|
229
|
+
assertNoLazyUrlFallback(o.key, o.fields)
|
|
157
230
|
const core = createStoreCore(o)
|
|
158
231
|
|
|
159
232
|
// `validateSearch` reads storage on every navigation, which says nothing about whether the
|
|
@@ -167,6 +240,39 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
167
240
|
readerWired = true
|
|
168
241
|
}
|
|
169
242
|
const nowhereWarned = new Set<string>()
|
|
243
|
+
const patchDroppedWarned = new Set<string>()
|
|
244
|
+
const patchOwnerLogged = new Set<string>()
|
|
245
|
+
|
|
246
|
+
/** Every URL param this store owns, mapped to the field that owns it — what a `patch` may not name. */
|
|
247
|
+
const paramOwners = new Map<string, string>()
|
|
248
|
+
for (const entry of core.urlEntries) {
|
|
249
|
+
for (const param of entry.codec.params) paramOwners.set(param, entry.name)
|
|
250
|
+
}
|
|
251
|
+
const persistedNames = new Set(
|
|
252
|
+
core.entries.filter((entry) => entry.codec.lane.persist).map((entry) => entry.name),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The URL lane's `clear()` needs a `navigate`, which only exists inside a render. Each render of
|
|
257
|
+
* `urlUse` therefore leaves the navigate its mount would use here, keyed by field — so `clear()`
|
|
258
|
+
* from an event handler on that mount navigates exactly as its setter would, and a field no mount
|
|
259
|
+
* has read falls back to clearing the mirror alone.
|
|
260
|
+
*/
|
|
261
|
+
const urlClear = new Map<string, () => void>()
|
|
262
|
+
|
|
263
|
+
const assertPatchNotOwned = (entry: StoreEntry, patch: Record<string, unknown>): void => {
|
|
264
|
+
for (const param of Object.keys(patch)) {
|
|
265
|
+
const owner = paramOwners.get(param)
|
|
266
|
+
// The field's OWN params are harmless: its `toSearch` is spread after the patch and wins.
|
|
267
|
+
if (owner === undefined || entry.codec.params.includes(param)) continue
|
|
268
|
+
const message = patchOwnsFieldMessage({ key: o.key, name: entry.name, owner, param })
|
|
269
|
+
if (process.env['NODE_ENV'] !== 'production') throw new Error(message)
|
|
270
|
+
if (patchOwnerLogged.has(param)) continue
|
|
271
|
+
patchOwnerLogged.add(param)
|
|
272
|
+
// oxlint-disable-next-line no-console -- production keeps the write; the log is the record
|
|
273
|
+
console.error(message)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
170
276
|
|
|
171
277
|
const flatten = (
|
|
172
278
|
search: Record<string, unknown> | null,
|
|
@@ -215,7 +321,7 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
215
321
|
return core.readStoredFlat() as Partial<StoredValues<S>>
|
|
216
322
|
}
|
|
217
323
|
|
|
218
|
-
const urlUse = (entry: StoreEntry) => (): readonly [unknown,
|
|
324
|
+
const urlUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
|
|
219
325
|
const search = useCurrentSearch()
|
|
220
326
|
const navigate = useNavigate()
|
|
221
327
|
const [record, setRecord] = core.usePersistedRecord()
|
|
@@ -224,14 +330,45 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
224
330
|
// The matched route validates this field iff its primary param is present (see the module
|
|
225
331
|
// header). A foreign route gets the persist-only lane.
|
|
226
332
|
const validated = Object.hasOwn(search, entry.codec.primary)
|
|
333
|
+
urlClear.set(entry.name, () => {
|
|
334
|
+
if (!validated) return
|
|
335
|
+
navigate({
|
|
336
|
+
to: '.',
|
|
337
|
+
search: (prev: Record<string, unknown>) => ({
|
|
338
|
+
...prev,
|
|
339
|
+
...entry.codec.toSearch(entry.codec.fallback),
|
|
340
|
+
}),
|
|
341
|
+
replace: true,
|
|
342
|
+
resetScroll: false,
|
|
343
|
+
})
|
|
344
|
+
})
|
|
227
345
|
|
|
228
346
|
return [
|
|
229
347
|
value,
|
|
230
|
-
(next: unknown) => {
|
|
348
|
+
(next: unknown, opts?: FieldSetOptions) => {
|
|
349
|
+
const patch = opts?.patch
|
|
350
|
+
if (patch !== undefined && Object.keys(patch).length > 0) {
|
|
351
|
+
assertPatchNotOwned(entry, patch)
|
|
352
|
+
if (
|
|
353
|
+
!validated &&
|
|
354
|
+
!patchDroppedWarned.has(entry.name) &&
|
|
355
|
+
process.env['NODE_ENV'] !== 'production'
|
|
356
|
+
) {
|
|
357
|
+
patchDroppedWarned.add(entry.name)
|
|
358
|
+
warnPatchDropped({ key: o.key, name: entry.name, param: entry.codec.primary })
|
|
359
|
+
}
|
|
360
|
+
}
|
|
231
361
|
if (validated) {
|
|
232
362
|
navigate({
|
|
233
363
|
to: '.',
|
|
234
|
-
|
|
364
|
+
// `patch` FIRST, the field's own params last: a patch is for keys the store does not
|
|
365
|
+
// own (clearing a sibling `detailDate` with `undefined`), so it must never be able to
|
|
366
|
+
// overwrite the value this very call is setting.
|
|
367
|
+
search: (prev: Record<string, unknown>) => ({
|
|
368
|
+
...prev,
|
|
369
|
+
...opts?.patch,
|
|
370
|
+
...entry.codec.toSearch(next),
|
|
371
|
+
}),
|
|
235
372
|
replace: entry.codec.lane.history === 'replace',
|
|
236
373
|
// A filter lives halfway down a page as often as it lives in the bar, and the router's
|
|
237
374
|
// default scroll restoration treats this same-route search write as a navigation — so
|
|
@@ -252,7 +389,9 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
252
389
|
] as const
|
|
253
390
|
}
|
|
254
391
|
|
|
255
|
-
|
|
392
|
+
// No `patch` here or in the memory lane: neither navigates, so there is no search object to merge
|
|
393
|
+
// into and nothing to silently half-apply.
|
|
394
|
+
const localUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
|
|
256
395
|
const [record, setRecord] = core.usePersistedRecord()
|
|
257
396
|
markReaderWired()
|
|
258
397
|
return [
|
|
@@ -265,6 +404,19 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
265
404
|
// memory-only. The third used to be the one combination that dropped its write.
|
|
266
405
|
const memoryEntries = core.entries.filter(isMemoryLane)
|
|
267
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Unsetting one field, per lane: drop the mirror key (never write the fallback — a thunk fallback
|
|
409
|
+
* resolved into localStorage is a value nobody chose), drop the memory value, and on the URL lane
|
|
410
|
+
* navigate back to the fallback params through the last render of `use()`.
|
|
411
|
+
*/
|
|
412
|
+
const clearFor = (entry: StoreEntry) => (): void => {
|
|
413
|
+
if (entry.codec.lane.persist) core.clearField(entry.name)
|
|
414
|
+
else if (!entry.codec.lane.url) core.clearMemory(entry.name)
|
|
415
|
+
if (!entry.codec.lane.url) return
|
|
416
|
+
const clearUrl = urlClear.get(entry.name)
|
|
417
|
+
if (clearUrl !== undefined) clearUrl()
|
|
418
|
+
}
|
|
419
|
+
|
|
268
420
|
const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
|
|
269
421
|
for (const entry of core.entries) {
|
|
270
422
|
const use = entry.codec.lane.url
|
|
@@ -272,7 +424,11 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
272
424
|
: entry.codec.lane.persist
|
|
273
425
|
? localUse(entry)
|
|
274
426
|
: core.memoryUse(entry)
|
|
275
|
-
field[entry.name as keyof S] = core.handle(
|
|
427
|
+
field[entry.name as keyof S] = core.handle(
|
|
428
|
+
entry,
|
|
429
|
+
use,
|
|
430
|
+
clearFor(entry),
|
|
431
|
+
) as unknown as FieldHandle<S[keyof S]>
|
|
276
432
|
}
|
|
277
433
|
|
|
278
434
|
const useValues = (): SearchValues<S> => {
|
|
@@ -325,11 +481,21 @@ export function buildSearchStore<const S extends Record<string, AnyField>>(
|
|
|
325
481
|
}
|
|
326
482
|
if (memoryEntries.length > 0) core.resetMemory()
|
|
327
483
|
if (!core.anyPersisted) return
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
484
|
+
// Reset means UNSET, not "write the fallback": `codec.fallback` is the RESOLVED value, so a
|
|
485
|
+
// thunk (`() => todayIso()`) would be pinned into the mirror by the very act of resetting and
|
|
486
|
+
// read back tomorrow as a choice nobody made. Deleting the key lets `resolve` fall through to
|
|
487
|
+
// the live fallback — which is what `resetMemory` above has always done for the memory lane.
|
|
488
|
+
const record = core.readRecord()
|
|
489
|
+
const next: Record<string, unknown> = {}
|
|
490
|
+
let dropped = false
|
|
491
|
+
for (const [name, value] of Object.entries(record)) {
|
|
492
|
+
if (persistedNames.has(name)) {
|
|
493
|
+
dropped = true
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
next[name] = value
|
|
331
497
|
}
|
|
332
|
-
setRecord(next)
|
|
498
|
+
if (dropped) setRecord(next)
|
|
333
499
|
}
|
|
334
500
|
}
|
|
335
501
|
|
package/src/shell/page-bar.tsx
CHANGED
|
@@ -137,7 +137,7 @@ export type PageBarProps = {
|
|
|
137
137
|
* sticky wrapper (`data-basalt-page-bar="shell"`) inside a `BasaltShell`. The seam for the two
|
|
138
138
|
* things only the consumer's own layout knows: bleeding the sticky bar across its container's
|
|
139
139
|
* gutters (`margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter)`) and drawing
|
|
140
|
-
* a hairline under it (`border-bottom: 1px solid var(--vx-
|
|
140
|
+
* a hairline under it (`border-bottom: 1px solid var(--vx-surface-hairline)`).
|
|
141
141
|
*
|
|
142
142
|
* Scope that CSS through this class, not through a global `[data-basalt-page-bar]` selector: the
|
|
143
143
|
* data attributes are stable enough to READ (a shell-less consumer can style
|
|
@@ -256,3 +256,257 @@ describe('createLocalStore — labels()', () => {
|
|
|
256
256
|
])
|
|
257
257
|
})
|
|
258
258
|
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A lazy fallback — `fallback: () => T`. The local store is its home: `createSearchStore` allows it
|
|
262
|
+
* off the URL lane only (a thunk in `validateSearch` would pin a computed value into a deep link),
|
|
263
|
+
* and every lane here is off the URL by construction.
|
|
264
|
+
*/
|
|
265
|
+
describe('createLocalStore — a lazy fallback', () => {
|
|
266
|
+
test('is not called at definition, resolves at read, and is re-read while nothing is written', () => {
|
|
267
|
+
let calls = 0
|
|
268
|
+
let today = '2026-01-01'
|
|
269
|
+
const store = createLocalStore({
|
|
270
|
+
key: 'l-lazy',
|
|
271
|
+
fields: {
|
|
272
|
+
since: field.string({
|
|
273
|
+
fallback: () => {
|
|
274
|
+
calls += 1
|
|
275
|
+
return today
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
// Definition must not run it: a store is built at module scope, where "now" is meaningless.
|
|
281
|
+
expect(calls).toBe(0)
|
|
282
|
+
|
|
283
|
+
const { result } = renderHook(() => store.field.since.use())
|
|
284
|
+
expect(result.current[0]).toBe('2026-01-01')
|
|
285
|
+
expect(calls).toBeGreaterThan(0)
|
|
286
|
+
|
|
287
|
+
// The thunk is the value while the field is unset — a later read sees what it returns NOW.
|
|
288
|
+
today = '2026-02-01'
|
|
289
|
+
expect(store.field.since.fallback).toBe('2026-02-01')
|
|
290
|
+
expect(store.field.since.isDefault('2026-02-01')).toBe(true)
|
|
291
|
+
expect(store.field.since.isDefault('2026-01-01')).toBe(false)
|
|
292
|
+
|
|
293
|
+
// Nothing was persisted by reading — a fallback is not a value until a write makes it one.
|
|
294
|
+
expect(localStorage.getItem('basalt:l-lazy')).toBeNull()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('a write wins over the thunk, and the thunk stops being consulted', () => {
|
|
298
|
+
let calls = 0
|
|
299
|
+
const store = createLocalStore({
|
|
300
|
+
key: 'l-lazy-write',
|
|
301
|
+
fields: {
|
|
302
|
+
rows: field.number({
|
|
303
|
+
fallback: () => {
|
|
304
|
+
calls += 1
|
|
305
|
+
return 10
|
|
306
|
+
},
|
|
307
|
+
min: 1,
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const { result } = renderHook(() => store.field.rows.use())
|
|
313
|
+
expect(result.current[0]).toBe(10)
|
|
314
|
+
act(() => {
|
|
315
|
+
result.current[1](25)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const after = calls
|
|
319
|
+
expect(result.current[0]).toBe(25)
|
|
320
|
+
expect(storedRecord('l-lazy-write')).toEqual({ rows: 25 })
|
|
321
|
+
expect(calls).toBe(after)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('the memory lane resolves it too — every kind takes the thunk form', () => {
|
|
325
|
+
let preset = '7d'
|
|
326
|
+
let n = 1
|
|
327
|
+
const store = createLocalStore({
|
|
328
|
+
key: 'l-lazy-memory',
|
|
329
|
+
fields: {
|
|
330
|
+
metric: field.enum(['load', 'volume'], () => 'volume', { persist: false }),
|
|
331
|
+
range: field.range({ presets: ['7d', '30d'], fallback: () => preset as '7d' | '30d' }),
|
|
332
|
+
tags: field.multi(['api', 'design'], () => ['design']),
|
|
333
|
+
compact: field.boolean(() => true),
|
|
334
|
+
// The two kinds this test used to omit. `number` builds its codec with an EXTRA member
|
|
335
|
+
// (`bounds`), which is exactly how it once ended up spreading the shell and freezing this
|
|
336
|
+
// fallback at definition — the whole kind list is here so that cannot recur unnoticed.
|
|
337
|
+
count: field.number({ fallback: () => n, min: 0 }),
|
|
338
|
+
label: field.string({ fallback: () => String(n) }),
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const metric = renderHook(() => store.field.metric.use())
|
|
343
|
+
expect(metric.result.current[0]).toBe('volume')
|
|
344
|
+
expect(store.field.range.fallback).toEqual({ preset: '7d' })
|
|
345
|
+
preset = '30d'
|
|
346
|
+
expect(store.field.range.fallback).toEqual({ preset: '30d' })
|
|
347
|
+
expect(store.field.tags.fallback).toEqual(['design'])
|
|
348
|
+
expect(store.field.compact.fallback).toBe(true)
|
|
349
|
+
expect([store.field.count.fallback, store.field.label.fallback]).toEqual([1, '1'])
|
|
350
|
+
|
|
351
|
+
// Read at READ time, not at definition: a later read sees what the thunk returns now.
|
|
352
|
+
n = 2
|
|
353
|
+
expect([store.field.count.fallback, store.field.label.fallback]).toEqual([2, '2'])
|
|
354
|
+
const count = renderHook(() => store.field.count.use())
|
|
355
|
+
expect(count.result.current[0]).toBe(2)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test('clear() unsets the mirror, so the thunk resolves again', () => {
|
|
359
|
+
let today = '2026-08-27'
|
|
360
|
+
const store = createLocalStore({
|
|
361
|
+
key: 'l-lazy-clear',
|
|
362
|
+
fields: {
|
|
363
|
+
since: field.string({ fallback: () => today }),
|
|
364
|
+
metric: field.enum(['load', 'volume'], 'load'),
|
|
365
|
+
},
|
|
366
|
+
})
|
|
367
|
+
persist('l-lazy-clear', { since: '2026-01-01', metric: 'volume' })
|
|
368
|
+
|
|
369
|
+
const { result } = renderHook(() => store.field.since.use())
|
|
370
|
+
expect(result.current[0]).toBe('2026-01-01')
|
|
371
|
+
|
|
372
|
+
act(() => {
|
|
373
|
+
store.field.since.clear()
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// The KEY is gone — writing the resolved fallback instead would hand a reader '2026-08-27'
|
|
377
|
+
// tomorrow, as a value they never chose. The sibling field is untouched.
|
|
378
|
+
expect(storedRecord('l-lazy-clear')).toEqual({ metric: 'volume' })
|
|
379
|
+
expect(result.current[0]).toBe('2026-08-27')
|
|
380
|
+
today = '2026-08-28'
|
|
381
|
+
expect(store.field.since.fallback).toBe('2026-08-28')
|
|
382
|
+
expect(store.field.since.isDefault('2026-08-28')).toBe(true)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('clear() on the memory lane drops that field only', () => {
|
|
386
|
+
const store = createLocalStore({
|
|
387
|
+
key: 'l-clear-memory',
|
|
388
|
+
fields: {
|
|
389
|
+
scratch: field.enum(['a', 'b'], 'a', { persist: false }),
|
|
390
|
+
other: field.enum(['a', 'b'], 'a', { persist: false }),
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
const scratch = renderHook(() => store.field.scratch.use())
|
|
395
|
+
const other = renderHook(() => store.field.other.use())
|
|
396
|
+
act(() => {
|
|
397
|
+
scratch.result.current[1]('b')
|
|
398
|
+
other.result.current[1]('b')
|
|
399
|
+
})
|
|
400
|
+
expect([scratch.result.current[0], other.result.current[0]]).toEqual(['b', 'b'])
|
|
401
|
+
|
|
402
|
+
act(() => {
|
|
403
|
+
store.field.scratch.clear()
|
|
404
|
+
})
|
|
405
|
+
expect([scratch.result.current[0], other.result.current[0]]).toEqual(['a', 'b'])
|
|
406
|
+
expect(localStorage.getItem('basalt:l-clear-memory')).toBeNull()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* `toWindow`'s TYPE, not just its runtime: the presets that declared a resolver are excluded from
|
|
411
|
+
* the `{ window }` branch, so the result assigns to an API param type naming only the
|
|
412
|
+
* server-understood windows. Before that, a consumer replacing its `presetToParams` switch with
|
|
413
|
+
* `toWindow` needed a cast — the workaround moved rather than going away.
|
|
414
|
+
*/
|
|
415
|
+
test('toWindow drops the resolved presets from its return TYPE', () => {
|
|
416
|
+
const store = createLocalStore({
|
|
417
|
+
key: 'l-towindow',
|
|
418
|
+
fields: {
|
|
419
|
+
window: field.range({
|
|
420
|
+
presets: ['7d', '30d', '90d', '3m', 'ytd', 'all'],
|
|
421
|
+
fallback: '30d',
|
|
422
|
+
window: {
|
|
423
|
+
'3m': () => ({ from: '2026-06-01', to: '2026-08-27' }),
|
|
424
|
+
ytd: () => ({ from: '2026-01-01', to: '2026-08-27' }),
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
},
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
type SummaryParams = { window: '7d' | '30d' | '90d' | 'all' } | { from: string; to: string }
|
|
431
|
+
|
|
432
|
+
// The assignment IS the assertion — no cast, which is what a consumer's `resolveWindow` was.
|
|
433
|
+
const resolve = (preset: '7d' | '30d' | '90d' | '3m' | 'ytd' | 'all'): SummaryParams =>
|
|
434
|
+
store.field.window.toWindow({ preset })
|
|
435
|
+
|
|
436
|
+
expect(resolve('30d')).toEqual({ window: '30d' })
|
|
437
|
+
expect(resolve('3m')).toEqual({ from: '2026-06-01', to: '2026-08-27' })
|
|
438
|
+
expect(resolve('ytd')).toEqual({ from: '2026-01-01', to: '2026-08-27' })
|
|
439
|
+
|
|
440
|
+
// @ts-expect-error `3m` declared a resolver, so it can never come back in the window branch.
|
|
441
|
+
const pinned: { window: '3m' } | { from: string; to: string } = store.field.window.toWindow({
|
|
442
|
+
preset: '3m',
|
|
443
|
+
})
|
|
444
|
+
expect(pinned).toEqual({ from: '2026-06-01', to: '2026-08-27' })
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The number handle's republished bounds (`NumberHandleExtras`). They are on the handle for exactly
|
|
450
|
+
* one reason: a control never sees the field descriptor, so before this the only way a `NumberFilter`
|
|
451
|
+
* could bound its own input was for the call site to pass `min`/`max` a second time — a second answer
|
|
452
|
+
* to a question the field already owns.
|
|
453
|
+
*/
|
|
454
|
+
describe('createLocalStore — a number handle republishes its bounds', () => {
|
|
455
|
+
test('min, max and int come off the field declaration', () => {
|
|
456
|
+
const store = createLocalStore({
|
|
457
|
+
key: 'l-bounds',
|
|
458
|
+
fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
expect({
|
|
462
|
+
min: store.field.nights.min,
|
|
463
|
+
max: store.field.nights.max,
|
|
464
|
+
int: store.field.nights.int,
|
|
465
|
+
}).toEqual({ min: 1, max: 14, int: true })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test('an undeclared bound is undefined, and int defaults to false — never 0/true by accident', () => {
|
|
469
|
+
const store = createLocalStore({
|
|
470
|
+
key: 'l-bounds-open',
|
|
471
|
+
fields: { threshold: field.number({ fallback: 0 }) },
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
expect({
|
|
475
|
+
min: store.field.threshold.min,
|
|
476
|
+
max: store.field.threshold.max,
|
|
477
|
+
int: store.field.threshold.int,
|
|
478
|
+
}).toEqual({ min: undefined, max: undefined, int: false })
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
// The negative branch of the extras type, asserted at RUNTIME as well: a non-number handle must
|
|
482
|
+
// not grow a `max` off `StringField`'s own `max`, which is the one collision this shape could have.
|
|
483
|
+
test('a non-number handle carries none of the three', () => {
|
|
484
|
+
const store = createLocalStore({
|
|
485
|
+
key: 'l-bounds-other',
|
|
486
|
+
fields: { q: field.string({ max: 40 }), metric: field.enum(['load', 'volume'], 'load') },
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
expect([store.field.q.max, store.field.q.min, store.field.q.int]).toEqual([
|
|
490
|
+
undefined,
|
|
491
|
+
undefined,
|
|
492
|
+
undefined,
|
|
493
|
+
])
|
|
494
|
+
expect(store.field.metric.int).toBeUndefined()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// The bounds are what the codec was ALREADY clamping to — the handle is a readout of that law,
|
|
498
|
+
// not a second copy of it.
|
|
499
|
+
test('the value the codec clamps to is the value the handle reports', () => {
|
|
500
|
+
const store = createLocalStore({
|
|
501
|
+
key: 'l-bounds-clamp',
|
|
502
|
+
fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
|
|
503
|
+
})
|
|
504
|
+
persist('l-bounds-clamp', { nights: 99 })
|
|
505
|
+
|
|
506
|
+
const { result } = renderHook(() => store.field.nights.use())
|
|
507
|
+
// Both sides in ONE assertion, so the pair has to agree rather than each matching 14 alone —
|
|
508
|
+
// `max` is `number | undefined` on the handle by construction, which is why it is not the
|
|
509
|
+
// `expected` argument.
|
|
510
|
+
expect([result.current[0], store.field.nights.max]).toEqual([14, 14])
|
|
511
|
+
})
|
|
512
|
+
})
|