@maestro-js/components 1.0.0-alpha.18 → 1.0.0-alpha.2
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/commands/add.ts +1 -16
- package/dist/components.json +1 -1
- package/dist/index.js +1 -14
- package/package.json +5 -5
- package/registry.json +790 -598
- package/scripts/build.ts +4 -11
- package/src/components/alert-dialog.tsx +3 -3
- package/src/components/avatar.tsx +1 -1
- package/src/components/button-link.tsx +0 -3
- package/src/components/button.tsx +0 -4
- package/src/components/checkbox.tsx +7 -7
- package/src/components/chip.tsx +7 -7
- package/src/components/container.tsx +3 -3
- package/src/components/dialog.tsx +7 -21
- package/src/components/disclosure.tsx +1 -1
- package/src/components/drawer.tsx +5 -19
- package/src/components/form.tsx +5 -27
- package/src/components/helpers/form-field.tsx +8 -7
- package/src/components/helpers/get-button-classes.ts +23 -23
- package/src/components/helpers/headless-button.tsx +1 -1
- package/src/components/inline-alert.tsx +3 -3
- package/src/components/labeled-value.tsx +2 -2
- package/src/components/menu.tsx +26 -60
- package/src/components/month-day-input.tsx +1 -2
- package/src/components/multiselect.tsx +70 -74
- package/src/components/radio.tsx +8 -8
- package/src/components/select.tsx +8 -9
- package/src/components/stepper.tsx +6 -6
- package/src/components/switch.tsx +7 -7
- package/src/components/tabs.tsx +1 -1
- package/src/components/tag-field.tsx +3 -3
- package/src/components/text-area.tsx +1 -1
- package/src/components/text-field.tsx +1 -4
- package/src/components/toast.tsx +6 -6
- package/src/components/toggle-button-group.tsx +2 -2
- package/src/utils/icons.d.ts +1 -2
- package/src/utils/use-tab-indicator.ts +9 -9
- package/src/components/date-range.tsx +0 -138
- package/src/components/optional-link.tsx +0 -15
- package/src/components/table/headless-templated-row-table.ts +0 -550
- package/src/components/table/index.tsx +0 -41
- package/src/components/table/server-table.ts +0 -143
- package/src/components/table/table-actions.tsx +0 -76
- package/src/components/table/table-container.tsx +0 -173
- package/src/components/table/table-container.type-test.ts +0 -155
- package/src/components/table/table-filters/date-range-filter.tsx +0 -227
- package/src/components/table/table-filters/select-filter.tsx +0 -211
- package/src/components/table/table-filters/sort.tsx +0 -85
- package/src/components/table/table-primitives.tsx +0 -226
- package/src/components/table/table-toolbar.tsx +0 -300
- package/src/components/table/table-types.ts +0 -61
- package/src/components/table/use-client-table.ts +0 -157
- package/src/components/table/use-client-table.type-test.ts +0 -217
- package/src/components/table/use-server-table.ts +0 -192
- package/src/components/table/use-server-table.type-test.ts +0 -174
- package/src/utils/use-query-state.ts +0 -626
|
@@ -1,626 +0,0 @@
|
|
|
1
|
-
import { useLocation, useNavigation, useSearchParams as useSearchParamsRR } from 'react-router'
|
|
2
|
-
import { dateFns, dateTimeFns, durationFns, type Iso } from 'iso-fns'
|
|
3
|
-
import React from 'react'
|
|
4
|
-
import { useStableAccessor } from './use-stable-accessor'
|
|
5
|
-
|
|
6
|
-
// ────────────────────────────────────────────────────────────────
|
|
7
|
-
// Types
|
|
8
|
-
// ────────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
interface QueryStateNavigateOptions {
|
|
11
|
-
replace?: boolean
|
|
12
|
-
preventScrollReset?: boolean
|
|
13
|
-
/** When true, updates the URL via `history.pushState`/`replaceState` without triggering a React Router navigation. */
|
|
14
|
-
shallow?: boolean
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface CodecDef<T> {
|
|
18
|
-
name: string
|
|
19
|
-
defaultValue?: T
|
|
20
|
-
['~type']: T
|
|
21
|
-
/** When true, the param is removed from the URL when the value matches `defaultValue`. */
|
|
22
|
-
clearOnDefault?: boolean
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface Codec<Def extends CodecDef<any>> {
|
|
26
|
-
_def: Def
|
|
27
|
-
encode(value: Def['~type']): string | null
|
|
28
|
-
decode(value: string | null): Def['~type']
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface CodecConfigurator<Def extends CodecDef<any>> extends Codec<Def> {
|
|
32
|
-
default(defaultValue: Def['~type']): CodecConfigurator<Def>
|
|
33
|
-
clearOnDefault(clear: boolean): CodecConfigurator<Def>
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function makeConfigurator<Def extends CodecDef<any>>(codec: Codec<Def>): CodecConfigurator<Def> {
|
|
37
|
-
return {
|
|
38
|
-
...codec,
|
|
39
|
-
default(defaultValue) {
|
|
40
|
-
return makeConfigurator({ ...codec, _def: { ...codec._def, defaultValue } })
|
|
41
|
-
},
|
|
42
|
-
clearOnDefault(clear) {
|
|
43
|
-
return makeConfigurator({ ...codec, _def: { ...codec._def, clearOnDefault: clear } })
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// ────────────────────────────────────────────────────────────────
|
|
49
|
-
// Codec defs
|
|
50
|
-
// ────────────────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
interface NoopDef extends CodecDef<string | null> {
|
|
53
|
-
name: 'noop'
|
|
54
|
-
['~type']: string | null
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface StringDef extends CodecDef<string | null> {
|
|
58
|
-
name: 'string'
|
|
59
|
-
['~type']: string | null
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface ValidatedDef<T extends string> extends CodecDef<T | null> {
|
|
63
|
-
name: string
|
|
64
|
-
['~type']: T | null
|
|
65
|
-
isValid: (v: string) => v is T
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface RelativeDateTimeRangeDef extends CodecDef<Iso.Duration | { start: Iso.DateTime; end: Iso.DateTime } | null> {
|
|
69
|
-
name: 'relativeDateTimeRange'
|
|
70
|
-
['~type']: Iso.Duration | { start: Iso.DateTime; end: Iso.DateTime } | null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
interface IntDef extends CodecDef<number | null> {
|
|
74
|
-
name: 'int'
|
|
75
|
-
['~type']: number | null
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface FloatDef extends CodecDef<number | null> {
|
|
79
|
-
name: 'float'
|
|
80
|
-
['~type']: number | null
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface HexDef extends CodecDef<number | null> {
|
|
84
|
-
name: 'hex'
|
|
85
|
-
['~type']: number | null
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface IndexDef extends CodecDef<number | null> {
|
|
89
|
-
name: 'index'
|
|
90
|
-
['~type']: number | null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
interface BooleanDef extends CodecDef<boolean | null> {
|
|
94
|
-
name: 'boolean'
|
|
95
|
-
['~type']: boolean | null
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
interface EnumDef<T extends string> extends CodecDef<T | null> {
|
|
99
|
-
name: 'enum'
|
|
100
|
-
['~type']: T | null
|
|
101
|
-
values: readonly T[]
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface EnumArrayDef<T extends string> extends CodecDef<T[]> {
|
|
105
|
-
name: 'enumArray'
|
|
106
|
-
['~type']: T[]
|
|
107
|
-
values: readonly T[]
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ────────────────────────────────────────────────────────────────
|
|
111
|
-
// Codec factories
|
|
112
|
-
// ────────────────────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
/** Pass-through — no transformation, value is `string | null`. */
|
|
115
|
-
function noop(def: Omit<NoopDef, '~type'>): Codec<NoopDef> {
|
|
116
|
-
return {
|
|
117
|
-
_def: { ...def, ['~type']: null as any },
|
|
118
|
-
encode: (value) => value,
|
|
119
|
-
decode: (value) => value
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** String codec — pass-through with `string | null` type. */
|
|
124
|
-
function string(def: Omit<StringDef, '~type'>): Codec<StringDef> {
|
|
125
|
-
return {
|
|
126
|
-
_def: { ...def, ['~type']: null as any },
|
|
127
|
-
encode: (value) => value,
|
|
128
|
-
decode: (value) => value
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Validates a string against a guard function. Used for date, dateTime, duration. */
|
|
133
|
-
function validated<T extends string>(def: Omit<ValidatedDef<T>, '~type'>): Codec<ValidatedDef<T>> {
|
|
134
|
-
return {
|
|
135
|
-
_def: { ...def, ['~type']: null as any },
|
|
136
|
-
encode: (value) => (value != null && def.isValid(value) ? value : null),
|
|
137
|
-
decode: (value) => (value !== null && def.isValid(value) ? value : null)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Either an ISO duration (relative range) or a `start~end` pair of ISO
|
|
143
|
-
* date-times (absolute range). Useful for "last 7 days" vs a custom window.
|
|
144
|
-
*/
|
|
145
|
-
function relativeDateTimeRange(def: Omit<RelativeDateTimeRangeDef, '~type'>): Codec<RelativeDateTimeRangeDef> {
|
|
146
|
-
return {
|
|
147
|
-
_def: { ...def, ['~type']: null as any },
|
|
148
|
-
encode: (value) => {
|
|
149
|
-
if (value == null) return null
|
|
150
|
-
if (durationFns.isValid(value)) return value
|
|
151
|
-
if (typeof value === 'object' && 'start' in value && 'end' in value) {
|
|
152
|
-
return dateTimeFns.isValid(value.start) && dateTimeFns.isValid(value.end) ? `${value.start}~${value.end}` : null
|
|
153
|
-
}
|
|
154
|
-
return null
|
|
155
|
-
},
|
|
156
|
-
decode: (value) => {
|
|
157
|
-
if (value === null) return null
|
|
158
|
-
if (durationFns.isValid(value)) return value
|
|
159
|
-
const [start, end] = value.split('~')
|
|
160
|
-
return dateTimeFns.isValid(start) && dateTimeFns.isValid(end) ? { start, end } : null
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Integer (base-10). Non-integer or missing values decode to `null`. */
|
|
166
|
-
function int(def: Omit<IntDef, '~type'>): Codec<IntDef> {
|
|
167
|
-
return {
|
|
168
|
-
_def: { ...def, ['~type']: null as any },
|
|
169
|
-
encode: (value) => (value != null && Number.isInteger(value) ? value.toString() : null),
|
|
170
|
-
decode: (value) => {
|
|
171
|
-
if (value === null) return null
|
|
172
|
-
const num = Number(value)
|
|
173
|
-
return Number.isInteger(num) ? num : null
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Finite floating-point number. `NaN` / `Infinity` decode to `null`. */
|
|
179
|
-
function float(def: Omit<FloatDef, '~type'>): Codec<FloatDef> {
|
|
180
|
-
return {
|
|
181
|
-
_def: { ...def, ['~type']: null as any },
|
|
182
|
-
encode: (value) => (value != null && Number.isFinite(value) ? value.toString() : null),
|
|
183
|
-
decode: (value) => {
|
|
184
|
-
if (value === null) return null
|
|
185
|
-
const num = Number(value)
|
|
186
|
-
return Number.isFinite(num) ? num : null
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Hexadecimal integer (with or without `0x` prefix). Stored without prefix. */
|
|
192
|
-
function hex(def: Omit<HexDef, '~type'>): Codec<HexDef> {
|
|
193
|
-
return {
|
|
194
|
-
_def: { ...def, ['~type']: null as any },
|
|
195
|
-
encode: (value) => (typeof value === 'number' && Number.isFinite(value) ? value.toString(16) : null),
|
|
196
|
-
decode: (value) => {
|
|
197
|
-
if (value === null) return null
|
|
198
|
-
const body = value.trim().toLowerCase().replace(/^0x/, '')
|
|
199
|
-
if (body.length === 0 || /[^0-9a-f]/i.test(body)) return null
|
|
200
|
-
const num = parseInt(body, 16)
|
|
201
|
-
return Number.isFinite(num) ? num : null
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Zero-based index stored as a **1-based** number in the URL.
|
|
208
|
-
* `?page=1` decodes to `0`, `0` encodes to `"1"`.
|
|
209
|
-
*/
|
|
210
|
-
function index(def: Omit<IndexDef, '~type'>): Codec<IndexDef> {
|
|
211
|
-
return {
|
|
212
|
-
_def: { ...def, ['~type']: null as any },
|
|
213
|
-
encode: (value) => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? (value + 1).toString() : null),
|
|
214
|
-
decode: (value) => {
|
|
215
|
-
if (value === null) return null
|
|
216
|
-
const num = Number(value)
|
|
217
|
-
return Number.isInteger(num) && num >= 1 ? num - 1 : null
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Boolean accepting common truthy/falsy strings (`1`, `true`, `yes`, `on`, etc). Encodes as `"1"` / `"0"`. */
|
|
223
|
-
function boolean(def: Omit<BooleanDef, '~type'>): Codec<BooleanDef> {
|
|
224
|
-
return {
|
|
225
|
-
_def: { ...def, ['~type']: null as any },
|
|
226
|
-
encode: (value) => (value == null ? null : value ? '1' : '0'),
|
|
227
|
-
decode: (value) => {
|
|
228
|
-
if (value === null) return null
|
|
229
|
-
const v = value.trim().toLowerCase()
|
|
230
|
-
if (v === '1' || v === 'true' || v === 't' || v === 'yes' || v === 'y' || v === 'on') return true
|
|
231
|
-
if (v === '0' || v === 'false' || v === 'f' || v === 'no' || v === 'n' || v === 'off') return false
|
|
232
|
-
return null
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Constrained to one of the provided `values`. Anything else decodes to `null`. */
|
|
238
|
-
function enumCodec<T extends string>(def: Omit<EnumDef<T>, '~type'>): Codec<EnumDef<T>> {
|
|
239
|
-
return {
|
|
240
|
-
_def: { ...def, ['~type']: null as any },
|
|
241
|
-
encode: (value) => value ?? null,
|
|
242
|
-
decode: (value) => {
|
|
243
|
-
if (value === null) return null
|
|
244
|
-
return (def.values as readonly string[]).includes(value) ? (value as T) : null
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Comma-separated list constrained to the provided `values`.
|
|
251
|
-
* Decodes to `T[]` (empty array when missing). Encodes in the canonical
|
|
252
|
-
* order of `values` so the same selection always produces the same URL.
|
|
253
|
-
*/
|
|
254
|
-
function enumArray<T extends string>(def: Omit<EnumArrayDef<T>, '~type'>): Codec<EnumArrayDef<T>> {
|
|
255
|
-
return {
|
|
256
|
-
_def: { ...def, ['~type']: null as any },
|
|
257
|
-
encode: (value) => {
|
|
258
|
-
if (!value.length) return null
|
|
259
|
-
return value
|
|
260
|
-
.sort((v1, v2) => def.values.indexOf(v1) - def.values.indexOf(v2))
|
|
261
|
-
.map((v) => encodeURIComponent(v))
|
|
262
|
-
.join(',')
|
|
263
|
-
},
|
|
264
|
-
decode: (value) => {
|
|
265
|
-
if (value === null) return []
|
|
266
|
-
return value
|
|
267
|
-
.split(',')
|
|
268
|
-
.map((v) => decodeURIComponent(v))
|
|
269
|
-
.filter((v) => def.values.includes(v as T)) as T[]
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ────────────────────────────────────────────────────────────────
|
|
275
|
-
// Serialize / Deserialize
|
|
276
|
-
// ────────────────────────────────────────────────────────────────
|
|
277
|
-
|
|
278
|
-
/** Extract the serializable def from a codec. */
|
|
279
|
-
function serializeCodec<Def extends CodecDef<any>>(codec: Codec<Def>): Def {
|
|
280
|
-
return codec._def
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
type CodecFactory = (def: any) => Codec<any>
|
|
284
|
-
|
|
285
|
-
const defaultFactories: Record<string, CodecFactory> = {
|
|
286
|
-
noop,
|
|
287
|
-
string,
|
|
288
|
-
date: validated,
|
|
289
|
-
dateTime: validated,
|
|
290
|
-
duration: validated,
|
|
291
|
-
relativeDateTimeRange,
|
|
292
|
-
int,
|
|
293
|
-
float,
|
|
294
|
-
hex,
|
|
295
|
-
index,
|
|
296
|
-
boolean,
|
|
297
|
-
enum: enumCodec,
|
|
298
|
-
enumArray
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Reconstruct a codec from a serialized def. */
|
|
302
|
-
function deserializeCodec<Def extends CodecDef<any>>(
|
|
303
|
-
def: Def,
|
|
304
|
-
factories: Record<string, CodecFactory> = defaultFactories
|
|
305
|
-
): Codec<Def> {
|
|
306
|
-
const factory = factories[def.name]
|
|
307
|
-
if (!factory) throw new Error(`Unknown codec: ${def.name}`)
|
|
308
|
-
return factory(def) as Codec<Def>
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
type SerializedCodec<C extends Codec<CodecDef<any>>> = C['_def']
|
|
312
|
-
|
|
313
|
-
type SerializedLoader<L extends QueryStateLoader> = { [K in keyof L]: SerializedCodec<L[K]> }
|
|
314
|
-
|
|
315
|
-
/** Serialize a loader into a plain object of defs, keyed by param name. */
|
|
316
|
-
function serializeLoader<L extends QueryStateLoader>(loader: L): SerializedLoader<L> {
|
|
317
|
-
return Object.fromEntries(
|
|
318
|
-
Object.entries(loader).map(([name, codec]) => [name, serializeCodec(codec)])
|
|
319
|
-
) as SerializedLoader<L>
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** Reconstruct a loader from a serialized object of defs. */
|
|
323
|
-
function deserializeLoader<L extends QueryStateLoader>(
|
|
324
|
-
defs: SerializedLoader<L>,
|
|
325
|
-
factories: Record<string, CodecFactory> = defaultFactories
|
|
326
|
-
): L {
|
|
327
|
-
return Object.fromEntries(
|
|
328
|
-
Object.entries(defs).map(([name, def]) => [name, deserializeCodec(def as CodecDef<any>, factories)])
|
|
329
|
-
) as L
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ────────────────────────────────────────────────────────────────
|
|
333
|
-
// Built-in codecs
|
|
334
|
-
// ────────────────────────────────────────────────────────────────
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Library of built-in codecs for common URL search-param types.
|
|
338
|
-
*
|
|
339
|
-
* Each codec converts between a URL string and a typed value, returning `null`
|
|
340
|
-
* for missing or invalid params. Chain `.default(v)` to substitute a
|
|
341
|
-
* fallback, or `.clearOnDefault(true)` to remove the param when it matches the default.
|
|
342
|
-
*/
|
|
343
|
-
const codecs = {
|
|
344
|
-
/** Pass-through — no transformation, value is `string | null`. */
|
|
345
|
-
noop: () => makeConfigurator(noop({ name: 'noop' })),
|
|
346
|
-
|
|
347
|
-
/** String codec — pass-through defaulting to `''` (never null). */
|
|
348
|
-
string: () => makeConfigurator(string({ name: 'string' })).default(''),
|
|
349
|
-
|
|
350
|
-
/** ISO date (`YYYY-MM-DD`). Invalid strings decode to `null`. */
|
|
351
|
-
date: () => makeConfigurator(validated({ name: 'date', isValid: dateFns.isValid })),
|
|
352
|
-
|
|
353
|
-
/** ISO date-time (`YYYY-MM-DDTHH:mm:ss`). Invalid strings decode to `null`. */
|
|
354
|
-
dateTime: () => makeConfigurator(validated({ name: 'dateTime', isValid: dateTimeFns.isValid })),
|
|
355
|
-
|
|
356
|
-
/** ISO duration (`P1DT2H`, etc). Invalid strings decode to `null`. */
|
|
357
|
-
duration: () => makeConfigurator(validated({ name: 'duration', isValid: durationFns.isValid })),
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Either an ISO duration (relative range) or a `start~end` pair of ISO
|
|
361
|
-
* date-times (absolute range). Useful for "last 7 days" vs a custom window.
|
|
362
|
-
*/
|
|
363
|
-
relativeDateTimeRange: () => makeConfigurator(relativeDateTimeRange({ name: 'relativeDateTimeRange' })),
|
|
364
|
-
|
|
365
|
-
/** Integer (base-10). Non-integer or missing values decode to `null`. */
|
|
366
|
-
int: () => makeConfigurator(int({ name: 'int' })),
|
|
367
|
-
|
|
368
|
-
/** Finite floating-point number. `NaN` / `Infinity` decode to `null`. */
|
|
369
|
-
float: () => makeConfigurator(float({ name: 'float' })),
|
|
370
|
-
|
|
371
|
-
/** Hexadecimal integer (with or without `0x` prefix). Stored without prefix. */
|
|
372
|
-
hex: () => makeConfigurator(hex({ name: 'hex' })),
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Zero-based index stored as a **1-based** number in the URL.
|
|
376
|
-
* `?page=1` decodes to `0`, `0` encodes to `"1"`.
|
|
377
|
-
*/
|
|
378
|
-
index: () => makeConfigurator(index({ name: 'index' })),
|
|
379
|
-
|
|
380
|
-
/** Boolean accepting common truthy/falsy strings (`1`, `true`, `yes`, `on`, etc). Encodes as `"1"` / `"0"`. */
|
|
381
|
-
boolean: () => makeConfigurator(boolean({ name: 'boolean' })),
|
|
382
|
-
|
|
383
|
-
/** Constrained to one of the provided `values`. Anything else decodes to `null`. */
|
|
384
|
-
enum: <T extends string>(values: readonly T[]) => makeConfigurator(enumCodec({ name: 'enum', values })),
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Comma-separated list constrained to the provided `values`.
|
|
388
|
-
* Decodes to `T[]` (empty array when missing). Encodes in the canonical
|
|
389
|
-
* order of `values` so the same selection always produces the same URL.
|
|
390
|
-
*/
|
|
391
|
-
enumArray: <T extends string>(values: readonly T[]) => makeConfigurator(enumArray({ name: 'enumArray', values }))
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ────────────────────────────────────────────────────────────────
|
|
395
|
-
// Loader types
|
|
396
|
-
// ────────────────────────────────────────────────────────────────
|
|
397
|
-
|
|
398
|
-
/** A map of param names to codecs, defining the shape of search-param state. */
|
|
399
|
-
type QueryStateLoader<T extends { [key: string]: any } = { [key: string]: any }> = {
|
|
400
|
-
[key in keyof T]: Codec<CodecDef<T[key]>>
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
type ExtractTypeFromLoader<L extends QueryStateLoader> = {
|
|
404
|
-
[key in keyof L]: L[key] extends Codec<infer S> ? S['~type'] : never
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// ────────────────────────────────────────────────────────────────
|
|
408
|
-
// Internal helpers
|
|
409
|
-
// ────────────────────────────────────────────────────────────────
|
|
410
|
-
|
|
411
|
-
function getQueryParam<T>(naturalValue: string | null, codec: Codec<CodecDef<T>>): T {
|
|
412
|
-
try {
|
|
413
|
-
const decoded = codec.decode(naturalValue)
|
|
414
|
-
return (decoded ?? codec?._def?.defaultValue ?? null) as T
|
|
415
|
-
} catch {
|
|
416
|
-
return (codec?._def?.defaultValue ?? null) as T
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function setQueryParam<V>({
|
|
421
|
-
searchParams,
|
|
422
|
-
name,
|
|
423
|
-
value,
|
|
424
|
-
codec
|
|
425
|
-
}: {
|
|
426
|
-
searchParams: URLSearchParams
|
|
427
|
-
name: string
|
|
428
|
-
value: V
|
|
429
|
-
codec: Codec<CodecDef<V>>
|
|
430
|
-
}): void {
|
|
431
|
-
const encoded = codec.encode(value)
|
|
432
|
-
if (
|
|
433
|
-
encoded === null ||
|
|
434
|
-
(codec._def?.defaultValue != null && codec._def?.clearOnDefault && encoded === codec.encode(codec._def?.defaultValue))
|
|
435
|
-
) {
|
|
436
|
-
searchParams.delete(name)
|
|
437
|
-
} else {
|
|
438
|
-
searchParams.set(name, encoded)
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function queryParamsToObject<L extends QueryStateLoader>(params: URLSearchParams, loader: L) {
|
|
443
|
-
return Object.fromEntries(
|
|
444
|
-
Object.entries(loader).map(([name, codec]) => [name, getQueryParam(params.get(name), codec)])
|
|
445
|
-
) as ExtractTypeFromLoader<L>
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// ────────────────────────────────────────────────────────────────
|
|
449
|
-
// Public API
|
|
450
|
-
// ────────────────────────────────────────────────────────────────
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Decode search params from various sources (URL string, `Request`, `URLSearchParams`, or
|
|
454
|
-
* an object with a `.search` property) using a loader. Useful in loaders/actions on the server side.
|
|
455
|
-
*/
|
|
456
|
-
function getSearchParamsState<L extends QueryStateLoader>(
|
|
457
|
-
searchParams: URLSearchParams | string | Request | { search: string },
|
|
458
|
-
loader: L
|
|
459
|
-
) {
|
|
460
|
-
if (typeof searchParams === 'string') {
|
|
461
|
-
return queryParamsToObject(new URL(searchParams).searchParams, loader)
|
|
462
|
-
} else if ('url' in searchParams) {
|
|
463
|
-
return queryParamsToObject(new URL(searchParams.url).searchParams, loader)
|
|
464
|
-
} else if ('search' in searchParams) {
|
|
465
|
-
return queryParamsToObject(new URLSearchParams(searchParams.search), loader)
|
|
466
|
-
} else {
|
|
467
|
-
return queryParamsToObject(searchParams, loader)
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* React hook that binds a single search param to component state via a codec.
|
|
473
|
-
* Returns `[value, setValue]` like `React.useState`. The setter accepts a value
|
|
474
|
-
* or updater function and optional {@link QueryStateNavigateOptions}.
|
|
475
|
-
*
|
|
476
|
-
* Reads from the pending navigation location when one is in flight so the UI
|
|
477
|
-
* reflects optimistic state during transitions.
|
|
478
|
-
*/
|
|
479
|
-
function useState<S>(name: string, codec: Codec<CodecDef<S>>): [S, (val: S | ((prevState: S) => S), navigateOpts?: QueryStateNavigateOptions) => void] {
|
|
480
|
-
const [searchParamsRaw, setSearchParams] = useSearchParamsRR()
|
|
481
|
-
const navigation = useNavigation()
|
|
482
|
-
|
|
483
|
-
const naturalValue = React.useMemo(
|
|
484
|
-
() => (navigation.location?.search ? new URLSearchParams(navigation.location.search) : searchParamsRaw).get(name),
|
|
485
|
-
[name, searchParamsRaw, navigation.location]
|
|
486
|
-
)
|
|
487
|
-
const value = React.useMemo(
|
|
488
|
-
() => getQueryParam<S>(naturalValue, codec),
|
|
489
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
490
|
-
[naturalValue]
|
|
491
|
-
)
|
|
492
|
-
|
|
493
|
-
const setValue = React.useCallback(
|
|
494
|
-
(val: S | ((prevState: S) => S), navigateOpts?: QueryStateNavigateOptions) => {
|
|
495
|
-
const { replace = true, preventScrollReset = false, shallow = false } = { ...codec, ...navigateOpts }
|
|
496
|
-
|
|
497
|
-
function updateParams(params: URLSearchParams) {
|
|
498
|
-
const newParams = new URLSearchParams(params)
|
|
499
|
-
const oldValue = getQueryParam(newParams.get(name), codec)
|
|
500
|
-
const newValue = typeof val === 'function' ? (val as (prevState: S) => S)(oldValue) : val
|
|
501
|
-
setQueryParam({ searchParams: newParams, name, value: newValue, codec })
|
|
502
|
-
return newParams
|
|
503
|
-
}
|
|
504
|
-
if (shallow) {
|
|
505
|
-
const params = new URLSearchParams(window.location.search)
|
|
506
|
-
const newUrl = [
|
|
507
|
-
[window.location.pathname, updateParams(params).toString()].filter(Boolean).join('?'),
|
|
508
|
-
window.location.hash
|
|
509
|
-
].join('#')
|
|
510
|
-
if (replace) {
|
|
511
|
-
window.history.replaceState(null, '', newUrl)
|
|
512
|
-
} else {
|
|
513
|
-
window.history.pushState(null, '', newUrl)
|
|
514
|
-
}
|
|
515
|
-
} else {
|
|
516
|
-
setSearchParams(updateParams, { replace, preventScrollReset })
|
|
517
|
-
}
|
|
518
|
-
},
|
|
519
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
520
|
-
[name, setSearchParams]
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
return [value, setValue] as const
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* React hook that decodes *all* search params defined by a loader into a typed object.
|
|
528
|
-
* Read-only — for read/write access to individual params, use {@link useState}.
|
|
529
|
-
*
|
|
530
|
-
* Reads from the pending navigation location when one is in flight.
|
|
531
|
-
*/
|
|
532
|
-
function useSearchParams<L extends QueryStateLoader>(
|
|
533
|
-
loader: L
|
|
534
|
-
): [
|
|
535
|
-
state: ExtractTypeFromLoader<L>,
|
|
536
|
-
setState: (
|
|
537
|
-
val: ExtractTypeFromLoader<L> | ((prevState: ExtractTypeFromLoader<L>) => ExtractTypeFromLoader<L>),
|
|
538
|
-
navigateOpts?: QueryStateNavigateOptions
|
|
539
|
-
) => void
|
|
540
|
-
] {
|
|
541
|
-
const location = useLocation()
|
|
542
|
-
const { location: pendingLocation } = useNavigation()
|
|
543
|
-
|
|
544
|
-
const search = pendingLocation?.search ?? location.search
|
|
545
|
-
|
|
546
|
-
const getLoader = useStableAccessor(loader)
|
|
547
|
-
const [_, setSearchParamsRaw] = useSearchParamsRR()
|
|
548
|
-
|
|
549
|
-
const setSearchParam = React.useCallback(
|
|
550
|
-
(
|
|
551
|
-
val: ExtractTypeFromLoader<L> | ((prevState: ExtractTypeFromLoader<L>) => ExtractTypeFromLoader<L>),
|
|
552
|
-
navigateOpts?: QueryStateNavigateOptions
|
|
553
|
-
) => {
|
|
554
|
-
const loader = getLoader()
|
|
555
|
-
const { replace = true, preventScrollReset = false, shallow = false } = { ...navigateOpts }
|
|
556
|
-
|
|
557
|
-
function updateParams(params: URLSearchParams) {
|
|
558
|
-
const newParams = new URLSearchParams(params)
|
|
559
|
-
const oldValue = queryParamsToObject(new URLSearchParams(search), loader)
|
|
560
|
-
const newValue = typeof val === 'function' ? val(oldValue) : val
|
|
561
|
-
Object.entries(loader).forEach(([name, codec]) =>
|
|
562
|
-
setQueryParam({ searchParams: newParams, name, value: newValue[name], codec })
|
|
563
|
-
)
|
|
564
|
-
return newParams
|
|
565
|
-
}
|
|
566
|
-
if (shallow) {
|
|
567
|
-
const params = new URLSearchParams(window.location.search)
|
|
568
|
-
const newUrl = [
|
|
569
|
-
[window.location.pathname, updateParams(params).toString()].filter(Boolean).join('?'),
|
|
570
|
-
window.location.hash
|
|
571
|
-
].join('#')
|
|
572
|
-
if (replace) {
|
|
573
|
-
window.history.replaceState(null, '', newUrl)
|
|
574
|
-
} else {
|
|
575
|
-
window.history.pushState(null, '', newUrl)
|
|
576
|
-
}
|
|
577
|
-
} else {
|
|
578
|
-
setSearchParamsRaw(updateParams, { replace, preventScrollReset })
|
|
579
|
-
}
|
|
580
|
-
},
|
|
581
|
-
[getLoader, setSearchParamsRaw]
|
|
582
|
-
)
|
|
583
|
-
|
|
584
|
-
const searchParams = React.useMemo(() => queryParamsToObject(new URLSearchParams(search), loader), [search, loader])
|
|
585
|
-
|
|
586
|
-
return [searchParams, setSearchParam]
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Identity function that provides type inference for a loader definition.
|
|
591
|
-
*
|
|
592
|
-
* @example
|
|
593
|
-
* ```ts
|
|
594
|
-
* const loader = SearchParams.createLoader({
|
|
595
|
-
* page: SearchParams.codecs.int.withDefaultValue(0),
|
|
596
|
-
* sort: SearchParams.codecs.enum(['name', 'date'] as const)
|
|
597
|
-
* })
|
|
598
|
-
* ```
|
|
599
|
-
*/
|
|
600
|
-
function createLoader<L extends QueryStateLoader>(loader: L): L {
|
|
601
|
-
return loader
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export const SearchParams = {
|
|
605
|
-
codecs,
|
|
606
|
-
serializeCodec,
|
|
607
|
-
deserializeCodec,
|
|
608
|
-
serializeLoader,
|
|
609
|
-
deserializeLoader,
|
|
610
|
-
createLoader,
|
|
611
|
-
getSearchParamsState,
|
|
612
|
-
useState,
|
|
613
|
-
useSearchParams
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
export declare namespace SearchParams {
|
|
617
|
-
export {
|
|
618
|
-
Codec,
|
|
619
|
-
CodecDef,
|
|
620
|
-
QueryStateLoader,
|
|
621
|
-
ExtractTypeFromLoader,
|
|
622
|
-
QueryStateNavigateOptions,
|
|
623
|
-
SerializedCodec,
|
|
624
|
-
SerializedLoader
|
|
625
|
-
}
|
|
626
|
-
}
|