@urbicon-ui/sveltekit-utils 7.0.1 → 8.0.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/README.md +44 -41
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/table-query.d.ts +16 -62
- package/dist/table-query.js +7 -63
- package/dist/table-view.d.ts +123 -0
- package/dist/table-view.js +241 -0
- package/dist/url.svelte.d.ts +1 -101
- package/dist/url.svelte.js +9 -110
- package/dist/view-binding.svelte.d.ts +73 -0
- package/dist/view-binding.svelte.js +365 -0
- package/package.json +6 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL (de)serialization for the v8 table view vocabulary (`TableView` from
|
|
3
|
+
* `@urbicon-ui/table`: search, sort, page, pageSize, filters, groupBy).
|
|
4
|
+
*
|
|
5
|
+
* The types in this module are a **structural mirror** of the table package's
|
|
6
|
+
* view types — deliberately not imported, so this package carries no
|
|
7
|
+
* dependency on `@urbicon-ui/table`. A type-parity test over there guards the
|
|
8
|
+
* shapes against drift; `bindViewToUrl` accepts any object shaped like
|
|
9
|
+
* {@link TableViewLike}, which the real `TableView` is.
|
|
10
|
+
*
|
|
11
|
+
* The key scheme is the shipped one (`q`, `page`, `size`, `sort`, `dir`,
|
|
12
|
+
* `group`, `filter`) — deep links written for v7 keep parsing. The write
|
|
13
|
+
* side adds one compatible extension: an empty `filter=` marker, analogous
|
|
14
|
+
* to `sort=`, so a cleared filter set elides like every other axis (the
|
|
15
|
+
* shipped read side already tolerated it).
|
|
16
|
+
*/
|
|
17
|
+
import { TABLE_QUERY_FILTER_OPERATORS } from './table-query.js';
|
|
18
|
+
/** All six view axes, in vocabulary order. */
|
|
19
|
+
export const TABLE_VIEW_AXES = [
|
|
20
|
+
'search',
|
|
21
|
+
'sort',
|
|
22
|
+
'page',
|
|
23
|
+
'pageSize',
|
|
24
|
+
'filters',
|
|
25
|
+
'groupBy'
|
|
26
|
+
];
|
|
27
|
+
/** URL keys per axis, unprefixed — the §3.2 key scheme, grouped by owning axis. */
|
|
28
|
+
const AXIS_KEYS = {
|
|
29
|
+
search: ['q'],
|
|
30
|
+
sort: ['sort', 'dir'],
|
|
31
|
+
page: ['page'],
|
|
32
|
+
pageSize: ['size'],
|
|
33
|
+
filters: ['filter'],
|
|
34
|
+
groupBy: ['group']
|
|
35
|
+
};
|
|
36
|
+
/** The URL keys a set of axes owns, with the configured prefix applied. */
|
|
37
|
+
export function viewAxisKeys(axes, prefix = '') {
|
|
38
|
+
return axes.flatMap((axis) => AXIS_KEYS[axis].map((key) => `${prefix}${key}`));
|
|
39
|
+
}
|
|
40
|
+
function isFilterOperator(value) {
|
|
41
|
+
return TABLE_QUERY_FILTER_OPERATORS.includes(value);
|
|
42
|
+
}
|
|
43
|
+
/** Same per-entry tolerance as the table-query parser: malformed → null → skipped. */
|
|
44
|
+
function parseFilterParam(raw) {
|
|
45
|
+
const parts = raw.split(':');
|
|
46
|
+
if (parts.length !== 3)
|
|
47
|
+
return null;
|
|
48
|
+
const [encodedColumn, operator, encodedValue] = parts;
|
|
49
|
+
if (!isFilterOperator(operator))
|
|
50
|
+
return null;
|
|
51
|
+
try {
|
|
52
|
+
const column = decodeURIComponent(encodedColumn);
|
|
53
|
+
if (!column)
|
|
54
|
+
return null;
|
|
55
|
+
return { column, operator, value: decodeURIComponent(encodedValue) };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The axes a URL names — presence only for params it actually carries. */
|
|
62
|
+
export function viewAxesNamedBy(sp, prefix = '') {
|
|
63
|
+
const axes = [];
|
|
64
|
+
if (sp.get(`${prefix}q`) !== null)
|
|
65
|
+
axes.push('search');
|
|
66
|
+
if (sp.get(`${prefix}sort`) !== null)
|
|
67
|
+
axes.push('sort');
|
|
68
|
+
if (sp.get(`${prefix}page`) !== null)
|
|
69
|
+
axes.push('page');
|
|
70
|
+
if (sp.get(`${prefix}size`) !== null)
|
|
71
|
+
axes.push('pageSize');
|
|
72
|
+
if (sp.getAll(`${prefix}filter`).length > 0)
|
|
73
|
+
axes.push('filters');
|
|
74
|
+
if (sp.get(`${prefix}group`) !== null)
|
|
75
|
+
axes.push('groupBy');
|
|
76
|
+
return axes;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parse search params into a **partial** view snapshot — a key per axis the
|
|
80
|
+
* URL actually carries, and nothing else. Read tolerant per key: an
|
|
81
|
+
* unparsable value on a *present* key falls back to the configured default
|
|
82
|
+
* for that axis (the key was present, so the axis stays claimed), and
|
|
83
|
+
* malformed filter entries are skipped individually.
|
|
84
|
+
*/
|
|
85
|
+
export function searchParamsToViewPartial(sp, defaults, prefix = '') {
|
|
86
|
+
const partial = {};
|
|
87
|
+
const rawSearch = sp.get(`${prefix}q`);
|
|
88
|
+
if (rawSearch !== null)
|
|
89
|
+
partial.search = rawSearch;
|
|
90
|
+
const rawSort = sp.get(`${prefix}sort`);
|
|
91
|
+
if (rawSort !== null) {
|
|
92
|
+
partial.sort =
|
|
93
|
+
rawSort === ''
|
|
94
|
+
? null // `sort: null` — "unsorted" is a value, not a sentinel
|
|
95
|
+
: { column: rawSort, direction: sp.get(`${prefix}dir`) === 'desc' ? 'desc' : 'asc' };
|
|
96
|
+
}
|
|
97
|
+
const rawPage = sp.get(`${prefix}page`);
|
|
98
|
+
if (rawPage !== null && /^\d+$/.test(rawPage) && Number(rawPage) >= 1) {
|
|
99
|
+
partial.page = Number(rawPage);
|
|
100
|
+
}
|
|
101
|
+
else if (rawPage !== null) {
|
|
102
|
+
partial.page = defaults.page;
|
|
103
|
+
}
|
|
104
|
+
const rawSize = sp.get(`${prefix}size`);
|
|
105
|
+
if (rawSize !== null && /^\d+$/.test(rawSize) && Number(rawSize) >= 1) {
|
|
106
|
+
partial.pageSize = Number(rawSize);
|
|
107
|
+
}
|
|
108
|
+
else if (rawSize !== null) {
|
|
109
|
+
partial.pageSize = defaults.pageSize;
|
|
110
|
+
}
|
|
111
|
+
// `filter=` (empty marker) and `filter=a:contains:b` both claim the axis;
|
|
112
|
+
// the empty marker claims it as *empty* — the compatible format extension.
|
|
113
|
+
const rawFilters = sp.getAll(`${prefix}filter`);
|
|
114
|
+
if (rawFilters.length > 0) {
|
|
115
|
+
partial.filters = rawFilters
|
|
116
|
+
.map(parseFilterParam)
|
|
117
|
+
.filter((f) => f !== null);
|
|
118
|
+
}
|
|
119
|
+
const rawGroup = sp.get(`${prefix}group`);
|
|
120
|
+
if (rawGroup !== null)
|
|
121
|
+
partial.groupBy = rawGroup === '' ? null : rawGroup;
|
|
122
|
+
return partial;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a full view snapshot from search params: every axis the URL names
|
|
126
|
+
* comes from the URL, every other one from `defaults` — the same resolution
|
|
127
|
+
* the URL binding performs at init, for code that has no view (a server
|
|
128
|
+
* `load`).
|
|
129
|
+
*/
|
|
130
|
+
export function searchParamsToViewSnapshot(sp, defaults = {}, prefix = '') {
|
|
131
|
+
const resolved = resolveViewDefaults(defaults);
|
|
132
|
+
return { ...resolved, ...searchParamsToViewPartial(sp, resolved, prefix) };
|
|
133
|
+
}
|
|
134
|
+
/** Fill an unset axis with the table's own default — never `undefined` anywhere. */
|
|
135
|
+
function resolveViewDefaults(defaults) {
|
|
136
|
+
return {
|
|
137
|
+
search: defaults.search ?? '',
|
|
138
|
+
sort: defaults.sort ?? null,
|
|
139
|
+
page: defaults.page ?? 1,
|
|
140
|
+
pageSize: defaults.pageSize ?? 10,
|
|
141
|
+
filters: defaults.filters ?? [],
|
|
142
|
+
groupBy: defaults.groupBy || null
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Project a view snapshot into the wire shape a backend speaks — the same
|
|
147
|
+
* mapping the table applies before calling a managed `source.query`, so a
|
|
148
|
+
* server `load` and the table's own fetches send identical field names.
|
|
149
|
+
*/
|
|
150
|
+
export function viewSnapshotToTableQuery(snapshot) {
|
|
151
|
+
return {
|
|
152
|
+
page: snapshot.page,
|
|
153
|
+
itemsPerPage: snapshot.pageSize,
|
|
154
|
+
sortColumn: snapshot.sort?.column ?? '',
|
|
155
|
+
sortDirection: snapshot.sort?.direction ?? 'asc',
|
|
156
|
+
searchTerm: snapshot.search,
|
|
157
|
+
activeFilters: [...snapshot.filters],
|
|
158
|
+
groupByKey: snapshot.groupBy
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The load-path counterpart of the URL binding: parse search params against
|
|
163
|
+
* the **view's own defaults** and hand back the query a fetch needs.
|
|
164
|
+
*
|
|
165
|
+
* The point is the defaults argument. `searchParamsToTableQuery` takes its
|
|
166
|
+
* baseline in the wire vocabulary (`itemsPerPage`, `sortColumn`/`sortDirection`,
|
|
167
|
+
* `groupByKey`) and has no field for filters at all, so a `load` had to keep a
|
|
168
|
+
* second, differently-spelled copy of what `createTableView({ defaults })`
|
|
169
|
+
* already says — and could not express a default filter set no matter how it
|
|
170
|
+
* was written (#157 finding 2). This one takes the very object the view takes:
|
|
171
|
+
* one spelling, all six axes, so the server cannot resolve an absent param
|
|
172
|
+
* differently from the client.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* ```ts
|
|
176
|
+
* // shared with the component that calls createTableView({ defaults })
|
|
177
|
+
* export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
|
|
178
|
+
*
|
|
179
|
+
* export const load = async ({ url }) => ({
|
|
180
|
+
* initialResult: await fetchInvoices(searchParamsToViewQuery(url.searchParams, invoiceView))
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function searchParamsToViewQuery(sp, defaults = {}, prefix = '') {
|
|
185
|
+
return viewSnapshotToTableQuery(searchParamsToViewSnapshot(sp, defaults, prefix));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Serialize a snapshot, eliding every axis that equals the defaults — the
|
|
189
|
+
* elision baseline *is* the view's defaults, structurally. `axes` restricts
|
|
190
|
+
* the output to a binding's own axes: an unbound axis never reaches the URL,
|
|
191
|
+
* no matter what the view holds.
|
|
192
|
+
*/
|
|
193
|
+
export function viewSnapshotToSearchParams(snapshot, defaults, axes = TABLE_VIEW_AXES, prefix = '') {
|
|
194
|
+
const sp = new URLSearchParams();
|
|
195
|
+
const bound = (axis) => axes.includes(axis);
|
|
196
|
+
if (bound('search') && snapshot.search !== defaults.search)
|
|
197
|
+
sp.set(`${prefix}q`, snapshot.search);
|
|
198
|
+
if (bound('page') && snapshot.page !== defaults.page)
|
|
199
|
+
sp.set(`${prefix}page`, String(snapshot.page));
|
|
200
|
+
if (bound('pageSize') && snapshot.pageSize !== defaults.pageSize)
|
|
201
|
+
sp.set(`${prefix}size`, String(snapshot.pageSize));
|
|
202
|
+
// `bound('sort')` gates BOTH branches — gating only the null-mismatch one
|
|
203
|
+
// let an unbound sort leak into the URL (an operator-precedence slip the
|
|
204
|
+
// spike caught; pinned by the axis-subset test).
|
|
205
|
+
const sortDiffers = bound('sort') &&
|
|
206
|
+
((snapshot.sort === null) !== (defaults.sort === null) ||
|
|
207
|
+
(snapshot.sort !== null &&
|
|
208
|
+
defaults.sort !== null &&
|
|
209
|
+
(snapshot.sort.column !== defaults.sort.column ||
|
|
210
|
+
snapshot.sort.direction !== defaults.sort.direction)));
|
|
211
|
+
if (sortDiffers) {
|
|
212
|
+
if (snapshot.sort === null) {
|
|
213
|
+
sp.set(`${prefix}sort`, ''); // explicitly unsorted — only reachable when defaults sort
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
sp.set(`${prefix}sort`, snapshot.sort.column);
|
|
217
|
+
if (snapshot.sort.direction === 'desc')
|
|
218
|
+
sp.set(`${prefix}dir`, 'desc');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (bound('groupBy') && snapshot.groupBy !== defaults.groupBy)
|
|
222
|
+
sp.set(`${prefix}group`, snapshot.groupBy ?? '');
|
|
223
|
+
const filtersDiffer = bound('filters') &&
|
|
224
|
+
(snapshot.filters.length !== defaults.filters.length ||
|
|
225
|
+
snapshot.filters.some((f, i) => f.column !== defaults.filters[i].column ||
|
|
226
|
+
f.operator !== defaults.filters[i].operator ||
|
|
227
|
+
f.value !== defaults.filters[i].value));
|
|
228
|
+
if (filtersDiffer) {
|
|
229
|
+
if (snapshot.filters.length === 0) {
|
|
230
|
+
// The format extension: an empty marker, analogous to `sort=`, so a
|
|
231
|
+
// cleared filter set elides like every other axis.
|
|
232
|
+
sp.set(`${prefix}filter`, '');
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
for (const filter of snapshot.filters) {
|
|
236
|
+
sp.append(`${prefix}filter`, `${encodeURIComponent(filter.column)}:${filter.operator}:${encodeURIComponent(filter.value)}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return sp;
|
|
241
|
+
}
|
package/dist/url.svelte.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export { bindViewToUrl, type UrlViewBindingOptions } from './view-binding.svelte.js';
|
|
2
2
|
/**
|
|
3
3
|
* How {@link useUrlArrayParam} maps an array onto the URL:
|
|
4
4
|
* - `repeat` — one entry per key: `?tag=a&tag=b`
|
|
@@ -134,103 +134,3 @@ export declare function useUrlArrayParam(key: string, opts: {
|
|
|
134
134
|
strategy?: UrlArrayStrategy;
|
|
135
135
|
delimiter?: string;
|
|
136
136
|
}): readonly [() => string[], (next: string[]) => void];
|
|
137
|
-
/** Options for {@link createTableQueryUrlSync}. */
|
|
138
|
-
export interface TableQueryUrlSyncOptions extends TableQueryUrlOptions {
|
|
139
|
-
/**
|
|
140
|
-
* Replace the current history entry instead of pushing a new one. The
|
|
141
|
-
* default (`true`) keeps every sort/filter/page interaction from polluting
|
|
142
|
-
* the back button.
|
|
143
|
-
* @default true
|
|
144
|
-
*/
|
|
145
|
-
replaceState?: boolean;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Opt-in URL sync for a table: mirrors the `TableQuery` the table emits onto
|
|
149
|
-
* `?q=…&sort=…&page=…` query params, so the view state survives reloads, can
|
|
150
|
-
* be shared as a link, and — unlike `localStorage` — is visible to the server.
|
|
151
|
-
* Works in client mode as well as server mode; the table emits its query in
|
|
152
|
-
* both.
|
|
153
|
-
*
|
|
154
|
-
* Two directions, both explicit:
|
|
155
|
-
* - **URL → query**: `viewState` carries the axes the URL actually names, and
|
|
156
|
-
* re-reads the URL on every navigation. Hand it to the table's `query` prop,
|
|
157
|
-
* which controls exactly those axes — per field, and ahead of both
|
|
158
|
-
* `persistenceConfig` and the `initial*` seeds. Because a controlled axis is
|
|
159
|
-
* a derived rather than a write, it resolves during server rendering too,
|
|
160
|
-
* which is the point: the server renders the view the link asked for instead
|
|
161
|
-
* of a default one the client swaps out on hydration.
|
|
162
|
-
* - **query → URL**: pass `syncQuery` the query from `onQueryChange`, or call
|
|
163
|
-
* it inside `queryFn` (when `queryFn` is set, `onQueryChange` does not
|
|
164
|
-
* fire). It rewrites only its own — optionally prefixed — params via
|
|
165
|
-
* `goto` (`replaceState`, `noScroll`, `keepFocus`); unrelated params are
|
|
166
|
-
* preserved. Values equal to `options.defaults` are elided, so a table in
|
|
167
|
-
* its default state leaves the URL clean.
|
|
168
|
-
*
|
|
169
|
-
* Set `options.defaults` to the table's initial props (`itemsPerPage`,
|
|
170
|
-
* `initialPage`, `initialGroupBy`) so the elision baseline matches the state
|
|
171
|
-
* the table actually starts in.
|
|
172
|
-
*
|
|
173
|
-
* @example Client mode — the whole wiring is two props
|
|
174
|
-
* ```svelte
|
|
175
|
-
* <script lang="ts">
|
|
176
|
-
* import { Table } from '@urbicon-ui/table';
|
|
177
|
-
* import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
178
|
-
*
|
|
179
|
-
* const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
|
|
180
|
-
* </script>
|
|
181
|
-
*
|
|
182
|
-
* <Table {items} {columns} query={sync.viewState} onQueryChange={sync.syncQuery} />
|
|
183
|
-
* ```
|
|
184
|
-
*
|
|
185
|
-
* `query` is a *controlled* prop, which is what makes this work on the server:
|
|
186
|
-
* the URL is parsed during SSR and the table renders the linked view rather
|
|
187
|
-
* than an unfiltered one the client then replaces. It also outranks
|
|
188
|
-
* `persistenceConfig` per axis, so there is no longer a caveat about a stored
|
|
189
|
-
* value beating the link.
|
|
190
|
-
*
|
|
191
|
-
* Pass `viewState`, never `initialQuery`. `initialQuery` describes **every**
|
|
192
|
-
* axis, so as a `query` value it claims every axis — including the ones the URL
|
|
193
|
-
* says nothing about, whose values it filled in from the defaults. A table
|
|
194
|
-
* wired that way ignores `persistenceConfig`, `initialSort`, `initialFilters`
|
|
195
|
-
* and `initialGroupBy` entirely, on any URL, and says nothing about it.
|
|
196
|
-
*
|
|
197
|
-
* @example Server mode — the same two directions, with the fetch in between
|
|
198
|
-
* ```svelte
|
|
199
|
-
* <script lang="ts">
|
|
200
|
-
* const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
|
|
201
|
-
* </script>
|
|
202
|
-
*
|
|
203
|
-
* <Table
|
|
204
|
-
* mode="server"
|
|
205
|
-
* {columns}
|
|
206
|
-
* itemsPerPage={25}
|
|
207
|
-
* query={sync.viewState}
|
|
208
|
-
* queryFn={async (query, { signal }) => {
|
|
209
|
-
* sync.syncQuery(query);
|
|
210
|
-
* const res = await fetch(`/api/users?${new URLSearchParams(...)}`, { signal });
|
|
211
|
-
* const data = await res.json();
|
|
212
|
-
* return { items: data.results, totalItems: data.total };
|
|
213
|
-
* }}
|
|
214
|
-
* />
|
|
215
|
-
* ```
|
|
216
|
-
*
|
|
217
|
-
* @param options - Elision defaults, key prefix, history behaviour.
|
|
218
|
-
* @returns `viewState` (the controlled axes, re-read on every navigation),
|
|
219
|
-
* `initialQuery` (a complete snapshot for a fetch) + `syncQuery` (write a
|
|
220
|
-
* query back to the URL).
|
|
221
|
-
*/
|
|
222
|
-
export declare function createTableQueryUrlSync(options?: TableQueryUrlSyncOptions): {
|
|
223
|
-
/**
|
|
224
|
-
* The axes the URL names, re-read on every navigation.
|
|
225
|
-
*
|
|
226
|
-
* A getter, not a captured value: `createTableQueryUrlSync` runs once per
|
|
227
|
-
* page component, and SvelteKit does not remount that component when only
|
|
228
|
-
* the query string changes. A snapshot would therefore never see the back
|
|
229
|
-
* button — the table would stay sorted after the user navigated back to a
|
|
230
|
-
* URL with no sort param. Reading `page.url` here makes every consumer of
|
|
231
|
-
* this getter a reader of SvelteKit's own reactive page state.
|
|
232
|
-
*/
|
|
233
|
-
readonly viewState: TableQueryViewState;
|
|
234
|
-
readonly initialQuery: TableQueryParams;
|
|
235
|
-
readonly syncQuery: (query: TableQueryParams) => void;
|
|
236
|
-
};
|
package/dist/url.svelte.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { building } from '$app/environment';
|
|
2
2
|
import { goto } from '$app/navigation';
|
|
3
3
|
import { page } from '$app/state';
|
|
4
|
-
|
|
4
|
+
// The v8 view-object binding lives in its own module; re-exported here so the
|
|
5
|
+
// documented import path (`@urbicon-ui/sveltekit-utils/url.svelte`) carries it.
|
|
6
|
+
// The mirror types (TableViewLike, TableViewSnapshot, …) are exported from the
|
|
7
|
+
// package root via `table-view` — not re-exported here, which would make the
|
|
8
|
+
// root's star exports ambiguous and silently drop them. The v7
|
|
9
|
+
// `createTableQueryUrlSync` factory is gone with the table's `query` prop —
|
|
10
|
+
// the URL home of a table view is `bindViewToUrl`; the load-path serializers
|
|
11
|
+
// (`searchParamsToTableQuery` & friends) live on in `table-query`.
|
|
12
|
+
export { bindViewToUrl } from './view-binding.svelte.js';
|
|
5
13
|
/**
|
|
6
14
|
* Low-level escape hatch to update several params at once via `goto` (without a
|
|
7
15
|
* full navigation). Starts from the current URL, applies `next`, and keeps
|
|
@@ -175,112 +183,3 @@ export function useUrlArrayParam(key, opts) {
|
|
|
175
183
|
};
|
|
176
184
|
return useUrlParam(key, { parse, serialize, initial: opts.initial });
|
|
177
185
|
}
|
|
178
|
-
/**
|
|
179
|
-
* Opt-in URL sync for a table: mirrors the `TableQuery` the table emits onto
|
|
180
|
-
* `?q=…&sort=…&page=…` query params, so the view state survives reloads, can
|
|
181
|
-
* be shared as a link, and — unlike `localStorage` — is visible to the server.
|
|
182
|
-
* Works in client mode as well as server mode; the table emits its query in
|
|
183
|
-
* both.
|
|
184
|
-
*
|
|
185
|
-
* Two directions, both explicit:
|
|
186
|
-
* - **URL → query**: `viewState` carries the axes the URL actually names, and
|
|
187
|
-
* re-reads the URL on every navigation. Hand it to the table's `query` prop,
|
|
188
|
-
* which controls exactly those axes — per field, and ahead of both
|
|
189
|
-
* `persistenceConfig` and the `initial*` seeds. Because a controlled axis is
|
|
190
|
-
* a derived rather than a write, it resolves during server rendering too,
|
|
191
|
-
* which is the point: the server renders the view the link asked for instead
|
|
192
|
-
* of a default one the client swaps out on hydration.
|
|
193
|
-
* - **query → URL**: pass `syncQuery` the query from `onQueryChange`, or call
|
|
194
|
-
* it inside `queryFn` (when `queryFn` is set, `onQueryChange` does not
|
|
195
|
-
* fire). It rewrites only its own — optionally prefixed — params via
|
|
196
|
-
* `goto` (`replaceState`, `noScroll`, `keepFocus`); unrelated params are
|
|
197
|
-
* preserved. Values equal to `options.defaults` are elided, so a table in
|
|
198
|
-
* its default state leaves the URL clean.
|
|
199
|
-
*
|
|
200
|
-
* Set `options.defaults` to the table's initial props (`itemsPerPage`,
|
|
201
|
-
* `initialPage`, `initialGroupBy`) so the elision baseline matches the state
|
|
202
|
-
* the table actually starts in.
|
|
203
|
-
*
|
|
204
|
-
* @example Client mode — the whole wiring is two props
|
|
205
|
-
* ```svelte
|
|
206
|
-
* <script lang="ts">
|
|
207
|
-
* import { Table } from '@urbicon-ui/table';
|
|
208
|
-
* import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
209
|
-
*
|
|
210
|
-
* const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
|
|
211
|
-
* </script>
|
|
212
|
-
*
|
|
213
|
-
* <Table {items} {columns} query={sync.viewState} onQueryChange={sync.syncQuery} />
|
|
214
|
-
* ```
|
|
215
|
-
*
|
|
216
|
-
* `query` is a *controlled* prop, which is what makes this work on the server:
|
|
217
|
-
* the URL is parsed during SSR and the table renders the linked view rather
|
|
218
|
-
* than an unfiltered one the client then replaces. It also outranks
|
|
219
|
-
* `persistenceConfig` per axis, so there is no longer a caveat about a stored
|
|
220
|
-
* value beating the link.
|
|
221
|
-
*
|
|
222
|
-
* Pass `viewState`, never `initialQuery`. `initialQuery` describes **every**
|
|
223
|
-
* axis, so as a `query` value it claims every axis — including the ones the URL
|
|
224
|
-
* says nothing about, whose values it filled in from the defaults. A table
|
|
225
|
-
* wired that way ignores `persistenceConfig`, `initialSort`, `initialFilters`
|
|
226
|
-
* and `initialGroupBy` entirely, on any URL, and says nothing about it.
|
|
227
|
-
*
|
|
228
|
-
* @example Server mode — the same two directions, with the fetch in between
|
|
229
|
-
* ```svelte
|
|
230
|
-
* <script lang="ts">
|
|
231
|
-
* const sync = createTableQueryUrlSync({ defaults: { itemsPerPage: 25 } });
|
|
232
|
-
* </script>
|
|
233
|
-
*
|
|
234
|
-
* <Table
|
|
235
|
-
* mode="server"
|
|
236
|
-
* {columns}
|
|
237
|
-
* itemsPerPage={25}
|
|
238
|
-
* query={sync.viewState}
|
|
239
|
-
* queryFn={async (query, { signal }) => {
|
|
240
|
-
* sync.syncQuery(query);
|
|
241
|
-
* const res = await fetch(`/api/users?${new URLSearchParams(...)}`, { signal });
|
|
242
|
-
* const data = await res.json();
|
|
243
|
-
* return { items: data.results, totalItems: data.total };
|
|
244
|
-
* }}
|
|
245
|
-
* />
|
|
246
|
-
* ```
|
|
247
|
-
*
|
|
248
|
-
* @param options - Elision defaults, key prefix, history behaviour.
|
|
249
|
-
* @returns `viewState` (the controlled axes, re-read on every navigation),
|
|
250
|
-
* `initialQuery` (a complete snapshot for a fetch) + `syncQuery` (write a
|
|
251
|
-
* query back to the URL).
|
|
252
|
-
*/
|
|
253
|
-
export function createTableQueryUrlSync(options = {}) {
|
|
254
|
-
// Same prerender rule as `useUrlParam`: no query string exists while
|
|
255
|
-
// building, so both readers parse from empty params instead of throwing.
|
|
256
|
-
const currentParams = () => (building ? new URLSearchParams() : page.url.searchParams);
|
|
257
|
-
// Parsed once, on purpose — a snapshot to seed a fetch with, not a source of
|
|
258
|
-
// truth. `viewState` below is the one the table binds to.
|
|
259
|
-
const initialQuery = searchParamsToTableQuery(currentParams(), options);
|
|
260
|
-
function syncQuery(query) {
|
|
261
|
-
const next = applyTableQueryToSearchParams(page.url.searchParams, query, options);
|
|
262
|
-
const qs = next.toString();
|
|
263
|
-
goto(`${page.url.pathname}${qs ? `?${qs}` : ''}${page.url.hash}`, {
|
|
264
|
-
replaceState: options.replaceState ?? true,
|
|
265
|
-
noScroll: true,
|
|
266
|
-
keepFocus: true
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
return {
|
|
270
|
-
/**
|
|
271
|
-
* The axes the URL names, re-read on every navigation.
|
|
272
|
-
*
|
|
273
|
-
* A getter, not a captured value: `createTableQueryUrlSync` runs once per
|
|
274
|
-
* page component, and SvelteKit does not remount that component when only
|
|
275
|
-
* the query string changes. A snapshot would therefore never see the back
|
|
276
|
-
* button — the table would stay sorted after the user navigated back to a
|
|
277
|
-
* URL with no sort param. Reading `page.url` here makes every consumer of
|
|
278
|
-
* this getter a reader of SvelteKit's own reactive page state.
|
|
279
|
-
*/
|
|
280
|
-
get viewState() {
|
|
281
|
-
return searchParamsToTableViewState(currentParams(), options);
|
|
282
|
-
},
|
|
283
|
-
initialQuery,
|
|
284
|
-
syncQuery
|
|
285
|
-
};
|
|
286
|
-
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type TableViewAxis, type TableViewLike } from './table-view.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reset the module-scope writer between tests. The writer's pending-set and
|
|
4
|
+
* memo are keyed to a page's navigation stream; a test runner reusing the
|
|
5
|
+
* module across tests would otherwise leak one test's in-flight markers into
|
|
6
|
+
* the next.
|
|
7
|
+
* @internal test-only — not part of the public API.
|
|
8
|
+
*/
|
|
9
|
+
export declare function __resetUrlWriterForTests(): void;
|
|
10
|
+
/**
|
|
11
|
+
* Size of the writer's live-key registry — lets the SSR suite assert that
|
|
12
|
+
* the module-global registry does not grow across simulated server requests.
|
|
13
|
+
* @internal test-only — not part of the public API.
|
|
14
|
+
*/
|
|
15
|
+
export declare function __urlWriterLiveKeyCountForTests(): number;
|
|
16
|
+
/** Options for {@link bindViewToUrl}. */
|
|
17
|
+
export interface UrlViewBindingOptions {
|
|
18
|
+
/** Axes to bind. @default all six */
|
|
19
|
+
axes?: readonly TableViewAxis[];
|
|
20
|
+
/** Debounce for view → URL writes in ms. @default 300 */
|
|
21
|
+
debounceMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Replace the current history entry instead of pushing a new one, so rapid
|
|
24
|
+
* sort/filter/page edits do not flood the back button.
|
|
25
|
+
*
|
|
26
|
+
* Decided for v8: this default covers **all** binding writes — v7
|
|
27
|
+
* continuity, keeping interactions from polluting the back button — and
|
|
28
|
+
* there is deliberately no per-interaction-type history mapping (a page
|
|
29
|
+
* turn pushing while a keystroke replaces); this one global option is the
|
|
30
|
+
* whole knob. Mirror-only writes (`reflectExternal`) always replace,
|
|
31
|
+
* whatever this is set to.
|
|
32
|
+
* @default true
|
|
33
|
+
*/
|
|
34
|
+
replaceState?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Key prefix (`prefix: 't_'` → `?t_q=…&t_page=…`) — namespace for a second
|
|
37
|
+
* bound table on the same page.
|
|
38
|
+
* @default ''
|
|
39
|
+
*/
|
|
40
|
+
prefix?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Whether *external* changes (a storage seed) are mirrored into the URL.
|
|
43
|
+
*
|
|
44
|
+
* - `false` (default): the address bar does not change without a reader
|
|
45
|
+
* interaction — a restored "yesterday's view" stays out of the URL until
|
|
46
|
+
* the reader's first edit, which then serializes the full snapshot.
|
|
47
|
+
* - `true`: an external application reaches the URL immediately, always
|
|
48
|
+
* via `replaceState` (a mirror is not a reader action, so it never
|
|
49
|
+
* mints a history entry) — the restored state becomes shareable without
|
|
50
|
+
* an interaction.
|
|
51
|
+
* @default false
|
|
52
|
+
*/
|
|
53
|
+
reflectExternal?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Bind a view to the page URL. Call during component initialisation: the
|
|
57
|
+
* init half runs synchronously (SSR-safe — a `?sort=…` link renders sorted
|
|
58
|
+
* server HTML), the runtime halves are effects.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```svelte
|
|
62
|
+
* <script lang="ts">
|
|
63
|
+
* import { Table, createTableView } from '@urbicon-ui/table';
|
|
64
|
+
* import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
65
|
+
*
|
|
66
|
+
* const view = createTableView({ defaults: { pageSize: 25 } });
|
|
67
|
+
* bindViewToUrl(view);
|
|
68
|
+
* </script>
|
|
69
|
+
*
|
|
70
|
+
* <Table {items} {columns} {view} />
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export declare function bindViewToUrl(view: TableViewLike, options?: UrlViewBindingOptions): void;
|