@escape-game-over/atlas 0.1.2 → 0.1.4

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
+ }