@escape-game-over/atlas 0.1.15 → 0.1.16
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 +59 -13
- package/package.json +6 -4
- package/src/astro/background-video.ts +290 -0
- package/src/astro/filters.ts +108 -47
- package/src/astro/youtube.ts +160 -0
package/docs/client-scripts.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Client scripts: what Atlas owns in the browser, and what it refuses to
|
|
2
2
|
|
|
3
3
|
Everything else in this package runs at build time and emits text. This is the
|
|
4
|
-
other half:
|
|
4
|
+
other half: the modules under `src/astro/` that run in a reader's browser, and
|
|
5
5
|
one rule they all keep.
|
|
6
6
|
|
|
7
7
|
**They draw nothing.**
|
|
@@ -25,15 +25,24 @@ It sets no class, no style and no `aria`, which is where the choices live. It
|
|
|
25
25
|
also never *finds* an element: it is handed the ones a project looked up, so no
|
|
26
26
|
attribute name in this package has to be matched by any consumer.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
|
36
|
-
|
|
|
28
|
+
`youtube` is the other exception, and the one that cannot be avoided: the player
|
|
29
|
+
*is* the behaviour, so something has to put an iframe in the page. What it sets
|
|
30
|
+
on it has no taste in it — the address, the title, the permissions, full screen,
|
|
31
|
+
the referrer policy — and everything that does, classes above all, comes from the
|
|
32
|
+
caller through a callback. It too is handed the button and the box rather than
|
|
33
|
+
finding them.
|
|
34
|
+
|
|
35
|
+
| Module | Owns | Leaves to the project |
|
|
36
|
+
| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------- |
|
|
37
|
+
| `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
|
|
38
|
+
| `./astro/filters` | which items match, and what the address bar says | every DOM read and write, and the markup contract |
|
|
39
|
+
| `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
|
|
40
|
+
| `./astro/youtube` | the swap from poster to player, on the click and not before | the poster, the button, the iframe's classes |
|
|
41
|
+
| `./astro/background-video` | playing or paused, playable or not, and which cut is loaded | the play/pause control, its icons and its label |
|
|
42
|
+
| `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
|
|
43
|
+
| `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
|
|
44
|
+
| `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
|
|
45
|
+
| `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
|
|
37
46
|
|
|
38
47
|
`./astro/consent` is the only one of these with a build-time half. Its
|
|
39
48
|
`consentApplies()` reports whether there is a Google tag to consent to, which a
|
|
@@ -53,8 +62,10 @@ the options, and when one drifts nothing reports it — a listener added without
|
|
|
53
62
|
its twin simply survives teardown.
|
|
54
63
|
|
|
55
64
|
```ts
|
|
56
|
-
const detach = slider.attach(viewport);
|
|
57
|
-
const detach = list.attach();
|
|
65
|
+
const detach = slider.attach(viewport); // carousel: one element
|
|
66
|
+
const detach = list.attach(); // filters: no element, only popstate
|
|
67
|
+
const detach = loop.attach(video); // background-video: the <video>
|
|
68
|
+
const detach = trailer.attach(button, box); // youtube: what is pressed, what it replaces
|
|
58
69
|
```
|
|
59
70
|
|
|
60
71
|
`AtlasElement` says the same thing in the shape a custom element needs, because
|
|
@@ -111,7 +122,7 @@ than an item that silently never matches.
|
|
|
111
122
|
|
|
112
123
|
| Kind | State | Item value | Matches when | In the URL | History |
|
|
113
124
|
| -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
|
|
114
|
-
| `text` | the query | searchable text | folded query
|
|
125
|
+
| `text` | the query | searchable text | every folded query word appears; empty keeps all | `?q=…`, dropped if empty | replace |
|
|
115
126
|
| `choice` | one value, `""` | one, or several | the item holds the chosen one; `""` keeps all | `?category=…`, dropped if empty | push |
|
|
116
127
|
| `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
|
|
117
128
|
|
|
@@ -159,6 +170,41 @@ parameters or comma-joined) that nothing has needed yet.
|
|
|
159
170
|
|
|
160
171
|
`param` is optional. A field without one is state-only and never reaches the URL.
|
|
161
172
|
|
|
173
|
+
### Facet counts: `matchedWithout`
|
|
174
|
+
|
|
175
|
+
A dropdown that says how many results each option would give needs a different
|
|
176
|
+
set from `matched`: every field *except its own*. Counted against `matched`, a
|
|
177
|
+
facet whose value is already picked can only ever offer that value — every other
|
|
178
|
+
option reads zero, because the items holding them were just filtered out by the
|
|
179
|
+
very field being asked about.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
onChange({ matched }) {
|
|
183
|
+
const rest = list.matchedWithout("country");
|
|
184
|
+
for (const option of countryOptions) {
|
|
185
|
+
const value = option.dataset.choice;
|
|
186
|
+
const count = rows.filter(
|
|
187
|
+
(row) => rest.has(row.key) && row.country === value
|
|
188
|
+
).length;
|
|
189
|
+
// …the project writes `count` wherever its design puts it
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
It runs the list's own matcher — folding, tokens, several-valued choices, flags —
|
|
195
|
+
so a count cannot drift from the list beneath it. That drift is not
|
|
196
|
+
hypothetical: a consumer wrote its own predicate for these counts, folding
|
|
197
|
+
without trimming and matching the query as one run, and it agreed with the list
|
|
198
|
+
only until the list learned to match word by word. Nothing reported it; the
|
|
199
|
+
counts were simply wrong.
|
|
200
|
+
|
|
201
|
+
Computed on first ask and kept until the state next moves, so a render that asks
|
|
202
|
+
once per option pays one pass per field. A field that is not narrowing anything —
|
|
203
|
+
an empty query, no choice, a flag that is off — answers with `matched` itself.
|
|
204
|
+
Grouping the survivors by option stays with the caller, which already holds each
|
|
205
|
+
item's values; it is safe to call from `onChange`, which never runs during
|
|
206
|
+
construction.
|
|
207
|
+
|
|
162
208
|
### The four URL decisions
|
|
163
209
|
|
|
164
210
|
1. **Replace while typing, push on a choice.** An input emits an event per
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
|
|
6
6
|
"private": false,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
".": "./src/index.ts",
|
|
16
16
|
"./astro": "./src/astro/index.ts",
|
|
17
17
|
"./astro/images": "./src/astro/images.ts",
|
|
18
|
+
"./astro/background-video": "./src/astro/background-video.ts",
|
|
18
19
|
"./astro/carousel": "./src/astro/carousel.ts",
|
|
19
20
|
"./astro/consent": "./src/astro/consent.ts",
|
|
20
21
|
"./astro/dev-log": "./src/astro/dev-log.ts",
|
|
@@ -22,7 +23,8 @@
|
|
|
22
23
|
"./astro/element": "./src/astro/element.ts",
|
|
23
24
|
"./astro/filters": "./src/astro/filters.ts",
|
|
24
25
|
"./astro/filters-view": "./src/astro/filters-view.ts",
|
|
25
|
-
"./astro/meta-tags": "./src/astro/MetaTags.astro"
|
|
26
|
+
"./astro/meta-tags": "./src/astro/MetaTags.astro",
|
|
27
|
+
"./astro/youtube": "./src/astro/youtube.ts"
|
|
26
28
|
},
|
|
27
29
|
"bin": {
|
|
28
30
|
"atlas": "bin/use-project.mjs"
|
|
@@ -53,8 +55,8 @@
|
|
|
53
55
|
"typescript": ">=5"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
|
-
"@biomejs/biome": "2.5.
|
|
57
|
-
"@types/node": "26.5.
|
|
58
|
+
"@biomejs/biome": "2.5.13",
|
|
59
|
+
"@types/node": "26.5.1",
|
|
58
60
|
"@vitest/coverage-istanbul": "5.0.0",
|
|
59
61
|
"astro": "7.3.2",
|
|
60
62
|
"typescript": "6.0.3",
|
|
@@ -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.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A YouTube video that loads nothing from YouTube until a reader asks for it.
|
|
3
|
+
*
|
|
4
|
+
* In `astro/` because it listens for a click and makes an element, which the
|
|
5
|
+
* core is type-checked without.
|
|
6
|
+
*
|
|
7
|
+
* An embedded player is the heaviest thing most pages carry that nobody asked
|
|
8
|
+
* for: the iframe fetches the player, its scripts and its tracking on load, once
|
|
9
|
+
* per video, whether or not anyone presses play. So the page renders a poster
|
|
10
|
+
* and a button — the project's own markup, drawn however it likes — and this
|
|
11
|
+
* swaps the player in on the click, with `autoplay=1` so that the one press the
|
|
12
|
+
* reader already made is the one that starts it.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const trailer = youtubeEmbed({
|
|
16
|
+
* id: "dQw4w9WgXcQ",
|
|
17
|
+
* title: "Trailer: The Blue Room",
|
|
18
|
+
* frame: (iframe) => {
|
|
19
|
+
* iframe.className = "absolute inset-0 h-full w-full";
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* const detach = trailer.attach(button, poster);
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* **It draws one element, and decides nothing about how it looks.** Unlike
|
|
27
|
+
* `carousel` this has to put something into the page — the player is the
|
|
28
|
+
* behaviour — so it is the second exception to the rule in
|
|
29
|
+
* `docs/client-scripts.md`, after `filters-view`, and it is drawn as narrowly.
|
|
30
|
+
* What it sets on the iframe is what every embed needs and has no taste in it:
|
|
31
|
+
* the address, the title, the permissions, full screen, the referrer policy.
|
|
32
|
+
* Classes, size and position are the caller's, through `frame`.
|
|
33
|
+
*
|
|
34
|
+
* Kept apart from `videoObject` in `jsonld/video.ts` although both are about a
|
|
35
|
+
* YouTube video: that one describes it to a crawler at build time, this plays
|
|
36
|
+
* it in a browser, and the only thing they share is an id the caller already
|
|
37
|
+
* holds. They do not even want the same host — Google reads the `youtube.com`
|
|
38
|
+
* player as `embedUrl`, and a reader is better served by `youtube-nocookie.com`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export interface YouTubeEmbedOptions {
|
|
42
|
+
/**
|
|
43
|
+
* The video's id: what follows `v=` in a watch link, or `youtu.be/`.
|
|
44
|
+
*
|
|
45
|
+
* Checked at construction, because the tempting mistake is pasting the
|
|
46
|
+
* whole link, and that fails in the one place nobody looks — inside the
|
|
47
|
+
* player, after the click, as YouTube's own "video unavailable".
|
|
48
|
+
*/
|
|
49
|
+
readonly id: string;
|
|
50
|
+
/**
|
|
51
|
+
* The iframe's title, which is its accessible name.
|
|
52
|
+
*
|
|
53
|
+
* Required and refused when blank: a frame without one is announced as
|
|
54
|
+
* "frame" and nothing else, and a screen-reader user tabbing into the
|
|
55
|
+
* player has no way to tell which video it is.
|
|
56
|
+
*/
|
|
57
|
+
readonly title: string;
|
|
58
|
+
/**
|
|
59
|
+
* Anything the project wants on the iframe — classes, above all.
|
|
60
|
+
*
|
|
61
|
+
* Called once, after everything this module sets and before the iframe is
|
|
62
|
+
* in the page, so what it writes is the last word and nothing is drawn
|
|
63
|
+
* unstyled first.
|
|
64
|
+
*/
|
|
65
|
+
readonly frame?: (iframe: HTMLIFrameElement) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface YouTubeEmbed {
|
|
69
|
+
/**
|
|
70
|
+
* Swaps `container`'s contents for the player when `trigger` is clicked,
|
|
71
|
+
* and returns the undo.
|
|
72
|
+
*
|
|
73
|
+
* Two elements rather than one, because they are different things: the
|
|
74
|
+
* trigger is the button a reader presses, and the container is what the
|
|
75
|
+
* player replaces — usually the poster and that same button together. A
|
|
76
|
+
* click anywhere in the container would be simpler and wrong, since a
|
|
77
|
+
* caption link or a credit inside it would then start the video.
|
|
78
|
+
*
|
|
79
|
+
* Once only. The button is gone with the poster, and a second player in
|
|
80
|
+
* the same box would be two videos playing over each other.
|
|
81
|
+
*
|
|
82
|
+
* The undo removes the listener and nothing else. A player already loaded
|
|
83
|
+
* stays where it is — taking it out would stop a video someone is
|
|
84
|
+
* watching, and a page swap removes it anyway.
|
|
85
|
+
*/
|
|
86
|
+
attach(trigger: HTMLElement, container: Element): () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The shape of a YouTube video id.
|
|
91
|
+
*
|
|
92
|
+
* Eleven characters from the URL-safe base64 alphabet, which is what every id
|
|
93
|
+
* has been. A shape check rather than a lookup: it cannot say the video exists,
|
|
94
|
+
* only that what was handed over is an id and not a link.
|
|
95
|
+
*/
|
|
96
|
+
const VIDEO_ID = /^[\w-]{11}$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The permissions YouTube's own embed code asks for.
|
|
100
|
+
*
|
|
101
|
+
* `autoplay` is the one that matters here: without it the `autoplay=1` in the
|
|
102
|
+
* address is refused, and the reader who pressed play gets a second play
|
|
103
|
+
* button to press.
|
|
104
|
+
*/
|
|
105
|
+
const ALLOW =
|
|
106
|
+
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
|
|
107
|
+
|
|
108
|
+
export function youtubeEmbed(options: YouTubeEmbedOptions): YouTubeEmbed {
|
|
109
|
+
const { id, title, frame } = options;
|
|
110
|
+
|
|
111
|
+
if (!VIDEO_ID.test(id)) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`"${id}" is not a YouTube video id — expected the id alone, as it appears after "v=" or "youtu.be/", not a link.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (title.trim() === "") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`The YouTube embed for "${id}" has no title. It is the frame's accessible name — without one a screen reader announces "frame" and nothing else.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `youtube-nocookie.com` rather than `youtube.com`: the same player, which
|
|
123
|
+
// sets no cookie until the video is played — and here it is only ever
|
|
124
|
+
// loaded to be played, so nothing is lost by it.
|
|
125
|
+
const src = `https://www.youtube-nocookie.com/embed/${id}?autoplay=1`;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
attach(trigger, container) {
|
|
129
|
+
const listeners = new AbortController();
|
|
130
|
+
|
|
131
|
+
trigger.addEventListener(
|
|
132
|
+
"click",
|
|
133
|
+
() => {
|
|
134
|
+
const iframe =
|
|
135
|
+
container.ownerDocument.createElement("iframe");
|
|
136
|
+
iframe.src = src;
|
|
137
|
+
iframe.title = title;
|
|
138
|
+
iframe.allow = ALLOW;
|
|
139
|
+
iframe.allowFullscreen = true;
|
|
140
|
+
// Stated rather than inherited. YouTube refuses to play an
|
|
141
|
+
// embed that arrives with no referrer at all, and a site
|
|
142
|
+
// sending `Referrer-Policy: no-referrer` would otherwise get
|
|
143
|
+
// a player that loads and shows an error in place of the
|
|
144
|
+
// video. This is the policy YouTube's own embed code sets.
|
|
145
|
+
iframe.referrerPolicy = "strict-origin-when-cross-origin";
|
|
146
|
+
frame?.(iframe);
|
|
147
|
+
|
|
148
|
+
container.replaceChildren(iframe);
|
|
149
|
+
// The button that had focus has just left the page, which
|
|
150
|
+
// drops a keyboard reader back at the top of the document.
|
|
151
|
+
// The player is where they were, and what they asked for.
|
|
152
|
+
iframe.focus();
|
|
153
|
+
},
|
|
154
|
+
{ once: true, signal: listeners.signal }
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return () => listeners.abort();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|