@escape-game-over/atlas 0.1.18 → 0.1.20
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 +33 -3
- package/package.json +2 -1
- package/src/astro/client.ts +23 -0
- package/src/astro/dom.ts +8 -26
- package/src/astro/element.ts +43 -147
- package/src/astro/filters-view.ts +46 -147
- package/src/astro/filters.ts +88 -248
- package/src/astro/markup.ts +217 -148
|
@@ -1,120 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The DOM writes every filtered list
|
|
2
|
+
* The two DOM writes every filtered list turned out to share.
|
|
3
3
|
*
|
|
4
|
-
* `filters` decides which items match
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* accordion on another, a two-level country roll-up on the third.
|
|
4
|
+
* `filters` decides which items match; what a page does about that is its own.
|
|
5
|
+
* These are the parts three lists wrote identically, and getting wrong was
|
|
6
|
+
* silent. Both take elements, never selectors, so no attribute name in this
|
|
7
|
+
* package has to be matched by a consumer.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* silent". Anything the three disagreed about is still theirs.
|
|
9
|
+
* They write only what is binary and derived — an input's `value`, and `hidden`
|
|
10
|
+
* on a clear button, an empty message or a section with nothing left. No class,
|
|
11
|
+
* style or `aria`: that is where the choices live.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
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.
|
|
13
|
+
* Two notes for the markup:
|
|
19
14
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
15
|
+
* - **`hidden` needs help in a grid.** Any author rule setting `display` beats
|
|
16
|
+
* it. Tailwind v4's preflight ships `[hidden] { display: none !important }`;
|
|
17
|
+
* without something like it these writes are inert and nothing filters.
|
|
18
|
+
* - **A result count wants `aria-live="polite"`**, in the template.
|
|
27
19
|
*
|
|
28
|
-
*
|
|
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.
|
|
20
|
+
* See docs/client-scripts.md.
|
|
39
21
|
*/
|
|
40
22
|
|
|
41
23
|
import type { FieldMap, Filters } from "./filters.ts";
|
|
42
24
|
|
|
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
|
-
*/
|
|
25
|
+
/** The `text` fields of `F`, so a search box cannot be pointed at a flag. */
|
|
50
26
|
export type TextFieldOf<F extends FieldMap> = {
|
|
51
27
|
[K in keyof F]: F[K] extends { kind: "text" } ? K : never;
|
|
52
28
|
}[keyof F];
|
|
53
29
|
|
|
54
30
|
/**
|
|
55
|
-
* What a search box is made of, as much of it as exists.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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.
|
|
31
|
+
* What a search box is made of, as much of it as exists. Every part is
|
|
32
|
+
* optional and `null` is accepted, so what a lookup returned can be handed over
|
|
33
|
+
* without checking it first.
|
|
61
34
|
*/
|
|
62
35
|
export interface SearchBoxElements {
|
|
63
36
|
readonly input?: HTMLInputElement | null;
|
|
64
37
|
/** Clears the field. Hidden while the field is already empty. */
|
|
65
38
|
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
|
-
*/
|
|
39
|
+
/** The "nothing matched" message. Tracks the whole result set. */
|
|
74
40
|
readonly empty?: HTMLElement | null;
|
|
75
41
|
/**
|
|
76
|
-
* How many matched,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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.
|
|
42
|
+
* How many matched, as digits and nothing else: the sentence around them is
|
|
43
|
+
* translated, and not something to take apart in a browser. Give the count
|
|
44
|
+
* its own element and let the copy sit beside it.
|
|
91
45
|
*/
|
|
92
46
|
readonly count?: HTMLElement | null;
|
|
93
47
|
}
|
|
94
48
|
|
|
95
49
|
export interface SearchBox {
|
|
96
50
|
/**
|
|
97
|
-
* Brings the box into line with the state, from inside `onChange`.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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.
|
|
51
|
+
* Brings the box into line with the state, from inside `onChange`. Two
|
|
52
|
+
* scalars rather than the change object, so this does not care what the
|
|
53
|
+
* field was named.
|
|
105
54
|
*/
|
|
106
55
|
render(query: string, matchCount: number): void;
|
|
107
56
|
/**
|
|
108
|
-
* Points the input and the clear button at one `text` field
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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`.
|
|
57
|
+
* Points the input and the clear button at one `text` field, and returns
|
|
58
|
+
* the undo. Separate from construction because `render` is called from the
|
|
59
|
+
* list's `onChange`: the box has to exist before the list, and this needs
|
|
60
|
+
* the list.
|
|
118
61
|
*/
|
|
119
62
|
bind<F extends FieldMap>(
|
|
120
63
|
list: Filters<F>,
|
|
@@ -123,45 +66,26 @@ export interface SearchBox {
|
|
|
123
66
|
}
|
|
124
67
|
|
|
125
68
|
/**
|
|
126
|
-
* The search input beside a `filters` list
|
|
127
|
-
* has to remember.
|
|
69
|
+
* The search input beside a `filters` list.
|
|
128
70
|
*
|
|
129
71
|
* ```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
|
-
*
|
|
72
|
+
* const search = searchBox({ input, clear, empty, count });
|
|
137
73
|
* const list = filters({
|
|
138
|
-
* fields,
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* // …everything this list draws for itself
|
|
142
|
-
* },
|
|
74
|
+
* fields,
|
|
75
|
+
* items,
|
|
76
|
+
* onChange: ({ state, matched }) => search.render(state.q, matched.size),
|
|
143
77
|
* });
|
|
144
|
-
*
|
|
145
78
|
* const unbind = search.bind(list, "q");
|
|
146
|
-
* const detach = list.attach();
|
|
147
79
|
* ```
|
|
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
80
|
*/
|
|
155
81
|
export function searchBox(elements: SearchBoxElements): SearchBox {
|
|
156
82
|
const { input, clear, empty, count } = elements;
|
|
157
83
|
|
|
158
84
|
return {
|
|
159
85
|
render(query, matchCount) {
|
|
160
|
-
// Guarded by inequality
|
|
161
|
-
//
|
|
162
|
-
// the
|
|
163
|
-
// without the keyboard, from the back button or a `reset` — must not
|
|
164
|
-
// cost a caret jump on every other keystroke.
|
|
86
|
+
// Guarded by inequality: assigning `value` while someone types
|
|
87
|
+
// moves the caret to the end, and this exists for state that moved
|
|
88
|
+
// without the keyboard — the back button, or a `reset`.
|
|
165
89
|
if (input != null && input.value !== query) input.value = query;
|
|
166
90
|
if (clear != null) clear.hidden = query === "";
|
|
167
91
|
if (empty != null) empty.hidden = matchCount > 0;
|
|
@@ -171,9 +95,8 @@ export function searchBox(elements: SearchBoxElements): SearchBox {
|
|
|
171
95
|
bind(list, field) {
|
|
172
96
|
const listeners = new AbortController();
|
|
173
97
|
const { signal } = listeners;
|
|
174
|
-
// The narrowing `TextFieldOf` already did
|
|
175
|
-
//
|
|
176
|
-
// that this field's value type is `string`, though every caller can.
|
|
98
|
+
// The narrowing `TextFieldOf` already did: inside a generic
|
|
99
|
+
// function TypeScript cannot see that this field holds a string.
|
|
177
100
|
const name = field as Parameters<typeof list.set>[0];
|
|
178
101
|
|
|
179
102
|
input?.addEventListener(
|
|
@@ -186,10 +109,7 @@ export function searchBox(elements: SearchBoxElements): SearchBox {
|
|
|
186
109
|
"click",
|
|
187
110
|
() => {
|
|
188
111
|
list.reset(name);
|
|
189
|
-
//
|
|
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.
|
|
112
|
+
// The button just hid itself; focus must not stay on it.
|
|
193
113
|
input?.focus();
|
|
194
114
|
},
|
|
195
115
|
{ signal }
|
|
@@ -201,40 +121,19 @@ export function searchBox(elements: SearchBoxElements): SearchBox {
|
|
|
201
121
|
}
|
|
202
122
|
|
|
203
123
|
/**
|
|
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.
|
|
124
|
+
* Hides each group that has nothing visible left inside it — the heading
|
|
125
|
+
* problem a list with sections has and nothing else does.
|
|
219
126
|
*
|
|
220
|
-
* **Reads `hidden
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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*.
|
|
127
|
+
* **Reads `hidden` rather than the matched set**, which is what lets it nest: a
|
|
128
|
+
* country is empty when every city is hidden, a region when every country is,
|
|
129
|
+
* *including the ones the previous call just hid*.
|
|
225
130
|
*
|
|
226
|
-
* **
|
|
131
|
+
* **So the order is load-bearing: innermost first.**
|
|
227
132
|
*
|
|
228
133
|
* ```ts
|
|
229
134
|
* hideEmpty(countries, (c) => within(c).all("[data-city]"));
|
|
230
135
|
* hideEmpty(regions, (r) => within(r).all("[data-country]"));
|
|
231
136
|
* ```
|
|
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
137
|
*/
|
|
239
138
|
export function hideEmpty<T extends HTMLElement>(
|
|
240
139
|
groups: Iterable<T>,
|