@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/LICENSE +21 -0
- package/README.md +42 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +195 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +88 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/index.ts +38 -0
- package/src/serializers.ts +49 -0
- package/src/tests/url-state.test.ts +566 -0
- package/src/types.ts +54 -0
- package/src/url.ts +99 -0
- package/src/use-url-state.ts +193 -0
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
|
+
}
|