@escape-game-over/atlas 0.1.25 → 0.1.27

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.
@@ -3,14 +3,9 @@
3
3
  *
4
4
  * ```ts
5
5
  * const list = filters({
6
- * fields: {
7
- * q: { kind: "text" },
8
- * category: { kind: "choice" },
9
- * },
10
- * items: entries.map((entry) => ({
11
- * key: entry.id,
12
- * values: { q: `${entry.title} ${entry.summary}`, category: entry.category },
13
- * })),
6
+ * fields: { q: "text", category: "choice" },
7
+ * items: entries,
8
+ * values: (entry) => ({ q: `${entry.title} ${entry.summary}`, category: entry.category }),
14
9
  * onChange: ({ matched }) => { /* every DOM write is the project's *\/ },
15
10
  * });
16
11
  *
@@ -40,19 +35,30 @@
40
35
  *
41
36
  * `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
42
37
  */
43
- export interface Field {
44
- readonly kind: "text" | "choice" | "flag";
45
- /** The URL parameter. Defaults to the field's name; `false` keeps it out of the URL. */
46
- readonly param?: string | false;
47
- }
38
+ export type FieldKind = "text" | "choice" | "flag";
39
+
40
+ /**
41
+ * A field: its kind, which puts it in the URL under the field's own name, or
42
+ * `{ kind, url: false }` to keep it out. To rename the parameter, rename the
43
+ * field.
44
+ */
45
+ export type Field =
46
+ | FieldKind
47
+ | { readonly kind: FieldKind; readonly url: false };
48
+
49
+ /** A field's kind, from either spelling. */
50
+ export type KindOf<F extends Field> = F extends FieldKind
51
+ ? F
52
+ : F extends { readonly kind: infer K }
53
+ ? K
54
+ : never;
48
55
 
49
56
  /** The fields of one list, named by the caller. */
50
57
  export type FieldMap = Readonly<Record<string, Field>>;
51
58
 
52
59
  /** What one field contributes in the state: what was typed, picked or toggled. */
53
- export type StateValue<F extends Field> = F["kind"] extends "flag"
54
- ? boolean
55
- : string;
60
+ export type StateValue<F extends Field> =
61
+ KindOf<F> extends "flag" ? boolean : string;
56
62
 
57
63
  /**
58
64
  * What one field contributes on an item: what it *is*, where the state holds
@@ -61,11 +67,12 @@ export type StateValue<F extends Field> = F["kind"] extends "flag"
61
67
  * **A `choice` item may hold several values, and then answers to any of them** —
62
68
  * a room is adventure *and* sci-fi, while the chips still pick one.
63
69
  */
64
- export type ItemValue<F extends Field> = F["kind"] extends "flag"
65
- ? boolean
66
- : F["kind"] extends "choice"
67
- ? string | readonly string[]
68
- : string;
70
+ export type ItemValue<F extends Field> =
71
+ KindOf<F> extends "flag"
72
+ ? boolean
73
+ : KindOf<F> extends "choice"
74
+ ? string | readonly string[]
75
+ : string;
69
76
 
70
77
  /** Every field's value for one item, derived from the field declaration. */
71
78
  export type ItemValues<F extends FieldMap> = {
@@ -77,33 +84,31 @@ export type FilterState<F extends FieldMap> = {
77
84
  readonly [K in keyof F]: StateValue<F[K]>;
78
85
  };
79
86
 
80
- export interface FilterItem<F extends FieldMap> {
81
- /** Unique across the list: a duplicate makes `matched` ambiguous, and is refused. */
82
- readonly key: string;
83
- readonly values: ItemValues<F>;
84
- }
85
-
86
- export interface FilterChange<F extends FieldMap> {
87
+ export interface FilterChange<F extends FieldMap, T> {
87
88
  readonly state: FilterState<F>;
88
89
  /**
89
- * The keys that survive every field at once. A set, so a caller asks about
90
- * the items it already holds, and `size` is the count a "showing N" wants.
90
+ * The items that survive every field at once — the caller's own, so it asks
91
+ * `matched.has(row)` of what it already holds, and `size` is the count a
92
+ * "showing N" wants.
91
93
  */
92
- readonly matched: ReadonlySet<string>;
94
+ readonly matched: ReadonlySet<T>;
93
95
  }
94
96
 
95
- export interface FiltersOptions<F extends FieldMap> {
97
+ export interface FiltersOptions<F extends FieldMap, T> {
96
98
  readonly fields: F;
97
99
  /**
98
100
  * The full list, every time — this filters, it does not paginate. Fixed for
99
101
  * the instance's lifetime; a list whose contents change is a new instance.
102
+ * Anything: elements, records, whatever the caller already holds.
100
103
  */
101
- readonly items: readonly FilterItem<F>[];
104
+ readonly items: readonly T[];
105
+ /** Each item's value for every field. Read once per item, at construction. */
106
+ values(item: T): ItemValues<F>;
102
107
  /**
103
108
  * Called whenever the state moves, and once on `attach` with whatever the
104
109
  * URL already said. Never called for a `set` that changed nothing.
105
110
  */
106
- onChange(change: FilterChange<F>): void;
111
+ onChange(change: FilterChange<F, T>): void;
107
112
  }
108
113
 
109
114
  export interface SetOptions {
@@ -117,11 +122,11 @@ export interface SetOptions {
117
122
  readonly history?: "push" | "replace";
118
123
  }
119
124
 
120
- export interface Filters<F extends FieldMap> {
125
+ export interface Filters<F extends FieldMap, T> {
121
126
  readonly state: FilterState<F>;
122
- readonly matched: ReadonlySet<string>;
127
+ readonly matched: ReadonlySet<T>;
123
128
  /**
124
- * The keys that survive every field *except* this one: what a facet asks of
129
+ * The items that survive every field *except* this one: what a facet asks of
125
130
  * its own options, so each can say what picking it would give. Counted
126
131
  * against `matched` instead, a facet that is already set could only ever
127
132
  * offer its own value.
@@ -129,7 +134,7 @@ export interface Filters<F extends FieldMap> {
129
134
  * The same matcher as `matched`, so a count cannot drift from the list it
130
135
  * counts. Computed on first ask and kept until the state moves.
131
136
  */
132
- matchedWithout(field: keyof F): ReadonlySet<string>;
137
+ matchedWithout(field: keyof F): ReadonlySet<T>;
133
138
  set<K extends keyof F>(
134
139
  field: K,
135
140
  value: StateValue<F[K]>,
@@ -141,7 +146,7 @@ export interface Filters<F extends FieldMap> {
141
146
  * Also called on every change, after `onChange`. Returns the undo. For a
142
147
  * helper like `searchBox` that keeps its own part of the page in step.
143
148
  */
144
- subscribe(listener: (change: FilterChange<F>) => void): () => void;
149
+ subscribe(listener: (change: FilterChange<F, T>) => void): () => void;
145
150
  /**
146
151
  * Reads the URL, applies it, and starts listening — returns the undo.
147
152
  *
@@ -164,61 +169,38 @@ function fold(value: string): string {
164
169
  return value.normalize("NFD").replace(DIACRITICS, "").toLowerCase().trim();
165
170
  }
166
171
 
167
- /**
168
- * How a set flag is spelled in the query string, and the only spelling read back
169
- * as set: an unset flag is *absent* rather than `=0`. `1` and not `true`
170
- * because that is what shared links already carry.
171
- */
172
- const FLAG_ON = "1";
173
-
174
- export function filters<const F extends FieldMap>(
175
- options: FiltersOptions<F>
176
- ): Filters<F> {
172
+ export function filters<const F extends FieldMap, T>(
173
+ options: FiltersOptions<F, T>
174
+ ): Filters<F, T> {
177
175
  const { fields, items, onChange } = options;
178
176
  const names = Object.keys(fields) as (keyof F & string)[];
179
177
 
180
- // Checks types cannot express, and both failures are otherwise silent: a
181
- // duplicate key makes `matched` ambiguous, and two fields sharing a
182
- // parameter means each URL write erases the other's value.
183
- const keys = new Set<string>();
184
- for (const item of items) {
185
- if (keys.has(item.key)) {
186
- throw new Error(`Two filter items share the key "${item.key}".`);
187
- }
188
- keys.add(item.key);
189
- }
190
- // Field name → URL parameter, for the fields that are in the URL.
191
- const params = new Map<string, string>();
192
- const taken = new Set<string>();
193
- for (const name of names) {
194
- const given = fields[name]?.param;
195
- if (given === false) continue;
196
- const param = given ?? name;
197
- if (taken.has(param)) {
198
- throw new Error(
199
- `Two filter fields share the URL parameter "${param}".`
200
- );
201
- }
202
- taken.add(param);
203
- params.set(name, param);
204
- }
178
+ const kindOf = (name: keyof F & string): FieldKind | undefined => {
179
+ const field = fields[name];
180
+ return typeof field === "string" ? field : field?.kind;
181
+ };
205
182
 
206
- // Folded once at construction: doing it per item per keystroke is the
207
- // difference between filtering as you type and stuttering.
208
- const haystacks = new Map<string, Map<string, string>>();
209
- for (const item of items) {
210
- const perField = new Map<string, string>();
183
+ // The fields in the URL, each under its own name.
184
+ const inUrl = names.filter((name) => {
185
+ const field = fields[name];
186
+ return typeof field === "string" || field?.url !== false;
187
+ });
188
+
189
+ // Each item's values read once, and its text folded once: doing it per item
190
+ // per keystroke is the difference between filtering as you type and
191
+ // stuttering.
192
+ const entries = items.map((item) => {
193
+ const values = options.values(item);
194
+ const haystacks = new Map<string, string>();
211
195
  for (const name of names) {
212
- if (fields[name]?.kind !== "text") continue;
213
- perField.set(name, fold(item.values[name] as string));
196
+ if (kindOf(name) !== "text") continue;
197
+ haystacks.set(name, fold(values[name] as string));
214
198
  }
215
- haystacks.set(item.key, perField);
216
- }
199
+ return { item, values, haystacks };
200
+ });
217
201
 
218
202
  const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
219
- (fields[name]?.kind === "flag" ? false : "") as StateValue<
220
- F[typeof name]
221
- >;
203
+ (kindOf(name) === "flag" ? false : "") as StateValue<F[typeof name]>;
222
204
 
223
205
  const blank = (): FilterState<F> =>
224
206
  Object.fromEntries(
@@ -226,13 +208,13 @@ export function filters<const F extends FieldMap>(
226
208
  ) as FilterState<F>;
227
209
 
228
210
  let state = blank();
229
- let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
211
+ let matched: ReadonlySet<T> = new Set(items);
230
212
 
231
213
  /** Each `text` field's query, folded and split once per state. */
232
214
  let queries = new Map<string, readonly string[]>();
233
215
 
234
216
  /** `matchedWithout` answers for the current state. Emptied when it moves. */
235
- const without = new Map<string, ReadonlySet<string>>();
217
+ const without = new Map<string, ReadonlySet<T>>();
236
218
 
237
219
  /**
238
220
  * Whether the URL is this instance's to write. An instance that was built
@@ -246,7 +228,7 @@ export function filters<const F extends FieldMap>(
246
228
  * a flag that is off all keep every item — the flag above all.
247
229
  */
248
230
  const narrows = (name: keyof F & string): boolean => {
249
- const kind = fields[name]?.kind;
231
+ const kind = kindOf(name);
250
232
  if (kind === "text") return (queries.get(name) ?? []).length > 0;
251
233
  if (kind === "choice") return state[name] !== "";
252
234
  return state[name] === true;
@@ -256,17 +238,20 @@ export function filters<const F extends FieldMap>(
256
238
  * Whether one item survives every field but `except`. The one matcher, so a
257
239
  * facet count and the list it counts cannot disagree about what a match is.
258
240
  */
259
- function passes(item: FilterItem<F>, except?: keyof F & string): boolean {
241
+ function passes(
242
+ entry: (typeof entries)[number],
243
+ except?: keyof F & string
244
+ ): boolean {
260
245
  for (const name of names) {
261
246
  if (name === except || !narrows(name)) continue;
262
- const kind = fields[name]?.kind;
247
+ const kind = kindOf(name);
263
248
  if (kind === "text") {
264
249
  // Every token must appear, rather than the query as one run: a
265
250
  // haystack is several things joined, and matching it whole made
266
251
  // the joining order load-bearing — "kettle kitchen" found
267
252
  // nothing while "kettle" worked.
268
253
  const tokens = queries.get(name) ?? [];
269
- const haystack = haystacks.get(item.key)?.get(name) ?? "";
254
+ const haystack = entry.haystacks.get(name) ?? "";
270
255
  if (!tokens.every((token) => haystack.includes(token))) {
271
256
  return false;
272
257
  }
@@ -274,12 +259,12 @@ export function filters<const F extends FieldMap>(
274
259
  // An item may sit in several categories while the control picks
275
260
  // one — see `ItemValue`.
276
261
  const wanted = state[name] as string;
277
- const held = item.values[name];
262
+ const held = entry.values[name];
278
263
  const holds = Array.isArray(held)
279
264
  ? held.includes(wanted)
280
265
  : held === wanted;
281
266
  if (!holds) return false;
282
- } else if (item.values[name] !== true) {
267
+ } else if (entry.values[name] !== true) {
283
268
  return false;
284
269
  }
285
270
  }
@@ -289,15 +274,15 @@ export function filters<const F extends FieldMap>(
289
274
  function recompute(): void {
290
275
  queries = new Map();
291
276
  for (const name of names) {
292
- if (fields[name]?.kind !== "text") continue;
277
+ if (kindOf(name) !== "text") continue;
293
278
  const folded = fold(state[name] as string);
294
279
  queries.set(name, folded === "" ? [] : folded.split(/\s+/));
295
280
  }
296
281
  without.clear();
297
282
 
298
- const next = new Set<string>();
299
- for (const item of items) {
300
- if (passes(item)) next.add(item.key);
283
+ const next = new Set<T>();
284
+ for (const entry of entries) {
285
+ if (passes(entry)) next.add(entry.item);
301
286
  }
302
287
  matched = next;
303
288
  }
@@ -306,18 +291,19 @@ export function filters<const F extends FieldMap>(
306
291
  const search = new URLSearchParams(window.location.search);
307
292
  const next: Record<string, string | boolean> = {};
308
293
  for (const name of names) {
309
- const field = fields[name];
310
- const param = params.get(name);
311
- if (field === undefined) continue;
312
- if (param === undefined) {
294
+ if (!inUrl.includes(name)) {
313
295
  // A state-only field is not in the URL, so the URL has nothing
314
296
  // to say about it. Clearing it here wiped a search mid-typing
315
297
  // whenever another parameter on the page moved.
316
298
  next[name] = state[name];
317
299
  continue;
318
300
  }
319
- const raw = search.get(param);
320
- next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
301
+ // A flag is on by being there, as an HTML boolean attribute is:
302
+ // `?featured`, and `?featured=1` from an older link, alike.
303
+ next[name] =
304
+ kindOf(name) === "flag"
305
+ ? search.has(name)
306
+ : (search.get(name) ?? "");
321
307
  }
322
308
  state = next as FilterState<F>;
323
309
  }
@@ -331,17 +317,23 @@ export function filters<const F extends FieldMap>(
331
317
  // attributed the first time someone typed in the search box. Its own
332
318
  // are deleted first, then rewritten, so one state gives one URL.
333
319
  const search = new URLSearchParams(window.location.search);
334
- for (const param of params.values()) search.delete(param);
335
- for (const [name, param] of params) {
336
- if (fields[name]?.kind === "flag") {
337
- if (state[name] === true) search.set(param, FLAG_ON);
320
+ for (const name of inUrl) search.delete(name);
321
+ const rest = search.toString();
322
+ const parts = rest === "" ? [] : [rest];
323
+ for (const name of inUrl) {
324
+ // Joined by hand because `URLSearchParams` always writes `=`, and a
325
+ // flag that is on is its bare name.
326
+ if (kindOf(name) === "flag") {
327
+ if (state[name] === true) parts.push(encodeURIComponent(name));
338
328
  continue;
339
329
  }
340
330
  const value = (state[name] as string).trim();
341
- if (value !== "") search.set(param, value);
331
+ if (value !== "") {
332
+ parts.push(new URLSearchParams({ [name]: value }).toString());
333
+ }
342
334
  }
343
335
 
344
- const query = search.toString();
336
+ const query = parts.join("&");
345
337
  // The bare path when nothing is left, rather than a trailing `?`.
346
338
  const url = query === "" ? window.location.pathname : `?${query}`;
347
339
  // `null` on a pushed step, so Astro's `ClientRouter` leaves its popstate
@@ -352,9 +344,9 @@ export function filters<const F extends FieldMap>(
352
344
 
353
345
  /** One field's value as the matcher and the URL will read it. */
354
346
  const normalized = (name: keyof F & string, value: unknown): unknown =>
355
- fields[name]?.kind === "text" ? (value as string).trim() : value;
347
+ kindOf(name) === "text" ? (value as string).trim() : value;
356
348
 
357
- const listeners = new Set<(change: FilterChange<F>) => void>();
349
+ const listeners = new Set<(change: FilterChange<F, T>) => void>();
358
350
  const announce = (): void => {
359
351
  onChange({ state, matched });
360
352
  for (const listener of listeners) listener({ state, matched });
@@ -376,9 +368,9 @@ export function filters<const F extends FieldMap>(
376
368
  const cached = without.get(name);
377
369
  if (cached !== undefined) return cached;
378
370
 
379
- const rest = new Set<string>();
380
- for (const item of items) {
381
- if (passes(item, name)) rest.add(item.key);
371
+ const rest = new Set<T>();
372
+ for (const entry of entries) {
373
+ if (passes(entry, name)) rest.add(entry.item);
382
374
  }
383
375
  without.set(name, rest);
384
376
  return rest;
@@ -398,7 +390,7 @@ export function filters<const F extends FieldMap>(
398
390
 
399
391
  state = { ...state, [name]: value };
400
392
  recompute();
401
- const fallback = fields[name]?.kind === "text" ? "replace" : "push";
393
+ const fallback = kindOf(name) === "text" ? "replace" : "push";
402
394
  writeUrl(setOptions?.history ?? fallback);
403
395
  announce();
404
396
  },
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Finding the elements a script works on, by a selector the template wrote as
3
+ * a plain attribute — `[data-faq-search]` — so what the script asks for is what
4
+ * devtools shows.
5
+ */
6
+
7
+ /** Names the root in an error, for a selector that matched nothing. */
8
+ const where = (root: ParentNode): string =>
9
+ "localName" in root && typeof root.localName === "string"
10
+ ? `<${root.localName}>`
11
+ : "the document";
12
+
13
+ /**
14
+ * The first match under `root`, or a thrown error naming the selector. Inside
15
+ * `element`'s `connect`, that error lands in the dev panel.
16
+ */
17
+ export function ref<T extends Element = HTMLElement>(
18
+ root: ParentNode,
19
+ selector: string
20
+ ): T {
21
+ const found = root.querySelector<T>(selector);
22
+ if (found === null) {
23
+ throw new Error(`${where(root)} has nothing matching ${selector}`);
24
+ }
25
+ return found;
26
+ }
27
+
28
+ /**
29
+ * An element's `data-<name>` value, or a thrown error naming the element and
30
+ * the attribute. For an optional one, read `dataset` directly.
31
+ */
32
+ export function data(element: Element, name: string): string {
33
+ const value = element.getAttribute(`data-${name}`);
34
+ if (value === null) {
35
+ throw new Error(`<${element.localName}> has no data-${name}`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ /** Every match under `root`. None is an answer here, not an error. */
41
+ export function refs<T extends Element = HTMLElement>(
42
+ root: ParentNode,
43
+ selector: string
44
+ ): T[] {
45
+ return [...root.querySelectorAll<T>(selector)];
46
+ }