@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.
- package/README.md +17 -1
- package/docs/NOT-BUILT.md +49 -26
- package/docs/checks.md +20 -5
- package/docs/client-scripts.md +307 -0
- package/package.json +10 -4
- package/src/astro/filters-view.ts +253 -0
- package/src/astro/filters.ts +79 -31
- package/src/contact-form.ts +1 -1
- package/src/contact.ts +7 -6
- package/src/countries.ts +314 -0
- package/src/index.ts +7 -0
- package/src/jsonld/faq.ts +105 -0
- package/src/jsonld/index.ts +1 -0
- package/src/jsonld/video.ts +2 -2
|
@@ -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
|
+
}
|
package/src/astro/filters.ts
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* q: { kind: "text", param: "q" },
|
|
25
25
|
* category: { kind: "choice", param: "category" },
|
|
26
26
|
* },
|
|
27
|
-
* items:
|
|
28
|
-
* key:
|
|
29
|
-
* values: { q: `${
|
|
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. "
|
|
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 `
|
|
202
|
-
* *
|
|
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 `?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
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
|
|
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
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
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;
|
package/src/contact-form.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
*
|
|
133
|
+
* Re-exported so an address and its country code stay one import.
|
|
133
134
|
*
|
|
134
|
-
*
|
|
135
|
-
* `
|
|
136
|
-
*
|
|
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
|
|
139
|
+
export type { CountryCode };
|
|
139
140
|
|
|
140
141
|
/**
|
|
141
142
|
* Where a place is, to the precision a map needs.
|