@talelk/newui-data 1.0.0 → 1.1.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.
@@ -0,0 +1,168 @@
1
+ import { type Accessor, createSignal, onCleanup, type Setter } from 'solid-js'
2
+ import {
3
+ encodeValue,
4
+ type UrlStateAdapter,
5
+ type UrlStateValue,
6
+ } from './url-serialize'
7
+
8
+ export type { UrlStateAdapter, UrlStateValue } from './url-serialize'
9
+
10
+ /**
11
+ * URL state, router-agnostic by contract.
12
+ *
13
+ * `createUrlState` owns the signals whose values serialize to
14
+ * `URLSearchParams`: what makes a page reproducible by link — filters, view,
15
+ * page index, sort, density, time range. The app supplies the adapter, so the
16
+ * kit never imports a router: Solid Start's `useNavigate`, TanStack Router or
17
+ * a `history.replaceState` shim all satisfy {@link UrlStateAdapter}. Without
18
+ * an adapter the primitive is a plain controllable signal — tests and SSR stay
19
+ * clean, and the URL is simply never written.
20
+ *
21
+ * Writes **replace** the query string, never push (the back button leaves the
22
+ * page, it does not walk a filter history), and concurrent writes coalesce
23
+ * into one `replace` per frame. Search-like keys can be debounced so typing
24
+ * does not spam the history.
25
+ */
26
+
27
+ /** Options for {@link createUrlState}. */
28
+ export interface UrlStateOptions {
29
+ /** The router bridge. Absent means no URL access at all — pure signals. */
30
+ adapter?: UrlStateAdapter
31
+ /** Keys written after a debounce instead of immediately (search-like keys). */
32
+ debounce?: readonly string[]
33
+ /** Debounce window in ms. Defaults to `300`. */
34
+ debounceMs?: number
35
+ }
36
+
37
+ /** The handle {@link createUrlState} returns: one accessor per key. */
38
+ export type UrlState<T extends Record<string, UrlStateValue>> = {
39
+ [K in keyof T]: Accessor<T[K]>
40
+ } & {
41
+ /** Write one key. `undefined` means absent — it is removed, never `""`. */
42
+ set<K extends keyof T>(key: K, value: T[K] | undefined): void
43
+ /** The params every key serializes to right now (for "copy link"). */
44
+ params(): URLSearchParams
45
+ }
46
+
47
+ const DEFAULT_DEBOUNCE_MS = 300
48
+
49
+ /** Decode one raw param back, trusting the initial value's type. */
50
+ function decode(raw: string, initial: UrlStateValue): UrlStateValue {
51
+ if (typeof initial === 'string') return raw
52
+ if (typeof initial === 'number') {
53
+ const value = Number(raw)
54
+ return Number.isFinite(value) ? value : initial
55
+ }
56
+ if (typeof initial === 'boolean') return raw === 'true'
57
+ try {
58
+ return JSON.parse(raw) as UrlStateValue
59
+ } catch {
60
+ return initial
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Controllable signals serialized to `URLSearchParams`.
66
+ *
67
+ * `initial` is what the page renders when the URL says nothing about a key;
68
+ * the URL wins when it does. Keys are the app's, not the table's — `view.sort`
69
+ * rather than `sort` — so one URL can host several surfaces without collisions.
70
+ */
71
+ export function createUrlState<T extends Record<string, UrlStateValue>>(
72
+ initial: T,
73
+ options: UrlStateOptions = {},
74
+ ): UrlState<T> {
75
+ const keys = Object.keys(initial) as (keyof T & string)[]
76
+ const params = new URLSearchParams()
77
+ for (const key of keys) {
78
+ params.set(key, encodeValue(initial[key] as UrlStateValue))
79
+ }
80
+
81
+ const signals = new Map<
82
+ string,
83
+ [Accessor<UrlStateValue>, Setter<UrlStateValue>]
84
+ >()
85
+ const state: Record<string, unknown> = {}
86
+ for (const key of keys) {
87
+ const [get, set] = createSignal<UrlStateValue>(
88
+ initial[key] as UrlStateValue,
89
+ )
90
+ // Storing the accessor reference, not reading it — `state.view` is the
91
+ // signal, so the reactivity rule sees no tracked read to miss.
92
+ // eslint-disable-next-line solid/reactivity
93
+ signals.set(key, [get, set])
94
+ state[key] = get
95
+ }
96
+
97
+ // Read-once on mount: the URL seeds the signals, then signals drive.
98
+ if (options.adapter) {
99
+ try {
100
+ const search = new URLSearchParams(options.adapter.search())
101
+ for (const key of keys) {
102
+ const raw = search.get(key)
103
+ if (raw === null) continue
104
+ const value = decode(raw, initial[key] as UrlStateValue)
105
+ params.set(key, encodeValue(value))
106
+ signals.get(key)?.[1](() => value)
107
+ }
108
+ } catch {
109
+ // An unreadable URL is "absent everywhere": keep the initial values.
110
+ }
111
+ }
112
+
113
+ const debounceKeys = new Set(options.debounce ?? [])
114
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
115
+ let timer: ReturnType<typeof setTimeout> | null = null
116
+ let scheduled = false
117
+ let dirty = false
118
+
119
+ const flush = () => {
120
+ scheduled = false
121
+ dirty = false
122
+ options.adapter?.replace(new URLSearchParams(params))
123
+ }
124
+
125
+ /** Replace-not-push, batched per tick: one `replace` per coalesced write. */
126
+ const schedule = () => {
127
+ dirty = true
128
+ if (!options.adapter || scheduled) return
129
+ scheduled = true
130
+ queueMicrotask(flush)
131
+ }
132
+
133
+ onCleanup(() => {
134
+ if (timer) clearTimeout(timer)
135
+ })
136
+
137
+ const setKey = (
138
+ key: keyof T & string,
139
+ value: UrlStateValue | undefined,
140
+ ): void => {
141
+ const entry = signals.get(key)
142
+ if (!entry) return
143
+ const [, set] = entry
144
+ if (value === undefined) {
145
+ params.delete(key)
146
+ set(() => initial[key] as UrlStateValue)
147
+ } else {
148
+ params.set(key, encodeValue(value))
149
+ set(() => value)
150
+ }
151
+ if (debounceKeys.has(key)) {
152
+ // A search-like key waits out the debounce window before its value
153
+ // reaches the URL, so typing does not spam the history.
154
+ if (timer) clearTimeout(timer)
155
+ dirty = true
156
+ timer = setTimeout(() => {
157
+ timer = null
158
+ if (dirty) flush()
159
+ }, debounceMs)
160
+ } else {
161
+ schedule()
162
+ }
163
+ }
164
+ state['set'] = setKey
165
+
166
+ state['params'] = () => new URLSearchParams(params)
167
+ return state as unknown as UrlState<T>
168
+ }