@escape-game-over/atlas 0.1.3 → 0.1.5

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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The DOM writes every filtered list turns out to make, and only those.
3
+ *
4
+ * `filters` decides which items match and what the address bar says, and hands
5
+ * both to `onChange`. Everything a page does about that answer is its own — and
6
+ * mostly stays that way: three lists in one repository draw their results three
7
+ * different ways, with tabs and a blurb on one, an exclusive `<details>`
8
+ * accordion on another, a two-level country roll-up on the third.
9
+ *
10
+ * These two helpers are what all three, or two of three, wrote identically. That
11
+ * is the whole bar for being here: not "a list might want this" but "every list
12
+ * we have written already did, character for character, and getting it wrong was
13
+ * silent". Anything the three disagreed about is still theirs.
14
+ *
15
+ * **Both take elements, never selectors.** A project decides what its markup is
16
+ * called and hands over what it found, so no attribute name in this package has
17
+ * to be matched by any consumer — the reason `filters` itself has no markup
18
+ * contract, kept intact one layer up.
19
+ *
20
+ * These write to elements, which the rest of `astro/` refuses to do, and the
21
+ * line is worth being exact about. `carousel` will not write a transform because
22
+ * a transform is a design decision with no single right answer. Everything
23
+ * written here is the opposite: an input's `value` is the control's own state,
24
+ * and `hidden` on a clear button, an empty message or a section with nothing
25
+ * left in it is binary and derived. Nothing here sets a class, a style or an
26
+ * `aria` attribute, which is where the choices live.
27
+ *
28
+ * Two notes for the markup on the other side of that boundary:
29
+ *
30
+ * - **`hidden` needs help in a grid.** It is a `display: none` from the user
31
+ * agent, and any author rule setting `display: flex` or `grid` beats it. A
32
+ * project on Tailwind v4 gets `[hidden] { display: none !important }` from
33
+ * preflight and needs nothing; anything else should ship that rule itself, or
34
+ * every one of these writes is inert and the list simply never filters.
35
+ * - **A result count wants `aria-live="polite"`.** Filtering as you type changes
36
+ * the page silently for anyone not looking at it. The attribute belongs on the
37
+ * element in the template, not here — this package is handed elements and does
38
+ * not decide what they are.
39
+ */
40
+
41
+ import type { FieldMap, Filters } from "./filters.ts";
42
+
43
+ /**
44
+ * The fields of `F` that hold text, so a search box cannot be pointed at a flag.
45
+ *
46
+ * `list.set(field, input.value)` hands over a string; aimed at a `flag` that is
47
+ * a type error worth getting at the call site rather than a filter that silently
48
+ * never matches.
49
+ */
50
+ export type TextFieldOf<F extends FieldMap> = {
51
+ [K in keyof F]: F[K] extends { kind: "text" } ? K : never;
52
+ }[keyof F];
53
+
54
+ /**
55
+ * What a search box is made of, as much of it as exists.
56
+ *
57
+ * Every part is optional and `null` is accepted, because `within(root).one(…)`
58
+ * returns `null` and a page is allowed to have a search field with no clear
59
+ * button, or a list with no empty state. Handing over what a lookup returned,
60
+ * without checking it first, is the point.
61
+ */
62
+ export interface SearchBoxElements {
63
+ readonly input?: HTMLInputElement | null;
64
+ /** Clears the field. Hidden while the field is already empty. */
65
+ readonly clear?: HTMLElement | null;
66
+ /**
67
+ * The "nothing matched" message.
68
+ *
69
+ * Tracks the whole result set rather than this field alone — a list emptied
70
+ * by a category is as empty as one emptied by a query, and there is one
71
+ * message either way. It lives here because it is the same line in every
72
+ * consumer, not because it belongs to the search box.
73
+ */
74
+ readonly empty?: HTMLElement | null;
75
+ /**
76
+ * How many matched, written as digits and nothing else.
77
+ *
78
+ * Two of the three write this, and the same way. Digits only, because the
79
+ * sentence around them is not ours to build: a list that reads "6 of 42" in
80
+ * one language reads "6 din 42" in another, and a translated string is not
81
+ * something to take apart in a browser to get at one number. Give the count
82
+ * an element of its own and let the copy sit beside it — which is what the
83
+ * consumer doing this already had to do.
84
+ *
85
+ * Same rule as the rest of this file, one field further: derived, and with
86
+ * no choice in it. Where the number *goes* is still the template's.
87
+ *
88
+ * Give it `aria-live="polite"` there. Filtering as you type changes the
89
+ * page silently for anyone not watching it, and this package is handed
90
+ * elements rather than deciding what they are.
91
+ */
92
+ readonly count?: HTMLElement | null;
93
+ }
94
+
95
+ export interface SearchBox {
96
+ /**
97
+ * Brings the box into line with the state, from inside `onChange`.
98
+ *
99
+ * Takes two scalars rather than the change object, so this stays independent
100
+ * of what the field was named and of which other fields the list has.
101
+ *
102
+ * Safe before `bind` and after the undo it returns: this only writes to the
103
+ * elements it was given, so a box that is no longer wired renders the last
104
+ * thing it was told rather than throwing.
105
+ */
106
+ render(query: string, matchCount: number): void;
107
+ /**
108
+ * Points the input and the clear button at one `text` field of a list, and
109
+ * returns the undo.
110
+ *
111
+ * Separate from construction because of an ordering knot rather than a
112
+ * preference: `render` is called from the list's own `onChange`, so the box
113
+ * has to exist before the list, and this needs the list. Two calls and a
114
+ * `const` each way is the honest version of that; the alternative is a `let`
115
+ * the reader has to hold in their head.
116
+ *
117
+ * One `AbortController`, as everywhere else here — see `carousel.attach`.
118
+ */
119
+ bind<F extends FieldMap>(
120
+ list: Filters<F>,
121
+ field: TextFieldOf<F>
122
+ ): () => void;
123
+ }
124
+
125
+ /**
126
+ * The search input beside a `filters` list, and the two things every one of them
127
+ * has to remember.
128
+ *
129
+ * ```ts
130
+ * const search = searchBox({
131
+ * input: one<HTMLInputElement>("[data-filter-search]"),
132
+ * clear: one("[data-filter-clear]"),
133
+ * empty: one("[data-filter-empty]"),
134
+ * count: one("[data-filter-count]"),
135
+ * });
136
+ *
137
+ * const list = filters({
138
+ * fields, items,
139
+ * onChange({ state, matched }) {
140
+ * search.render(state.q, matched.size);
141
+ * // …everything this list draws for itself
142
+ * },
143
+ * });
144
+ *
145
+ * const unbind = search.bind(list, "q");
146
+ * const detach = list.attach();
147
+ * ```
148
+ *
149
+ * It writes rather than calling back, and that is the only reason it exists.
150
+ * `filters.onChange` is already the callback that hands a consumer the value and
151
+ * gets out of the way; a second one here would hand back `query` and `count` and
152
+ * leave the same three writes to be spelled out at every call site, which is the
153
+ * duplication this was extracted from.
154
+ */
155
+ export function searchBox(elements: SearchBoxElements): SearchBox {
156
+ const { input, clear, empty, count } = elements;
157
+
158
+ return {
159
+ render(query, matchCount) {
160
+ // Guarded by inequality, not written unconditionally. Assigning
161
+ // `value` while someone is typing in the field moves the caret to
162
+ // the end, so the one case this exists for — state that moved
163
+ // without the keyboard, from the back button or a `reset` — must not
164
+ // cost a caret jump on every other keystroke.
165
+ if (input != null && input.value !== query) input.value = query;
166
+ if (clear != null) clear.hidden = query === "";
167
+ if (empty != null) empty.hidden = matchCount > 0;
168
+ if (count != null) count.textContent = String(matchCount);
169
+ },
170
+
171
+ bind(list, field) {
172
+ const listeners = new AbortController();
173
+ const { signal } = listeners;
174
+ // The narrowing `TextFieldOf` already did, restated for the
175
+ // implementation: inside a generic function TypeScript cannot see
176
+ // that this field's value type is `string`, though every caller can.
177
+ const name = field as Parameters<typeof list.set>[0];
178
+
179
+ input?.addEventListener(
180
+ "input",
181
+ () => list.set(name, input.value as never),
182
+ { signal }
183
+ );
184
+
185
+ clear?.addEventListener(
186
+ "click",
187
+ () => {
188
+ list.reset(name);
189
+ // Focus follows the click, because the button the pointer
190
+ // was on has just hidden itself — leaving focus on a
191
+ // `hidden` element strands a keyboard reader at a control
192
+ // that is no longer there.
193
+ input?.focus();
194
+ },
195
+ { signal }
196
+ );
197
+
198
+ return () => listeners.abort();
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Hides each group that has nothing visible left inside it.
205
+ *
206
+ * ```ts
207
+ * onChange({ matched }) {
208
+ * for (const item of items) item.hidden = !matched.has(keyOf(item));
209
+ * hideEmpty(groups, (group) => within(group).all("[data-faq-key]"));
210
+ * }
211
+ * ```
212
+ *
213
+ * A filtered list that renders its results under headings has this problem and
214
+ * nothing else does: hide the items and the headings stay, so a search for one
215
+ * word leaves a page of section titles with nothing under them. It reads as a
216
+ * broken template rather than as a result, which is what makes forgetting it
217
+ * expensive — nothing errors, and the page looks wrong in a way that does not
218
+ * point at the filter.
219
+ *
220
+ * **Reads `hidden`, rather than taking the matched set.** The items were just
221
+ * hidden by the caller, so their state is the shortest true description of what
222
+ * survived — and reading it is what lets this nest. A country is empty when
223
+ * every city in it is hidden; a region is empty when every country in it is
224
+ * hidden, *including the ones this call just hid*.
225
+ *
226
+ * **Which makes the order load-bearing: innermost first.**
227
+ *
228
+ * ```ts
229
+ * hideEmpty(countries, (c) => within(c).all("[data-city]"));
230
+ * hideEmpty(regions, (r) => within(r).all("[data-country]"));
231
+ * ```
232
+ *
233
+ * Run the other way round, the regions are judged against countries that have
234
+ * not been hidden yet, and a region with nothing in it survives.
235
+ *
236
+ * A group with no children at all is hidden, which is the same answer by the
237
+ * same rule — there is nothing visible in it.
238
+ */
239
+ export function hideEmpty<T extends HTMLElement>(
240
+ groups: Iterable<T>,
241
+ childrenOf: (group: T) => Iterable<HTMLElement>
242
+ ): void {
243
+ for (const group of groups) {
244
+ let visible = false;
245
+ for (const child of childrenOf(group)) {
246
+ if (!child.hidden) {
247
+ visible = true;
248
+ break;
249
+ }
250
+ }
251
+ group.hidden = !visible;
252
+ }
253
+ }
@@ -24,9 +24,9 @@
24
24
  * q: { kind: "text", param: "q" },
25
25
  * category: { kind: "choice", param: "category" },
26
26
  * },
27
- * items: games.map((game) => ({
28
- * key: game.id,
29
- * values: { q: `${game.name} ${game.blurb}`, category: game.category },
27
+ * items: entries.map((entry) => ({
28
+ * key: entry.id,
29
+ * values: { q: `${entry.title} ${entry.summary}`, category: entry.category },
30
30
  * })),
31
31
  * onChange: ({ matched }) => {
32
32
  * for (const [key, element] of elements) element.hidden = !matched.has(key);
@@ -63,7 +63,7 @@
63
63
  * - `text` — free entry, folded and substring-matched. The search box.
64
64
  * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
65
65
  * - `flag` — a narrowing toggle: off matches everything, on keeps only the
66
- * items that carry it. "Arena only", "In stock".
66
+ * items that carry it. "In stock", "Step-free access".
67
67
  *
68
68
  * `flag` is not a tri-state and should not become one. On, off and *either* is
69
69
  * a `choice` with two values; folding that into a toggle gives a control with a
@@ -198,8 +198,8 @@ const DIACRITICS = /[̀-ͯ]/g;
198
198
  * A string reduced to what a query should match against.
199
199
  *
200
200
  * Decompose, drop the marks, lowercase, trim. This is the part every list needs
201
- * and few have: without it a reader typing `abilita` is told there is no
202
- * *Abilità*, and a Romanian catalogue hides every entry spelled with `ă`, `ș` or
201
+ * and few have: without it a reader typing `malaga` is told there is no
202
+ * *Málaga*, and a Romanian catalogue hides every entry spelled with `ă`, `ș` or
203
203
  * `ț` from anyone whose keyboard does not carry them.
204
204
  */
205
205
  function fold(value: string): string {
@@ -213,7 +213,7 @@ function fold(value: string): string {
213
213
  * An unset flag is *absent* rather than `=0`. One way to say off keeps the URL
214
214
  * short and means there is a single form to handle; two would both have to be
215
215
  * understood forever, and a reader could not tell which of them a link was
216
- * carrying. The cost is that a hand-written `?arena=0` reads as off, which is
216
+ * carrying. The cost is that a hand-written `?featured=0` reads as off, which is
217
217
  * the answer it would get anyway.
218
218
  *
219
219
  * `1` and not `true` because that is what the existing lists already emit:
@@ -293,10 +293,18 @@ export function filters<const F extends FieldMap>(
293
293
  function recompute(): void {
294
294
  // The query folded once per render rather than once per item. The other
295
295
  // kinds compare values as they are, so there is nothing to prepare.
296
- const queries = new Map<string, string>();
296
+ // Split into tokens, all of which must appear — not one substring that
297
+ // must appear whole. A haystack is several things joined (a title, plus
298
+ // its category, plus its summary), and the order they were joined in is
299
+ // an accident of whoever wrote the template. Matching the query as one
300
+ // run made that accident load-bearing: against "Copper Kettle" in the
301
+ // Kitchen category, "kettle kitchen" found nothing while "kettle" alone
302
+ // worked, and the first is what someone narrowing a list types.
303
+ const queries = new Map<string, string[]>();
297
304
  for (const name of names) {
298
305
  if (fields[name]?.kind !== "text") continue;
299
- queries.set(name, fold(state[name] as string));
306
+ const folded = fold(state[name] as string);
307
+ queries.set(name, folded === "" ? [] : folded.split(/\s+/));
300
308
  }
301
309
 
302
310
  const next = new Set<string>();
@@ -305,13 +313,10 @@ export function filters<const F extends FieldMap>(
305
313
  for (const name of names) {
306
314
  const kind = fields[name]?.kind;
307
315
  if (kind === "text") {
308
- const query = queries.get(name) ?? "";
309
- if (query === "") continue;
310
- if (
311
- !(haystacks.get(item.key)?.get(name) ?? "").includes(
312
- query
313
- )
314
- ) {
316
+ const tokens = queries.get(name) ?? [];
317
+ if (tokens.length === 0) continue;
318
+ const haystack = haystacks.get(item.key)?.get(name) ?? "";
319
+ if (!tokens.every((token) => haystack.includes(token))) {
315
320
  hit = false;
316
321
  break;
317
322
  }
@@ -344,9 +349,13 @@ export function filters<const F extends FieldMap>(
344
349
  const field = fields[name];
345
350
  if (field === undefined) continue;
346
351
  if (field.param === undefined) {
347
- // No parameter: the field is state-only, and a reload starts it
348
- // empty rather than carrying a value the URL never held.
349
- next[name] = empty(name);
352
+ // A state-only field is not in the URL, so the URL has nothing
353
+ // to say about it — and a back button is the URL speaking.
354
+ // Clearing it here read "absent from the URL" as "empty", which
355
+ // wiped a search the reader was in the middle of the moment any
356
+ // other parameter on the page moved. Left as it is instead: this
357
+ // function applies the URL, and this field is not in it.
358
+ next[name] = state[name];
350
359
  continue;
351
360
  }
352
361
  const raw = search.get(field.param);
@@ -358,11 +367,23 @@ export function filters<const F extends FieldMap>(
358
367
  function writeUrl(history: "push" | "replace"): void {
359
368
  if (!attached) return;
360
369
 
361
- // Built from the declaration rather than from the URL that is there, so
362
- // a parameter this list owns and no longer needs is dropped instead of
363
- // surviving because nobody thought to delete it. Fields are written in
364
- // declaration order, so the same state always produces the same URL.
365
- const search = new URLSearchParams();
370
+ // Started from the URL that is there, not from an empty set. A list owns
371
+ // the parameters it declared and nothing else, and the page around it is
372
+ // full of parameters that are not its business `utm_*` on a link
373
+ // someone shared, a page number, another widget's state. Building from
374
+ // scratch published a URL with all of them gone, and the loss only
375
+ // showed up somewhere else: a campaign that stopped being attributed the
376
+ // first time a visitor typed in the search box.
377
+ //
378
+ // Its own are deleted first, then rewritten, so a parameter this list
379
+ // owns and no longer needs is dropped rather than surviving because
380
+ // nobody thought to remove it. Deletion and writing both run in
381
+ // declaration order, so one state still produces one URL.
382
+ const search = new URLSearchParams(window.location.search);
383
+ for (const name of names) {
384
+ const param = fields[name]?.param;
385
+ if (param !== undefined) search.delete(param);
386
+ }
366
387
  for (const name of names) {
367
388
  const field = fields[name];
368
389
  if (field?.param === undefined) continue;
@@ -375,13 +396,24 @@ export function filters<const F extends FieldMap>(
375
396
  }
376
397
 
377
398
  const query = search.toString();
378
- // The bare path when nothing is set, rather than a trailing `?`: the two
379
- // are the same page, and only one of them is worth sharing.
399
+ // The bare path when nothing is left, rather than a trailing `?`: the
400
+ // two are the same page, and only one of them is worth sharing. Read
401
+ // from the merged set, so a page carrying somebody else's parameter
402
+ // keeps it instead of being reduced to its path.
380
403
  const url = query === "" ? window.location.pathname : `?${query}`;
381
404
  if (history === "push") window.history.pushState({}, "", url);
382
405
  else window.history.replaceState({}, "", url);
383
406
  }
384
407
 
408
+ /**
409
+ * One field's value as the matcher and the URL will read it.
410
+ *
411
+ * Only `text` has a form that differs from what was handed over — the other
412
+ * kinds compare and publish exactly what they hold.
413
+ */
414
+ const normalized = (name: keyof F & string, value: unknown): unknown =>
415
+ fields[name]?.kind === "text" ? (value as string).trim() : value;
416
+
385
417
  const announce = (): void => onChange({ state, matched });
386
418
 
387
419
  recompute();
@@ -396,11 +428,21 @@ export function filters<const F extends FieldMap>(
396
428
 
397
429
  set(field, value, setOptions) {
398
430
  const name = field as keyof F & string;
399
- // Nothing changed, so nothing is announced and nothing is pushed.
400
- // Typing a character that does not alter a trimmed query, or
401
- // re-selecting the tab that is already current, should not put a
402
- // step in history for the back button to walk through.
403
- if (state[name] === value) return;
431
+ // Compared as everything downstream will read it, not as it arrived.
432
+ // Both the matcher and the URL trim a query, so "sol" and "sol "
433
+ // filter the same list and publish the same address but comparing
434
+ // them raw called that a change, and a trailing space cost a
435
+ // recompute, a `replaceState` to a URL identical to the current one,
436
+ // and a full re-render of every item.
437
+ //
438
+ // The *raw* value is what gets stored, though, and that asymmetry is
439
+ // deliberate. A search box mirrors the state back into the input
440
+ // (see `filters-view`), so normalising here would delete the space a
441
+ // reader had just typed, from under the caret, every time they
442
+ // reached for the second word.
443
+ if (normalized(name, state[name]) === normalized(name, value)) {
444
+ return;
445
+ }
404
446
 
405
447
  state = { ...state, [name]: value };
406
448
  recompute();
@@ -411,6 +453,12 @@ export function filters<const F extends FieldMap>(
411
453
 
412
454
  reset(field) {
413
455
  if (field === undefined) {
456
+ // Short-circuited like the single-field arm below, which it was
457
+ // not. A "clear all" on a list nobody has filtered yet is a
458
+ // no-op, and pushing for it puts a step in history that goes
459
+ // back to the state it is already in — so the back button looks
460
+ // broken to the one reader who pressed clear twice.
461
+ if (names.every((name) => state[name] === empty(name))) return;
414
462
  state = blank();
415
463
  } else {
416
464
  const name = field as keyof F & string;
@@ -41,7 +41,7 @@ export interface MailEndpoint {
41
41
  */
42
42
  readonly url: HttpsUrl;
43
43
  /**
44
- * The account the message is sent on behalf of: `"b2b_cube"`.
44
+ * The account the message is sent on behalf of, e.g. `"acme_b2b"`.
45
45
  *
46
46
  * What the API looks the mailbox and the allowed domains up by. A domain
47
47
  * that is not registered against it is refused, and nothing configured here
package/src/contact.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Digit, Letter } from "./types.ts";
1
+ import type { CountryCode } from "./countries.ts";
2
+ import type { Digit } from "./types.ts";
2
3
  import { warn } from "./warn.ts";
3
4
 
4
5
  /**
@@ -129,13 +130,13 @@ export function mailtoHref(email: EmailAddress): string {
129
130
  }
130
131
 
131
132
  /**
132
- * An ISO 3166-1 alpha-2 country code: `"IT"`, `"GR"`, `"RO"`.
133
+ * Re-exported so an address and its country code stay one import.
133
134
  *
134
- * Two uppercase letters, which is what `addressCountry` is read as. A name
135
- * `"Italy"`, `"Ιταλία"` is a translation of a country rather than an
136
- * identifier for one, and belongs in the sentence a page prints, not here.
135
+ * Defined beside the list it is read from see `countries.ts`. Imported at the
136
+ * top as well: `export … from` forwards a name without binding it here, and
137
+ * `PostalAddress` below needs it in scope.
137
138
  */
138
- export type CountryCode = `${Letter}${Letter}`;
139
+ export type { CountryCode };
139
140
 
140
141
  /**
141
142
  * Where a place is, to the precision a map needs.