@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.
- package/docs/client-scripts.md +84 -128
- package/docs/rich-text.md +9 -4
- package/package.json +5 -4
- package/src/astro/AtlasElement.astro +26 -0
- package/src/astro/ConsentElement.astro +20 -14
- package/src/astro/Image.astro +1 -2
- package/src/astro/MetaTags.astro +27 -14
- package/src/astro/RichText.astro +60 -43
- package/src/astro/client.ts +7 -8
- package/src/astro/consent.ts +10 -10
- package/src/astro/element.ts +59 -93
- package/src/astro/filters-view.ts +10 -20
- package/src/astro/filters.ts +108 -116
- package/src/astro/ref.ts +46 -0
- package/src/astro/markup.ts +0 -656
package/src/astro/filters.ts
CHANGED
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ```ts
|
|
5
5
|
* const list = filters({
|
|
6
|
-
* fields: {
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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> =
|
|
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> =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
90
|
-
*
|
|
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<
|
|
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
|
|
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<
|
|
127
|
+
readonly matched: ReadonlySet<T>;
|
|
123
128
|
/**
|
|
124
|
-
* The
|
|
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<
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 (
|
|
213
|
-
|
|
196
|
+
if (kindOf(name) !== "text") continue;
|
|
197
|
+
haystacks.set(name, fold(values[name] as string));
|
|
214
198
|
}
|
|
215
|
-
|
|
216
|
-
}
|
|
199
|
+
return { item, values, haystacks };
|
|
200
|
+
});
|
|
217
201
|
|
|
218
202
|
const empty = (name: keyof F & string): StateValue<F[typeof name]> =>
|
|
219
|
-
(
|
|
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<
|
|
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<
|
|
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 =
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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<
|
|
299
|
-
for (const
|
|
300
|
-
if (passes(
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 !== "")
|
|
331
|
+
if (value !== "") {
|
|
332
|
+
parts.push(new URLSearchParams({ [name]: value }).toString());
|
|
333
|
+
}
|
|
342
334
|
}
|
|
343
335
|
|
|
344
|
-
const query =
|
|
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
|
-
|
|
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<
|
|
380
|
-
for (const
|
|
381
|
-
if (passes(
|
|
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 =
|
|
393
|
+
const fallback = kindOf(name) === "text" ? "replace" : "push";
|
|
402
394
|
writeUrl(setOptions?.history ?? fallback);
|
|
403
395
|
announce();
|
|
404
396
|
},
|
package/src/astro/ref.ts
ADDED
|
@@ -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
|
+
}
|