@pyreon/url-state 0.11.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/src/url.ts ADDED
@@ -0,0 +1,99 @@
1
+ const _isBrowser = typeof window !== "undefined"
2
+
3
+ /** Read a search param from the current URL. Returns `null` if not present. */
4
+ export function getParam(key: string): string | null {
5
+ if (!_isBrowser) return null
6
+ return new URLSearchParams(window.location.search).get(key)
7
+ }
8
+
9
+ /**
10
+ * Read all values for a repeated param (e.g. `?tags=a&tags=b`).
11
+ * Returns an empty array if the param is not present.
12
+ */
13
+ export function getParamAll(key: string): string[] {
14
+ if (!_isBrowser) return []
15
+ return new URLSearchParams(window.location.search).getAll(key)
16
+ }
17
+
18
+ /**
19
+ * Minimal router-like interface — only the `replace` method is needed.
20
+ * This avoids a hard dependency on `@pyreon/router`.
21
+ */
22
+ export interface UrlRouter {
23
+ replace(path: string): void | Promise<void>
24
+ }
25
+
26
+ /** Module-level router reference. Set via `setUrlRouter()`. */
27
+ let _router: UrlRouter | null = null
28
+
29
+ /** Register a router to use for URL updates instead of the raw history API. */
30
+ export function setUrlRouter(router: UrlRouter | null): void {
31
+ _router = router
32
+ }
33
+
34
+ /** @internal */
35
+ export function getUrlRouter(): UrlRouter | null {
36
+ return _router
37
+ }
38
+
39
+ /** Write one or more search params to the URL without a full navigation. */
40
+ export function setParams(entries: Record<string, string | null>, replace: boolean): void {
41
+ if (!_isBrowser) return
42
+
43
+ const params = new URLSearchParams(window.location.search)
44
+
45
+ for (const [key, value] of Object.entries(entries)) {
46
+ if (value === null) {
47
+ params.delete(key)
48
+ } else {
49
+ params.set(key, value)
50
+ }
51
+ }
52
+
53
+ const search = params.toString()
54
+ const url = search ? `${window.location.pathname}?${search}` : window.location.pathname
55
+
56
+ if (_router) {
57
+ _router.replace(url)
58
+ return
59
+ }
60
+
61
+ if (replace) {
62
+ history.replaceState(null, "", url)
63
+ } else {
64
+ history.pushState(null, "", url)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Write an array param using repeated keys (e.g. `?tags=a&tags=b`).
70
+ * When `values` is null the param is deleted.
71
+ */
72
+ export function setParamRepeated(key: string, values: string[] | null, replace: boolean): void {
73
+ if (!_isBrowser) return
74
+
75
+ const params = new URLSearchParams(window.location.search)
76
+ params.delete(key)
77
+
78
+ if (values !== null) {
79
+ for (const v of values) {
80
+ params.append(key, v)
81
+ }
82
+ }
83
+
84
+ const search = params.toString()
85
+ const url = search ? `${window.location.pathname}?${search}` : window.location.pathname
86
+
87
+ if (_router) {
88
+ _router.replace(url)
89
+ return
90
+ }
91
+
92
+ if (replace) {
93
+ history.replaceState(null, "", url)
94
+ } else {
95
+ history.pushState(null, "", url)
96
+ }
97
+ }
98
+
99
+ export { _isBrowser }
@@ -0,0 +1,193 @@
1
+ import { effect, onCleanup, signal } from "@pyreon/reactivity"
2
+ import { inferSerializer } from "./serializers"
3
+ import type { Serializer, UrlStateOptions, UrlStateSignal } from "./types"
4
+ import { _isBrowser, getParam, getParamAll, setParamRepeated, setParams } from "./url"
5
+
6
+ // ─── Single-param overload ──────────────────────────────────────────────────
7
+
8
+ /**
9
+ * Bind a single URL search parameter to a reactive signal.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const page = useUrlState("page", 1)
14
+ * page() // read reactively (number)
15
+ * page.set(2) // updates signal + URL
16
+ * page.reset() // back to 1
17
+ * page.remove() // removes ?page entirely
18
+ * ```
19
+ */
20
+ export function useUrlState<T>(
21
+ key: string,
22
+ defaultValue: T,
23
+ options?: UrlStateOptions<T>,
24
+ ): UrlStateSignal<T>
25
+
26
+ // ─── Schema overload ────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Bind multiple URL search parameters at once via a schema object.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const { page, q } = useUrlState({ page: 1, q: "" })
34
+ * page() // number
35
+ * q.set("hi") // updates ?q=hi
36
+ * ```
37
+ */
38
+ export function useUrlState<T extends Record<string, unknown>>(
39
+ schema: T,
40
+ options?: UrlStateOptions,
41
+ ): { [K in keyof T]: UrlStateSignal<T[K]> }
42
+
43
+ // ─── Implementation ─────────────────────────────────────────────────────────
44
+
45
+ export function useUrlState<T>(
46
+ keyOrSchema: string | Record<string, unknown>,
47
+ defaultOrOptions?: T | UrlStateOptions,
48
+ maybeOptions?: UrlStateOptions<T>,
49
+ ): UrlStateSignal<T> | Record<string, UrlStateSignal<unknown>> {
50
+ // Schema mode
51
+ if (typeof keyOrSchema === "object") {
52
+ const schema = keyOrSchema as Record<string, unknown>
53
+ const opts = defaultOrOptions as UrlStateOptions | undefined
54
+ const result: Record<string, UrlStateSignal<unknown>> = {}
55
+
56
+ for (const key of Object.keys(schema)) {
57
+ result[key] = createUrlSignal(key, schema[key], opts)
58
+ }
59
+
60
+ return result
61
+ }
62
+
63
+ // Single-param mode
64
+ const key = keyOrSchema
65
+ const defaultValue = defaultOrOptions as T
66
+ const options = maybeOptions
67
+ return createUrlSignal(key, defaultValue, options)
68
+ }
69
+
70
+ // ─── Core factory ───────────────────────────────────────────────────────────
71
+
72
+ function createUrlSignal<T>(
73
+ key: string,
74
+ defaultValue: T,
75
+ options?: UrlStateOptions<T>,
76
+ ): UrlStateSignal<T> {
77
+ const replace = options?.replace !== false
78
+ const debounceMs = options?.debounce ?? 0
79
+ const arrayFormat = options?.arrayFormat ?? "comma"
80
+ const isArray = Array.isArray(defaultValue)
81
+ const isRepeat = isArray && arrayFormat === "repeat"
82
+
83
+ const { serialize, deserialize }: Serializer<T> =
84
+ options?.serialize && options?.deserialize
85
+ ? { serialize: options.serialize, deserialize: options.deserialize }
86
+ : inferSerializer(defaultValue, arrayFormat)
87
+
88
+ // Read initial value from URL (falls back to default when missing or in SSR)
89
+ let initial: T
90
+ if (isRepeat) {
91
+ const values = getParamAll(key)
92
+ initial = values.length > 0 ? (values as T) : defaultValue
93
+ } else {
94
+ const raw = getParam(key)
95
+ initial = raw !== null ? deserialize(raw) : defaultValue
96
+ }
97
+
98
+ const state = signal<T>(initial)
99
+
100
+ // Pending debounce timer
101
+ let timer: ReturnType<typeof setTimeout> | undefined
102
+
103
+ // Write URL when signal changes
104
+ const writeUrl = (value: T) => {
105
+ if (isRepeat) {
106
+ const arr = value as string[]
107
+ const defaultArr = defaultValue as string[]
108
+ // Remove param when value equals default to keep URLs clean
109
+ if (arr.length === defaultArr.length && arr.every((v, i) => v === defaultArr[i])) {
110
+ setParamRepeated(key, null, replace)
111
+ } else {
112
+ setParamRepeated(key, arr, replace)
113
+ }
114
+ return
115
+ }
116
+
117
+ const serialized = serialize(value)
118
+ const defaultSerialized = serialize(defaultValue)
119
+
120
+ // Remove param when value equals default to keep URLs clean
121
+ if (serialized === defaultSerialized) {
122
+ setParams({ [key]: null }, replace)
123
+ } else {
124
+ setParams({ [key]: serialized }, replace)
125
+ }
126
+ }
127
+
128
+ /** Force-remove the param from URL regardless of value. */
129
+ const removeFromUrl = () => {
130
+ if (isRepeat) {
131
+ setParamRepeated(key, null, replace)
132
+ } else {
133
+ setParams({ [key]: null }, replace)
134
+ }
135
+ }
136
+
137
+ const scheduleWrite = (value: T) => {
138
+ if (debounceMs <= 0) {
139
+ writeUrl(value)
140
+ return
141
+ }
142
+ if (timer !== undefined) clearTimeout(timer)
143
+ timer = setTimeout(() => {
144
+ timer = undefined
145
+ writeUrl(value)
146
+ }, debounceMs)
147
+ }
148
+
149
+ // Listen for popstate (back/forward navigation)
150
+ if (_isBrowser) {
151
+ const onPopState = () => {
152
+ let value: T
153
+ if (isRepeat) {
154
+ const values = getParamAll(key)
155
+ value = values.length > 0 ? (values as T) : defaultValue
156
+ } else {
157
+ const current = getParam(key)
158
+ value = current !== null ? deserialize(current) : defaultValue
159
+ }
160
+ state.set(value)
161
+ options?.onChange?.(value)
162
+ }
163
+
164
+ effect(() => {
165
+ window.addEventListener("popstate", onPopState)
166
+ onCleanup(() => {
167
+ window.removeEventListener("popstate", onPopState)
168
+ if (timer !== undefined) clearTimeout(timer)
169
+ })
170
+ })
171
+ }
172
+
173
+ // Build the signal-like accessor
174
+ const accessor = (() => state()) as UrlStateSignal<T>
175
+
176
+ accessor.set = (value: T) => {
177
+ state.set(value)
178
+ scheduleWrite(value)
179
+ }
180
+
181
+ accessor.reset = () => {
182
+ state.set(defaultValue)
183
+ scheduleWrite(defaultValue)
184
+ }
185
+
186
+ accessor.remove = () => {
187
+ state.set(defaultValue)
188
+ if (timer !== undefined) clearTimeout(timer)
189
+ removeFromUrl()
190
+ }
191
+
192
+ return accessor
193
+ }