@escape-game-over/atlas 0.1.15 → 0.1.17
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 +14 -11
- package/docs/client-scripts.md +260 -22
- package/package.json +7 -4
- package/src/astro/background-video.ts +290 -0
- package/src/astro/filters.ts +108 -47
- package/src/astro/markup.ts +432 -0
- package/src/astro/youtube.ts +160 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A decorative background video's playback — not its markup, and not its button.
|
|
3
|
+
*
|
|
4
|
+
* In `astro/` because it reaches for `matchMedia` and a media element, which the
|
|
5
|
+
* core is type-checked without.
|
|
6
|
+
*
|
|
7
|
+
* **It draws nothing.** No icon, no label, no `hidden`, no `aria`. It owns
|
|
8
|
+
* whether the video is playing, whether there is anything to play, and which of
|
|
9
|
+
* its cuts is loaded, and calls `onChange` when the first two move; the project
|
|
10
|
+
* draws its own play/pause control from that and points the control at
|
|
11
|
+
* `toggle`.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const loop = backgroundVideo({
|
|
15
|
+
* onChange: ({ playing, playable }) => {
|
|
16
|
+
* button.hidden = !playable;
|
|
17
|
+
* button.setAttribute("aria-label", playing ? pauseLabel : playLabel);
|
|
18
|
+
* },
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* button.addEventListener("click", () => loop.toggle(), { signal });
|
|
22
|
+
* const detach = loop.attach(video);
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* What it owns is the handful of things every looping hero needs and most get
|
|
26
|
+
* quietly wrong:
|
|
27
|
+
*
|
|
28
|
+
* - **Reduced motion starts it paused.** A reader who asked their system for
|
|
29
|
+
* less motion should not get a full-width loop behind the headline.
|
|
30
|
+
* - **A way to stop it.** WCAG 2.2.2 asks for one on anything that moves for
|
|
31
|
+
* more than five seconds, and a loop moves forever. The control is the
|
|
32
|
+
* project's; the state it shows is this.
|
|
33
|
+
* - **No control over nothing.** A `<source media>` that matches no viewport
|
|
34
|
+
* leaves the element empty, and a pause button beside an empty element pauses
|
|
35
|
+
* nothing. `playable` says so, and the caller hides it.
|
|
36
|
+
* - **The right cut after a rotation.** A browser chooses among `<source
|
|
37
|
+
* media>` once, when the element loads, and never again — so a phone turned
|
|
38
|
+
* sideways keeps the portrait cut, cropped across a landscape screen, until
|
|
39
|
+
* something reloads it.
|
|
40
|
+
* - **The reader's pause surviving that reload.** A reload resets the element
|
|
41
|
+
* to its markup, and markup that says `autoplay` starts again the video they
|
|
42
|
+
* had just stopped.
|
|
43
|
+
*
|
|
44
|
+
* The markup wants `muted`, `loop` and `playsinline`, and — being decoration —
|
|
45
|
+
* `aria-hidden="true"` and `tabindex="-1"`. `autoplay` is better left out: this
|
|
46
|
+
* starts the video itself, and without this script there is no control to stop
|
|
47
|
+
* it with, while with the attribute a reduced-motion reader sees the first
|
|
48
|
+
* frames move before this gets to pause them. Left in, everything still works.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export interface BackgroundVideoState {
|
|
52
|
+
/** Whether it is playing now: the element's own `paused`, inverted. */
|
|
53
|
+
readonly playing: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Whether a source was chosen for this viewport.
|
|
56
|
+
*
|
|
57
|
+
* False when no `<source media>` matched, or every one that did failed —
|
|
58
|
+
* there is nothing on screen to pause, so the control has nothing to do
|
|
59
|
+
* and should not be offered.
|
|
60
|
+
*/
|
|
61
|
+
readonly playable: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface BackgroundVideoOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Called once on `attach`, and then whenever `playing` or `playable` moves.
|
|
67
|
+
*
|
|
68
|
+
* Once on `attach` although nothing moved, because the control's first
|
|
69
|
+
* state is only known then: whether the viewport matched a source and
|
|
70
|
+
* whether reduced motion held it still are both read from the page, and the
|
|
71
|
+
* markup cannot say either in advance. Never called for an event that
|
|
72
|
+
* changed neither, so a caller can redraw unconditionally.
|
|
73
|
+
*/
|
|
74
|
+
readonly onChange: (state: BackgroundVideoState) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BackgroundVideo {
|
|
78
|
+
readonly state: BackgroundVideoState;
|
|
79
|
+
/** Plays, as the reader's choice: it outlives a reload and a re-attach. */
|
|
80
|
+
play(): void;
|
|
81
|
+
/** Pauses, as the reader's choice: it outlives a reload and a re-attach. */
|
|
82
|
+
pause(): void;
|
|
83
|
+
/** Whichever of the two the video is not doing now. What a button wants. */
|
|
84
|
+
toggle(): void;
|
|
85
|
+
/**
|
|
86
|
+
* Takes over one `<video>`, and returns the undo.
|
|
87
|
+
*
|
|
88
|
+
* Applies the starting state — the reader's earlier choice if they made
|
|
89
|
+
* one, otherwise playing unless reduced motion is set — and starts
|
|
90
|
+
* listening to the element and to each of its sources' media queries. The
|
|
91
|
+
* undo stops listening and leaves the video as it is.
|
|
92
|
+
*
|
|
93
|
+
* Before `attach` and after the undo, `play`, `pause` and `toggle` do
|
|
94
|
+
* nothing: there is no element to act on. The reader's choice is kept
|
|
95
|
+
* across the gap, as `carousel` keeps its index.
|
|
96
|
+
*/
|
|
97
|
+
attach(video: HTMLVideoElement): () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `HTMLMediaElement.NETWORK_NO_SOURCE`, as a literal.
|
|
104
|
+
*
|
|
105
|
+
* The state an element settles in when it has run out of sources to try —
|
|
106
|
+
* none matched, or each that did failed. Written out so nothing global is read
|
|
107
|
+
* at import, which is when a module like this must do nothing at all.
|
|
108
|
+
*/
|
|
109
|
+
const NETWORK_NO_SOURCE = 3;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The media element events after which either half of the state may differ.
|
|
113
|
+
*
|
|
114
|
+
* `emptied` is a reload beginning, `loadstart` a source having been chosen.
|
|
115
|
+
* `error` is listened to separately, in the capture phase — see `attach`.
|
|
116
|
+
*/
|
|
117
|
+
const EVENTS = ["play", "pause", "emptied", "loadstart"] as const;
|
|
118
|
+
|
|
119
|
+
export function backgroundVideo(
|
|
120
|
+
options: BackgroundVideoOptions
|
|
121
|
+
): BackgroundVideo {
|
|
122
|
+
const { onChange } = options;
|
|
123
|
+
|
|
124
|
+
/** The element while attached, and nothing otherwise. */
|
|
125
|
+
let video: HTMLVideoElement | undefined;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the reader chose, once they have chosen.
|
|
129
|
+
*
|
|
130
|
+
* Undefined until they press something, and then it outranks reduced
|
|
131
|
+
* motion: a reader who asked for less motion and then pressed play has
|
|
132
|
+
* answered the question for this video. Kept across a detach, so an element
|
|
133
|
+
* moved in the DOM comes back as the reader left it.
|
|
134
|
+
*/
|
|
135
|
+
let chosen: boolean | undefined;
|
|
136
|
+
|
|
137
|
+
let state: BackgroundVideoState = { playing: false, playable: false };
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether this attachment has announced yet.
|
|
141
|
+
*
|
|
142
|
+
* The first read of an attachment is announced even if it matches the last
|
|
143
|
+
* state, since the control has never been drawn from it. A flag rather
|
|
144
|
+
* than a forced call at the end of `attach`, because applying the starting
|
|
145
|
+
* state can fire `play` on its own — and then the control was told twice.
|
|
146
|
+
*/
|
|
147
|
+
let fresh = false;
|
|
148
|
+
|
|
149
|
+
/** Reads the element, and announces if anything moved. */
|
|
150
|
+
const sync = (): void => {
|
|
151
|
+
if (video === undefined) return;
|
|
152
|
+
const next: BackgroundVideoState = {
|
|
153
|
+
playing: !video.paused,
|
|
154
|
+
// Both halves, because neither alone is true. `currentSrc` is empty
|
|
155
|
+
// until a source is chosen, but is not reliably cleared when a
|
|
156
|
+
// reload then finds none; `networkState` says an element has run
|
|
157
|
+
// out of sources, but passes through that state for a moment at
|
|
158
|
+
// the start of every reload too.
|
|
159
|
+
playable:
|
|
160
|
+
video.currentSrc !== "" &&
|
|
161
|
+
video.networkState !== NETWORK_NO_SOURCE,
|
|
162
|
+
};
|
|
163
|
+
if (
|
|
164
|
+
!fresh &&
|
|
165
|
+
next.playing === state.playing &&
|
|
166
|
+
next.playable === state.playable
|
|
167
|
+
) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
fresh = false;
|
|
171
|
+
state = next;
|
|
172
|
+
onChange(state);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Puts the element in the wanted state, and makes it stay there.
|
|
177
|
+
*
|
|
178
|
+
* `autoplay` is the half that stays. A reload resets the element to paused
|
|
179
|
+
* and then consults the attribute, so keeping it equal to what is wanted is
|
|
180
|
+
* what carries a reader's pause — or their play — across one.
|
|
181
|
+
*/
|
|
182
|
+
const apply = (playing: boolean): void => {
|
|
183
|
+
if (video === undefined) return;
|
|
184
|
+
video.autoplay = playing;
|
|
185
|
+
if (playing) {
|
|
186
|
+
// Refused when a browser will not autoplay even a muted video —
|
|
187
|
+
// iOS in low-power mode is the one people meet. The element stays
|
|
188
|
+
// paused, and the control should say "play".
|
|
189
|
+
video.play().catch(() => sync());
|
|
190
|
+
} else {
|
|
191
|
+
video.pause();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const choose = (playing: boolean): void => {
|
|
196
|
+
if (video === undefined) return;
|
|
197
|
+
chosen = playing;
|
|
198
|
+
apply(playing);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
get state() {
|
|
203
|
+
return state;
|
|
204
|
+
},
|
|
205
|
+
play: () => choose(true),
|
|
206
|
+
pause: () => choose(false),
|
|
207
|
+
// From the element rather than from `chosen`. The control shows what
|
|
208
|
+
// the video is doing, so pressing it must do the opposite of that —
|
|
209
|
+
// including when the browser paused it rather than the reader.
|
|
210
|
+
toggle: () => {
|
|
211
|
+
if (video !== undefined) choose(video.paused);
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
attach(element) {
|
|
215
|
+
video = element;
|
|
216
|
+
fresh = true;
|
|
217
|
+
const listeners = new AbortController();
|
|
218
|
+
const { signal } = listeners;
|
|
219
|
+
|
|
220
|
+
// What decorative means, and what every browser requires before it
|
|
221
|
+
// will start a video nobody pressed play on.
|
|
222
|
+
element.muted = true;
|
|
223
|
+
|
|
224
|
+
for (const type of EVENTS) {
|
|
225
|
+
element.addEventListener(type, () => sync(), { signal });
|
|
226
|
+
}
|
|
227
|
+
// A `<source>` that is skipped or fails fires `error` at itself, and
|
|
228
|
+
// `error` does not bubble. The capture phase passes through the
|
|
229
|
+
// video on the way down, so this hears every source's failure —
|
|
230
|
+
// the last of which is how an element says it found nothing.
|
|
231
|
+
element.addEventListener("error", () => sync(), {
|
|
232
|
+
capture: true,
|
|
233
|
+
signal,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const sources = [...element.querySelectorAll("source")];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Which source a browser would choose now: the first whose media
|
|
240
|
+
* matches and whose type it can play.
|
|
241
|
+
*
|
|
242
|
+
* Compared before reloading rather than reloading on every change,
|
|
243
|
+
* because not every crossing changes the answer. A later source's
|
|
244
|
+
* query flipping while an earlier one still matches picks the same
|
|
245
|
+
* file, and a reload would only restart the loop from its first
|
|
246
|
+
* frame for nothing.
|
|
247
|
+
*/
|
|
248
|
+
const choice = (): number =>
|
|
249
|
+
sources.findIndex(
|
|
250
|
+
(source) =>
|
|
251
|
+
(source.media === "" ||
|
|
252
|
+
matchMedia(source.media).matches) &&
|
|
253
|
+
(source.type === "" ||
|
|
254
|
+
element.canPlayType(source.type) !== "")
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
let chosenSource = choice();
|
|
258
|
+
// One listener per distinct query: two sources sharing one would
|
|
259
|
+
// otherwise reload twice for a single rotation.
|
|
260
|
+
const queries = new Set(
|
|
261
|
+
sources
|
|
262
|
+
.map((source) => source.media)
|
|
263
|
+
.filter((media) => media !== "")
|
|
264
|
+
);
|
|
265
|
+
for (const media of queries) {
|
|
266
|
+
matchMedia(media).addEventListener(
|
|
267
|
+
"change",
|
|
268
|
+
() => {
|
|
269
|
+
const next = choice();
|
|
270
|
+
if (next === chosenSource) return;
|
|
271
|
+
chosenSource = next;
|
|
272
|
+
// `emptied` and then `loadstart` follow, and `sync`
|
|
273
|
+
// hears both — so the control follows without a call
|
|
274
|
+
// from here.
|
|
275
|
+
element.load();
|
|
276
|
+
},
|
|
277
|
+
{ signal }
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
apply(chosen ?? !matchMedia(REDUCED_MOTION).matches);
|
|
282
|
+
sync();
|
|
283
|
+
|
|
284
|
+
return () => {
|
|
285
|
+
listeners.abort();
|
|
286
|
+
video = undefined;
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
package/src/astro/filters.ts
CHANGED
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
*
|
|
61
61
|
* The kinds are closed, and deliberately few:
|
|
62
62
|
*
|
|
63
|
-
* - `text` — free entry, folded and
|
|
63
|
+
* - `text` — free entry, folded and matched word by word. The search box.
|
|
64
64
|
* - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group. The
|
|
65
65
|
* control picks one; an item may hold several and answer to any — see
|
|
66
66
|
* `ItemValue`.
|
|
@@ -200,6 +200,29 @@ export interface SetOptions {
|
|
|
200
200
|
export interface Filters<F extends FieldMap> {
|
|
201
201
|
readonly state: FilterState<F>;
|
|
202
202
|
readonly matched: ReadonlySet<string>;
|
|
203
|
+
/**
|
|
204
|
+
* The keys that survive every field *except* this one.
|
|
205
|
+
*
|
|
206
|
+
* The question a facet asks of its own options: how many results would
|
|
207
|
+
* picking each of them give, with everything else as it stands. Counted
|
|
208
|
+
* against `matched` instead, a dropdown whose value is already set could
|
|
209
|
+
* only ever offer that value — every other option would read zero, since
|
|
210
|
+
* the items holding it have just been filtered out by the very field being
|
|
211
|
+
* asked about.
|
|
212
|
+
*
|
|
213
|
+
* The same matcher as `matched`, folding and tokens included, rather than a
|
|
214
|
+
* second one in the caller. A facet count computed by a hand-written
|
|
215
|
+
* predicate drifts from the list it is counting the moment either changes —
|
|
216
|
+
* a count of four above a list of three — and nothing reports it.
|
|
217
|
+
*
|
|
218
|
+
* Computed on first ask and kept until the state next moves, so a render
|
|
219
|
+
* that asks once per option pays one pass per field rather than one per
|
|
220
|
+
* option. A field that is not narrowing anything answers with `matched`
|
|
221
|
+
* itself: without it, the rest is exactly what already matched.
|
|
222
|
+
*
|
|
223
|
+
* Safe to call from `onChange`, which never runs during construction.
|
|
224
|
+
*/
|
|
225
|
+
matchedWithout(field: keyof F): ReadonlySet<string>;
|
|
203
226
|
set<K extends keyof F>(
|
|
204
227
|
field: K,
|
|
205
228
|
value: StateValue<F[K]>,
|
|
@@ -315,6 +338,17 @@ export function filters<const F extends FieldMap>(
|
|
|
315
338
|
let state = blank();
|
|
316
339
|
let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
|
|
317
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Each `text` field's query, folded and split, for the current state.
|
|
343
|
+
*
|
|
344
|
+
* Folded once per state rather than once per item. The other kinds compare
|
|
345
|
+
* values as they are, so there is nothing to prepare for them.
|
|
346
|
+
*/
|
|
347
|
+
let queries = new Map<string, readonly string[]>();
|
|
348
|
+
|
|
349
|
+
/** `matchedWithout` answers for the current state. Emptied when it moves. */
|
|
350
|
+
const without = new Map<string, ReadonlySet<string>>();
|
|
351
|
+
|
|
318
352
|
/**
|
|
319
353
|
* Whether the URL is this instance's to write.
|
|
320
354
|
*
|
|
@@ -324,61 +358,74 @@ export function filters<const F extends FieldMap>(
|
|
|
324
358
|
*/
|
|
325
359
|
let attached = false;
|
|
326
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Whether a field is narrowing anything in the current state.
|
|
363
|
+
*
|
|
364
|
+
* An empty query, no choice and a flag that is off all keep every item —
|
|
365
|
+
* the flag above all, which keeps everything rather than keeping the items
|
|
366
|
+
* that are *not* flagged. See `Field`.
|
|
367
|
+
*/
|
|
368
|
+
const narrows = (name: keyof F & string): boolean => {
|
|
369
|
+
const kind = fields[name]?.kind;
|
|
370
|
+
if (kind === "text") return (queries.get(name) ?? []).length > 0;
|
|
371
|
+
if (kind === "choice") return state[name] !== "";
|
|
372
|
+
return state[name] === true;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Whether one item survives every field but `except`, in the current state.
|
|
377
|
+
*
|
|
378
|
+
* The one matcher. `matched` is this with nothing excepted and
|
|
379
|
+
* `matchedWithout` is this with one field excepted, so a facet count and
|
|
380
|
+
* the list it counts cannot disagree about what a match is.
|
|
381
|
+
*/
|
|
382
|
+
function passes(item: FilterItem<F>, except?: keyof F & string): boolean {
|
|
383
|
+
for (const name of names) {
|
|
384
|
+
if (name === except || !narrows(name)) continue;
|
|
385
|
+
const kind = fields[name]?.kind;
|
|
386
|
+
if (kind === "text") {
|
|
387
|
+
// Every token must appear, not one run that must appear whole.
|
|
388
|
+
// A haystack is several things joined (a title, plus its
|
|
389
|
+
// category, plus its summary), and the order they were joined
|
|
390
|
+
// in is an accident of whoever wrote the template. Matching
|
|
391
|
+
// the query as one run made that accident load-bearing:
|
|
392
|
+
// against "Copper Kettle" in the Kitchen category, "kettle
|
|
393
|
+
// kitchen" found nothing while "kettle" alone worked, and the
|
|
394
|
+
// first is what someone narrowing a list types.
|
|
395
|
+
const tokens = queries.get(name) ?? [];
|
|
396
|
+
const haystack = haystacks.get(item.key)?.get(name) ?? "";
|
|
397
|
+
if (!tokens.every((token) => haystack.includes(token))) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
} else if (kind === "choice") {
|
|
401
|
+
// An item may sit in several categories while the control
|
|
402
|
+
// still picks one — see `ItemValue`. One value is the same
|
|
403
|
+
// question asked of a set of one.
|
|
404
|
+
const wanted = state[name] as string;
|
|
405
|
+
const held = item.values[name];
|
|
406
|
+
const holds = Array.isArray(held)
|
|
407
|
+
? held.includes(wanted)
|
|
408
|
+
: held === wanted;
|
|
409
|
+
if (!holds) return false;
|
|
410
|
+
} else if (item.values[name] !== true) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
327
417
|
function recompute(): void {
|
|
328
|
-
|
|
329
|
-
// kinds compare values as they are, so there is nothing to prepare.
|
|
330
|
-
// Split into tokens, all of which must appear — not one substring that
|
|
331
|
-
// must appear whole. A haystack is several things joined (a title, plus
|
|
332
|
-
// its category, plus its summary), and the order they were joined in is
|
|
333
|
-
// an accident of whoever wrote the template. Matching the query as one
|
|
334
|
-
// run made that accident load-bearing: against "Copper Kettle" in the
|
|
335
|
-
// Kitchen category, "kettle kitchen" found nothing while "kettle" alone
|
|
336
|
-
// worked, and the first is what someone narrowing a list types.
|
|
337
|
-
const queries = new Map<string, string[]>();
|
|
418
|
+
queries = new Map();
|
|
338
419
|
for (const name of names) {
|
|
339
420
|
if (fields[name]?.kind !== "text") continue;
|
|
340
421
|
const folded = fold(state[name] as string);
|
|
341
422
|
queries.set(name, folded === "" ? [] : folded.split(/\s+/));
|
|
342
423
|
}
|
|
424
|
+
without.clear();
|
|
343
425
|
|
|
344
426
|
const next = new Set<string>();
|
|
345
427
|
for (const item of items) {
|
|
346
|
-
|
|
347
|
-
for (const name of names) {
|
|
348
|
-
const kind = fields[name]?.kind;
|
|
349
|
-
if (kind === "text") {
|
|
350
|
-
const tokens = queries.get(name) ?? [];
|
|
351
|
-
if (tokens.length === 0) continue;
|
|
352
|
-
const haystack = haystacks.get(item.key)?.get(name) ?? "";
|
|
353
|
-
if (!tokens.every((token) => haystack.includes(token))) {
|
|
354
|
-
hit = false;
|
|
355
|
-
break;
|
|
356
|
-
}
|
|
357
|
-
} else if (kind === "choice") {
|
|
358
|
-
const wanted = state[name] as string;
|
|
359
|
-
if (wanted === "") continue;
|
|
360
|
-
// An item may sit in several categories while the control
|
|
361
|
-
// still picks one — see `ItemValue`. One value is the same
|
|
362
|
-
// question asked of a set of one.
|
|
363
|
-
const held = item.values[name];
|
|
364
|
-
const holds = Array.isArray(held)
|
|
365
|
-
? held.includes(wanted)
|
|
366
|
-
: held === wanted;
|
|
367
|
-
if (!holds) {
|
|
368
|
-
hit = false;
|
|
369
|
-
break;
|
|
370
|
-
}
|
|
371
|
-
} else {
|
|
372
|
-
// A flag off is not a filter: it keeps everything, rather
|
|
373
|
-
// than keeping the items that are *not* flagged. See `Field`.
|
|
374
|
-
if (state[name] !== true) continue;
|
|
375
|
-
if (item.values[name] !== true) {
|
|
376
|
-
hit = false;
|
|
377
|
-
break;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (hit) next.add(item.key);
|
|
428
|
+
if (passes(item)) next.add(item.key);
|
|
382
429
|
}
|
|
383
430
|
matched = next;
|
|
384
431
|
}
|
|
@@ -467,6 +514,20 @@ export function filters<const F extends FieldMap>(
|
|
|
467
514
|
return matched;
|
|
468
515
|
},
|
|
469
516
|
|
|
517
|
+
matchedWithout(field) {
|
|
518
|
+
const name = field as keyof F & string;
|
|
519
|
+
if (!narrows(name)) return matched;
|
|
520
|
+
const cached = without.get(name);
|
|
521
|
+
if (cached !== undefined) return cached;
|
|
522
|
+
|
|
523
|
+
const rest = new Set<string>();
|
|
524
|
+
for (const item of items) {
|
|
525
|
+
if (passes(item, name)) rest.add(item.key);
|
|
526
|
+
}
|
|
527
|
+
without.set(name, rest);
|
|
528
|
+
return rest;
|
|
529
|
+
},
|
|
530
|
+
|
|
470
531
|
set(field, value, setOptions) {
|
|
471
532
|
const name = field as keyof F & string;
|
|
472
533
|
// Compared as everything downstream will read it, not as it arrived.
|