@escape-game-over/atlas 0.1.18 → 0.1.19
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/docs/client-scripts.md +6 -0
- package/package.json +1 -1
- package/src/astro/dom.ts +8 -26
- package/src/astro/element.ts +46 -124
- package/src/astro/filters-view.ts +46 -147
- package/src/astro/filters.ts +88 -248
- package/src/astro/markup.ts +163 -142
package/src/astro/filters.ts
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A filtered list's state and its URL — not its markup.
|
|
3
3
|
*
|
|
4
|
-
* In `astro/` for the reason `carousel.ts` is: it reaches for `location`,
|
|
5
|
-
* `history` and `window`, which the core is type-checked without.
|
|
6
|
-
*
|
|
7
|
-
* **It draws nothing.** No `hidden`, no `aria-selected`, no counts, no empty
|
|
8
|
-
* state. It owns which items match and what the address bar says, and calls
|
|
9
|
-
* `onChange` when either moves; a project does every DOM write, and its markup
|
|
10
|
-
* contract — however many data attributes that turns out to be — stays where the
|
|
11
|
-
* markup is. That is the whole split, and it is why one function can serve a
|
|
12
|
-
* game grid with tabs and a two-input directory.
|
|
13
|
-
*
|
|
14
|
-
* Matching is the one place this cannot copy `carousel`. A carousel's entire
|
|
15
|
-
* state is an integer, so it never needs to know anything about the page. A
|
|
16
|
-
* predicate needs each item's text, and that text lives in the markup — so the
|
|
17
|
-
* caller hands it over once, as plain values, rather than the library reaching
|
|
18
|
-
* into the DOM for it. Keys are strings for the same reason: nothing here holds
|
|
19
|
-
* an element, and the whole module is testable without a document.
|
|
20
|
-
*
|
|
21
4
|
* ```ts
|
|
22
5
|
* const list = filters({
|
|
23
6
|
* fields: {
|
|
@@ -28,48 +11,34 @@
|
|
|
28
11
|
* key: entry.id,
|
|
29
12
|
* values: { q: `${entry.title} ${entry.summary}`, category: entry.category },
|
|
30
13
|
* })),
|
|
31
|
-
* onChange: ({ matched }) => {
|
|
32
|
-
* for (const [key, element] of elements) element.hidden = !matched.has(key);
|
|
33
|
-
* },
|
|
14
|
+
* onChange: ({ matched }) => { /* every DOM write is the project's *\/ },
|
|
34
15
|
* });
|
|
35
16
|
*
|
|
36
17
|
* const detach = list.attach();
|
|
37
18
|
* ```
|
|
38
19
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
20
|
+
* **It draws nothing** and holds no element: the caller hands over each item's
|
|
21
|
+
* values once, as plain data, which is also why the whole module is testable
|
|
22
|
+
* without a document.
|
|
23
|
+
*
|
|
24
|
+
* What it owns is what every filtered list needs and most get wrong: folding, so
|
|
25
|
+
* a query without accents matches words with them; a query and its facets
|
|
26
|
+
* resolved together; and a URL that behaves — replaced while typing, pushed on a
|
|
27
|
+
* deliberate choice, empty parameters dropped, `popstate` applied.
|
|
46
28
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* page, and the first `onChange` here continues from that state instead of
|
|
50
|
-
* resetting it.
|
|
29
|
+
* In `astro/` because it reaches for `location`, `history` and `window`. See
|
|
30
|
+
* docs/client-scripts.md.
|
|
51
31
|
*/
|
|
52
32
|
|
|
53
33
|
/**
|
|
54
|
-
* What a field is, as data rather than a constructor
|
|
55
|
-
*
|
|
56
|
-
* A plain object because that is all a field is — a kind and, if it belongs in
|
|
57
|
-
* the URL, a parameter name. Three exported builder functions would add three
|
|
58
|
-
* very general names to a module a project imports by name (`text` above all)
|
|
59
|
-
* and would return exactly this.
|
|
60
|
-
*
|
|
61
|
-
* The kinds are closed, and deliberately few:
|
|
34
|
+
* What a field is, as data rather than a constructor: builder functions would
|
|
35
|
+
* put names as general as `text` into every module that imports this.
|
|
62
36
|
*
|
|
63
37
|
* - `text` — free entry, folded and matched word by word. The search box.
|
|
64
|
-
* - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
|
|
65
|
-
*
|
|
66
|
-
* `ItemValue`.
|
|
67
|
-
* - `flag` — a narrowing toggle: off matches everything, on keeps only the
|
|
68
|
-
* items that carry it. "In stock", "Step-free access".
|
|
38
|
+
* - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
|
|
39
|
+
* - `flag` — a narrowing toggle: off keeps everything, on keeps what carries it.
|
|
69
40
|
*
|
|
70
|
-
* `flag` is not a tri-state
|
|
71
|
-
* a `choice` with two values; folding that into a toggle gives a control with a
|
|
72
|
-
* third position nothing can reach.
|
|
41
|
+
* `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
|
|
73
42
|
*/
|
|
74
43
|
export type Field =
|
|
75
44
|
| { readonly kind: "text"; readonly param?: string }
|
|
@@ -79,36 +48,17 @@ export type Field =
|
|
|
79
48
|
/** The fields of one list, named by the caller. */
|
|
80
49
|
export type FieldMap = Readonly<Record<string, Field>>;
|
|
81
50
|
|
|
82
|
-
/**
|
|
83
|
-
* What one field contributes, in the state.
|
|
84
|
-
*
|
|
85
|
-
* Always one value per field, whatever the kind: a `choice` is the one category
|
|
86
|
-
* asked for, a `text` is what someone typed, a `flag` is whether the toggle is
|
|
87
|
-
* on. This is what `set` takes and what reaches the address bar.
|
|
88
|
-
*/
|
|
51
|
+
/** What one field contributes in the state: what was typed, picked or toggled. */
|
|
89
52
|
export type StateValue<F extends Field> = F["kind"] extends "flag"
|
|
90
53
|
? boolean
|
|
91
54
|
: string;
|
|
92
55
|
|
|
93
56
|
/**
|
|
94
|
-
* What one field contributes
|
|
57
|
+
* What one field contributes on an item: what it *is*, where the state holds
|
|
58
|
+
* what was *asked for*. A `text` value is everything that field searches.
|
|
95
59
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* and a blurb joined — while in the state it is what someone typed; a `flag` is
|
|
99
|
-
* what the item *is* on one side and what was *asked for* on the other.
|
|
100
|
-
*
|
|
101
|
-
* **A `choice` item may hold several values, and then it answers to any of
|
|
102
|
-
* them.** The asymmetry is the point: an escape room is adventure *and* sci-fi,
|
|
103
|
-
* a film is a comedy *and* a drama, but the tab strip above them still picks
|
|
104
|
-
* one, and the URL still carries one. Widened here rather than given a kind of
|
|
105
|
-
* its own, because a second kind would carry the same state, the same
|
|
106
|
-
* parameter, the same `set` semantics and the same control, and differ by one
|
|
107
|
-
* operator — two kinds doing one job, which is exactly what `Field` refuses
|
|
108
|
-
* when it declines to let `flag` become a tri-state.
|
|
109
|
-
*
|
|
110
|
-
* A list whose items each sit in one category writes a bare string and is
|
|
111
|
-
* untouched by this.
|
|
60
|
+
* **A `choice` item may hold several values, and then answers to any of them** —
|
|
61
|
+
* a room is adventure *and* sci-fi, while the chips still pick one.
|
|
112
62
|
*/
|
|
113
63
|
export type ItemValue<F extends Field> = F["kind"] extends "flag"
|
|
114
64
|
? boolean
|
|
@@ -135,13 +85,7 @@ export type FilterState<F extends FieldMap> = {
|
|
|
135
85
|
};
|
|
136
86
|
|
|
137
87
|
export interface FilterItem<F extends FieldMap> {
|
|
138
|
-
/**
|
|
139
|
-
* How the caller finds this item again.
|
|
140
|
-
*
|
|
141
|
-
* Opaque here and unique across the list — a duplicate is rejected at
|
|
142
|
-
* construction, because two items answering to one key make `matched`
|
|
143
|
-
* unable to say which of them matched.
|
|
144
|
-
*/
|
|
88
|
+
/** Unique across the list: a duplicate makes `matched` ambiguous, and is refused. */
|
|
145
89
|
readonly key: string;
|
|
146
90
|
readonly values: ItemValues<F>;
|
|
147
91
|
}
|
|
@@ -149,11 +93,8 @@ export interface FilterItem<F extends FieldMap> {
|
|
|
149
93
|
export interface FilterChange<F extends FieldMap> {
|
|
150
94
|
readonly state: FilterState<F>;
|
|
151
95
|
/**
|
|
152
|
-
* The keys that survive every field at once.
|
|
153
|
-
*
|
|
154
|
-
* A set rather than a filtered list, so a caller renders by asking about the
|
|
155
|
-
* items it already holds instead of diffing two arrays — and `size` is the
|
|
156
|
-
* count a "showing N" line wants, with no second pass.
|
|
96
|
+
* The keys that survive every field at once. A set, so a caller asks about
|
|
97
|
+
* the items it already holds, and `size` is the count a "showing N" wants.
|
|
157
98
|
*/
|
|
158
99
|
readonly matched: ReadonlySet<string>;
|
|
159
100
|
}
|
|
@@ -161,38 +102,24 @@ export interface FilterChange<F extends FieldMap> {
|
|
|
161
102
|
export interface FiltersOptions<F extends FieldMap> {
|
|
162
103
|
readonly fields: F;
|
|
163
104
|
/**
|
|
164
|
-
* The full list, every time — this filters, it does not paginate.
|
|
165
|
-
*
|
|
166
|
-
* Fixed for the lifetime of the instance. A list whose contents change is a
|
|
167
|
-
* new instance, which is one line at the call site and avoids this owning a
|
|
168
|
-
* second lifecycle it would have to keep in step with `attach`.
|
|
105
|
+
* The full list, every time — this filters, it does not paginate. Fixed for
|
|
106
|
+
* the instance's lifetime; a list whose contents change is a new instance.
|
|
169
107
|
*/
|
|
170
108
|
readonly items: readonly FilterItem<F>[];
|
|
171
109
|
/**
|
|
172
110
|
* Called whenever the state moves, and once on `attach` with whatever the
|
|
173
|
-
* URL already said.
|
|
174
|
-
*
|
|
175
|
-
* Never called for a `set` that changes nothing: re-rendering an unchanged
|
|
176
|
-
* list on every keystroke that did not alter the query is work a caller
|
|
177
|
-
* cannot skip on its own, because by then it has been told the state
|
|
178
|
-
* changed.
|
|
111
|
+
* URL already said. Never called for a `set` that changed nothing.
|
|
179
112
|
*/
|
|
180
113
|
onChange(change: FilterChange<F>): void;
|
|
181
114
|
}
|
|
182
115
|
|
|
183
116
|
export interface SetOptions {
|
|
184
117
|
/**
|
|
185
|
-
* Whether this move is worth a step in history.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* spelled, while picking a category is one deliberate act the back button
|
|
191
|
-
* should undo.
|
|
192
|
-
*
|
|
193
|
-
* It is an override rather than a fixed rule because the default is really
|
|
194
|
-
* a fact about the *input*, not the field. A `<select>` that sets a `text`
|
|
195
|
-
* field, or a preset link, is a deliberate choice and wants `"push"`.
|
|
118
|
+
* Whether this move is worth a step in history. Defaults from the kind —
|
|
119
|
+
* `text` replaces, `choice` and `flag` push — because typing would otherwise
|
|
120
|
+
* fill history with a word being spelled. An override, because the default
|
|
121
|
+
* is really a fact about the input: a `<select>` setting a `text` field is a
|
|
122
|
+
* deliberate choice and wants `"push"`.
|
|
196
123
|
*/
|
|
197
124
|
readonly history?: "push" | "replace";
|
|
198
125
|
}
|
|
@@ -201,26 +128,13 @@ export interface Filters<F extends FieldMap> {
|
|
|
201
128
|
readonly state: FilterState<F>;
|
|
202
129
|
readonly matched: ReadonlySet<string>;
|
|
203
130
|
/**
|
|
204
|
-
* The keys that survive every field *except* this one
|
|
131
|
+
* The keys that survive every field *except* this one: what a facet asks of
|
|
132
|
+
* its own options, so each can say what picking it would give. Counted
|
|
133
|
+
* against `matched` instead, a facet that is already set could only ever
|
|
134
|
+
* offer its own value.
|
|
205
135
|
*
|
|
206
|
-
* The
|
|
207
|
-
*
|
|
208
|
-
* against `matched` instead, a dropdown whose value is already set could
|
|
209
|
-
* only ever offer that value — every other option would read zero, since
|
|
210
|
-
* the items holding it have just been filtered out by the very field being
|
|
211
|
-
* asked about.
|
|
212
|
-
*
|
|
213
|
-
* The same matcher as `matched`, folding and tokens included, rather than a
|
|
214
|
-
* second one in the caller. A facet count computed by a hand-written
|
|
215
|
-
* predicate drifts from the list it is counting the moment either changes —
|
|
216
|
-
* a count of four above a list of three — and nothing reports it.
|
|
217
|
-
*
|
|
218
|
-
* Computed on first ask and kept until the state next moves, so a render
|
|
219
|
-
* that asks once per option pays one pass per field rather than one per
|
|
220
|
-
* option. A field that is not narrowing anything answers with `matched`
|
|
221
|
-
* itself: without it, the rest is exactly what already matched.
|
|
222
|
-
*
|
|
223
|
-
* Safe to call from `onChange`, which never runs during construction.
|
|
136
|
+
* The same matcher as `matched`, so a count cannot drift from the list it
|
|
137
|
+
* counts. Computed on first ask and kept until the state moves.
|
|
224
138
|
*/
|
|
225
139
|
matchedWithout(field: keyof F): ReadonlySet<string>;
|
|
226
140
|
set<K extends keyof F>(
|
|
@@ -233,17 +147,10 @@ export interface Filters<F extends FieldMap> {
|
|
|
233
147
|
/**
|
|
234
148
|
* Reads the URL, applies it, and starts listening — returns the undo.
|
|
235
149
|
*
|
|
236
|
-
* One lifecycle
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* `astro:page-load` and calling the returned function on teardown is what
|
|
241
|
-
* makes that correct — and the listener is aborted by its own undo, so it
|
|
242
|
-
* cannot outlive the page that added it.
|
|
243
|
-
*
|
|
244
|
-
* Nothing writes to the URL before this runs. `attach` is what reads it, and
|
|
245
|
-
* a `set` on an unattached instance would otherwise overwrite state it never
|
|
246
|
-
* loaded.
|
|
150
|
+
* One lifecycle, which matters with view transitions: a module bound at top
|
|
151
|
+
* level runs once per session, not once per navigation, so the incoming page
|
|
152
|
+
* would get a live list and dead controls. Nothing writes to the URL before
|
|
153
|
+
* this runs.
|
|
247
154
|
*/
|
|
248
155
|
attach(): () => void;
|
|
249
156
|
}
|
|
@@ -252,30 +159,17 @@ export interface Filters<F extends FieldMap> {
|
|
|
252
159
|
const DIACRITICS = /[̀-ͯ]/g;
|
|
253
160
|
|
|
254
161
|
/**
|
|
255
|
-
* A string reduced to what a query should match against.
|
|
256
|
-
*
|
|
257
|
-
* Decompose, drop the marks, lowercase, trim. This is the part every list needs
|
|
258
|
-
* and few have: without it a reader typing `malaga` is told there is no
|
|
259
|
-
* *Málaga*, and a Romanian catalogue hides every entry spelled with `ă`, `ș` or
|
|
260
|
-
* `ț` from anyone whose keyboard does not carry them.
|
|
162
|
+
* A string reduced to what a query should match against. Without it, a reader
|
|
163
|
+
* typing `malaga` is told there is no *Málaga*.
|
|
261
164
|
*/
|
|
262
165
|
function fold(value: string): string {
|
|
263
166
|
return value.normalize("NFD").replace(DIACRITICS, "").toLowerCase().trim();
|
|
264
167
|
}
|
|
265
168
|
|
|
266
169
|
/**
|
|
267
|
-
* How a set flag is spelled in the query string, and the only spelling read
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* An unset flag is *absent* rather than `=0`. One way to say off keeps the URL
|
|
271
|
-
* short and means there is a single form to handle; two would both have to be
|
|
272
|
-
* understood forever, and a reader could not tell which of them a link was
|
|
273
|
-
* carrying. The cost is that a hand-written `?featured=0` reads as off, which is
|
|
274
|
-
* the answer it would get anyway.
|
|
275
|
-
*
|
|
276
|
-
* `1` and not `true` because that is what the existing lists already emit:
|
|
277
|
-
* links people have shared or bookmarked resolve to the view they named, and a
|
|
278
|
-
* spelling change would quietly reset every one of them.
|
|
170
|
+
* How a set flag is spelled in the query string, and the only spelling read back
|
|
171
|
+
* as set: an unset flag is *absent* rather than `=0`. `1` and not `true`
|
|
172
|
+
* because that is what shared links already carry.
|
|
279
173
|
*/
|
|
280
174
|
const FLAG_ON = "1";
|
|
281
175
|
|
|
@@ -285,10 +179,9 @@ export function filters<const F extends FieldMap>(
|
|
|
285
179
|
const { fields, items, onChange } = options;
|
|
286
180
|
const names = Object.keys(fields) as (keyof F & string)[];
|
|
287
181
|
|
|
288
|
-
// Checks
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// other's value, which reads as a filter that will not stay set.
|
|
182
|
+
// Checks types cannot express, and both failures are otherwise silent: a
|
|
183
|
+
// duplicate key makes `matched` ambiguous, and two fields sharing a
|
|
184
|
+
// parameter means each URL write erases the other's value.
|
|
292
185
|
const keys = new Set<string>();
|
|
293
186
|
for (const item of items) {
|
|
294
187
|
if (keys.has(item.key)) {
|
|
@@ -308,13 +201,8 @@ export function filters<const F extends FieldMap>(
|
|
|
308
201
|
params.add(param);
|
|
309
202
|
}
|
|
310
203
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
*
|
|
314
|
-
* Folding is four string operations, and doing it per item per keystroke is
|
|
315
|
-
* the difference between a list that filters as you type and one that
|
|
316
|
-
* stutters. Only the query is folded per render, and there is one of those.
|
|
317
|
-
*/
|
|
204
|
+
// Folded once at construction: doing it per item per keystroke is the
|
|
205
|
+
// difference between filtering as you type and stuttering.
|
|
318
206
|
const haystacks = new Map<string, Map<string, string>>();
|
|
319
207
|
for (const item of items) {
|
|
320
208
|
const perField = new Map<string, string>();
|
|
@@ -338,32 +226,22 @@ export function filters<const F extends FieldMap>(
|
|
|
338
226
|
let state = blank();
|
|
339
227
|
let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
|
|
340
228
|
|
|
341
|
-
/**
|
|
342
|
-
* Each `text` field's query, folded and split, for the current state.
|
|
343
|
-
*
|
|
344
|
-
* Folded once per state rather than once per item. The other kinds compare
|
|
345
|
-
* values as they are, so there is nothing to prepare for them.
|
|
346
|
-
*/
|
|
229
|
+
/** Each `text` field's query, folded and split once per state. */
|
|
347
230
|
let queries = new Map<string, readonly string[]>();
|
|
348
231
|
|
|
349
232
|
/** `matchedWithout` answers for the current state. Emptied when it moves. */
|
|
350
233
|
const without = new Map<string, ReadonlySet<string>>();
|
|
351
234
|
|
|
352
235
|
/**
|
|
353
|
-
* Whether the URL is this instance's to write.
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* flag: an instance that was built but never attached has not read the URL,
|
|
357
|
-
* so writing to it would replace state that nobody here has loaded.
|
|
236
|
+
* Whether the URL is this instance's to write. An instance that was built
|
|
237
|
+
* but never attached has not read the URL, so writing would replace state
|
|
238
|
+
* nobody here loaded.
|
|
358
239
|
*/
|
|
359
240
|
let attached = false;
|
|
360
241
|
|
|
361
242
|
/**
|
|
362
|
-
* Whether a field is narrowing anything
|
|
363
|
-
*
|
|
364
|
-
* An empty query, no choice and a flag that is off all keep every item —
|
|
365
|
-
* the flag above all, which keeps everything rather than keeping the items
|
|
366
|
-
* that are *not* flagged. See `Field`.
|
|
243
|
+
* Whether a field is narrowing anything now. An empty query, no choice and
|
|
244
|
+
* a flag that is off all keep every item — the flag above all.
|
|
367
245
|
*/
|
|
368
246
|
const narrows = (name: keyof F & string): boolean => {
|
|
369
247
|
const kind = fields[name]?.kind;
|
|
@@ -373,34 +251,26 @@ export function filters<const F extends FieldMap>(
|
|
|
373
251
|
};
|
|
374
252
|
|
|
375
253
|
/**
|
|
376
|
-
* Whether one item survives every field but `except
|
|
377
|
-
*
|
|
378
|
-
* The one matcher. `matched` is this with nothing excepted and
|
|
379
|
-
* `matchedWithout` is this with one field excepted, so a facet count and
|
|
380
|
-
* the list it counts cannot disagree about what a match is.
|
|
254
|
+
* Whether one item survives every field but `except`. The one matcher, so a
|
|
255
|
+
* facet count and the list it counts cannot disagree about what a match is.
|
|
381
256
|
*/
|
|
382
257
|
function passes(item: FilterItem<F>, except?: keyof F & string): boolean {
|
|
383
258
|
for (const name of names) {
|
|
384
259
|
if (name === except || !narrows(name)) continue;
|
|
385
260
|
const kind = fields[name]?.kind;
|
|
386
261
|
if (kind === "text") {
|
|
387
|
-
// Every token must appear,
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// the query as one run made that accident load-bearing:
|
|
392
|
-
// against "Copper Kettle" in the Kitchen category, "kettle
|
|
393
|
-
// kitchen" found nothing while "kettle" alone worked, and the
|
|
394
|
-
// first is what someone narrowing a list types.
|
|
262
|
+
// Every token must appear, rather than the query as one run: a
|
|
263
|
+
// haystack is several things joined, and matching it whole made
|
|
264
|
+
// the joining order load-bearing — "kettle kitchen" found
|
|
265
|
+
// nothing while "kettle" worked.
|
|
395
266
|
const tokens = queries.get(name) ?? [];
|
|
396
267
|
const haystack = haystacks.get(item.key)?.get(name) ?? "";
|
|
397
268
|
if (!tokens.every((token) => haystack.includes(token))) {
|
|
398
269
|
return false;
|
|
399
270
|
}
|
|
400
271
|
} else if (kind === "choice") {
|
|
401
|
-
// An item may sit in several categories while the control
|
|
402
|
-
//
|
|
403
|
-
// question asked of a set of one.
|
|
272
|
+
// An item may sit in several categories while the control picks
|
|
273
|
+
// one — see `ItemValue`.
|
|
404
274
|
const wanted = state[name] as string;
|
|
405
275
|
const held = item.values[name];
|
|
406
276
|
const holds = Array.isArray(held)
|
|
@@ -438,11 +308,8 @@ export function filters<const F extends FieldMap>(
|
|
|
438
308
|
if (field === undefined) continue;
|
|
439
309
|
if (field.param === undefined) {
|
|
440
310
|
// A state-only field is not in the URL, so the URL has nothing
|
|
441
|
-
// to say about it
|
|
442
|
-
//
|
|
443
|
-
// wiped a search the reader was in the middle of the moment any
|
|
444
|
-
// other parameter on the page moved. Left as it is instead: this
|
|
445
|
-
// function applies the URL, and this field is not in it.
|
|
311
|
+
// to say about it. Clearing it here wiped a search mid-typing
|
|
312
|
+
// whenever another parameter on the page moved.
|
|
446
313
|
next[name] = state[name];
|
|
447
314
|
continue;
|
|
448
315
|
}
|
|
@@ -455,18 +322,11 @@ export function filters<const F extends FieldMap>(
|
|
|
455
322
|
function writeUrl(history: "push" | "replace"): void {
|
|
456
323
|
if (!attached) return;
|
|
457
324
|
|
|
458
|
-
// Started from the URL that is there
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// showed up somewhere else: a campaign that stopped being attributed the
|
|
464
|
-
// first time a visitor typed in the search box.
|
|
465
|
-
//
|
|
466
|
-
// Its own are deleted first, then rewritten, so a parameter this list
|
|
467
|
-
// owns and no longer needs is dropped rather than surviving because
|
|
468
|
-
// nobody thought to remove it. Deletion and writing both run in
|
|
469
|
-
// declaration order, so one state still produces one URL.
|
|
325
|
+
// Started from the URL that is there: the page is full of parameters
|
|
326
|
+
// that are not this list's business (`utm_*`, a page number), and
|
|
327
|
+
// building from scratch dropped them — a campaign stopped being
|
|
328
|
+
// attributed the first time someone typed in the search box. Its own
|
|
329
|
+
// are deleted first, then rewritten, so one state gives one URL.
|
|
470
330
|
const search = new URLSearchParams(window.location.search);
|
|
471
331
|
for (const name of names) {
|
|
472
332
|
const param = fields[name]?.param;
|
|
@@ -484,21 +344,13 @@ export function filters<const F extends FieldMap>(
|
|
|
484
344
|
}
|
|
485
345
|
|
|
486
346
|
const query = search.toString();
|
|
487
|
-
// The bare path when nothing is left, rather than a trailing
|
|
488
|
-
// two are the same page, and only one of them is worth sharing. Read
|
|
489
|
-
// from the merged set, so a page carrying somebody else's parameter
|
|
490
|
-
// keeps it instead of being reduced to its path.
|
|
347
|
+
// The bare path when nothing is left, rather than a trailing `?`.
|
|
491
348
|
const url = query === "" ? window.location.pathname : `?${query}`;
|
|
492
349
|
if (history === "push") window.history.pushState({}, "", url);
|
|
493
350
|
else window.history.replaceState({}, "", url);
|
|
494
351
|
}
|
|
495
352
|
|
|
496
|
-
/**
|
|
497
|
-
* One field's value as the matcher and the URL will read it.
|
|
498
|
-
*
|
|
499
|
-
* Only `text` has a form that differs from what was handed over — the other
|
|
500
|
-
* kinds compare and publish exactly what they hold.
|
|
501
|
-
*/
|
|
353
|
+
/** One field's value as the matcher and the URL will read it. */
|
|
502
354
|
const normalized = (name: keyof F & string, value: unknown): unknown =>
|
|
503
355
|
fields[name]?.kind === "text" ? (value as string).trim() : value;
|
|
504
356
|
|
|
@@ -530,18 +382,12 @@ export function filters<const F extends FieldMap>(
|
|
|
530
382
|
|
|
531
383
|
set(field, value, setOptions) {
|
|
532
384
|
const name = field as keyof F & string;
|
|
533
|
-
// Compared as everything downstream will read it
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// The *raw* value is what gets stored, though, and that asymmetry is
|
|
541
|
-
// deliberate. A search box mirrors the state back into the input
|
|
542
|
-
// (see `filters-view`), so normalising here would delete the space a
|
|
543
|
-
// reader had just typed, from under the caret, every time they
|
|
544
|
-
// reached for the second word.
|
|
385
|
+
// Compared as everything downstream will read it: "sol" and "sol "
|
|
386
|
+
// filter the same list, and comparing them raw cost a recompute, a
|
|
387
|
+
// `replaceState` to the same URL and a full re-render. The *raw*
|
|
388
|
+
// value is stored, though — the search box mirrors the state back
|
|
389
|
+
// into the input, and normalising here would delete the space a
|
|
390
|
+
// reader just typed.
|
|
545
391
|
if (normalized(name, state[name]) === normalized(name, value)) {
|
|
546
392
|
return;
|
|
547
393
|
}
|
|
@@ -555,11 +401,8 @@ export function filters<const F extends FieldMap>(
|
|
|
555
401
|
|
|
556
402
|
reset(field) {
|
|
557
403
|
if (field === undefined) {
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
// no-op, and pushing for it puts a step in history that goes
|
|
561
|
-
// back to the state it is already in — so the back button looks
|
|
562
|
-
// broken to the one reader who pressed clear twice.
|
|
404
|
+
// A "clear all" on an unfiltered list is a no-op; pushing for it
|
|
405
|
+
// puts a history step that goes back to where it already is.
|
|
563
406
|
if (names.every((name) => state[name] === empty(name))) return;
|
|
564
407
|
state = blank();
|
|
565
408
|
} else {
|
|
@@ -575,10 +418,8 @@ export function filters<const F extends FieldMap>(
|
|
|
575
418
|
attach() {
|
|
576
419
|
const listeners = new AbortController();
|
|
577
420
|
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
// page is the one leak here that has no visible symptom: it keeps
|
|
581
|
-
// rendering into markup that has been replaced.
|
|
421
|
+
// A `popstate` listener that outlives its page is the leak with no
|
|
422
|
+
// visible symptom: it keeps rendering into markup that was replaced.
|
|
582
423
|
window.addEventListener(
|
|
583
424
|
"popstate",
|
|
584
425
|
() => {
|
|
@@ -590,10 +431,9 @@ export function filters<const F extends FieldMap>(
|
|
|
590
431
|
);
|
|
591
432
|
|
|
592
433
|
attached = true;
|
|
593
|
-
// Read before the first announcement
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
// first would flash the unfiltered list over it.
|
|
434
|
+
// Read before the first announcement: the URL may already carry a
|
|
435
|
+
// state, and announcing the empty one first flashes the unfiltered
|
|
436
|
+
// list over it.
|
|
597
437
|
readUrl();
|
|
598
438
|
recompute();
|
|
599
439
|
announce();
|