@escape-game-over/atlas 0.1.1 → 0.1.2
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 +48 -7
- package/docs/NOT-BUILT.md +84 -0
- package/docs/toolchain.md +49 -22
- package/package.json +10 -6
- package/src/astro/MetaTags.astro +2 -4
- package/src/astro/carousel.ts +342 -0
- package/src/astro/dev-log.ts +128 -0
- package/src/astro/dom.ts +53 -0
- package/src/astro/element.ts +259 -0
- package/src/astro/site-routes.ts +111 -21
- package/src/astro/tsconfig.json +8 -0
- package/src/contact-form.ts +177 -0
- package/src/index.ts +7 -0
- package/src/routes/define.ts +3 -1
- package/src/routes/resolve.ts +28 -3
- package/src/site/api.ts +12 -0
- package/src/site/create.ts +132 -42
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A carousel's arithmetic and its input handling — not its markup.
|
|
3
|
+
*
|
|
4
|
+
* In `astro/` because it touches the DOM and timers, which the core is
|
|
5
|
+
* type-checked without — this and `consent.ts` are the folder allowed them.
|
|
6
|
+
*
|
|
7
|
+
* **It draws nothing.** No transform, no classes, no dots, no arrows, no
|
|
8
|
+
* `aria`. It owns which slide is current and calls `onChange` when that
|
|
9
|
+
* changes; a project does the one line that moves the track, and every design
|
|
10
|
+
* decision stays where the design is. That is the whole split, and it is why
|
|
11
|
+
* one function can serve a full-bleed hero and a three-up logo strip.
|
|
12
|
+
*
|
|
13
|
+
* What it does own is the handful of things that are identical in every
|
|
14
|
+
* carousel and quietly wrong in most: the negative modulo going backwards, a
|
|
15
|
+
* lock so a fast swipe cannot start a second move mid-transition, autoplay that
|
|
16
|
+
* restarts on interaction rather than fighting it, and a swipe threshold that
|
|
17
|
+
* does not steal a vertical scroll.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* const slider = carousel({
|
|
21
|
+
* length: slides.length,
|
|
22
|
+
* autoplayMs: 6000,
|
|
23
|
+
* onChange: (index) => {
|
|
24
|
+
* track.style.transform = `translateX(-${index * 100}%)`;
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
* slider.attach(viewport);
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* The element handed to `attach` wants `touch-action: pan-y` in CSS. Without
|
|
31
|
+
* it a browser may claim the horizontal gesture before this sees it; with it,
|
|
32
|
+
* vertical scrolling still works over the carousel, which is what a reader on a
|
|
33
|
+
* phone expects.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface CarouselOptions {
|
|
37
|
+
/** How many slides there are. Fewer than two and everything is inert. */
|
|
38
|
+
readonly length: number;
|
|
39
|
+
/**
|
|
40
|
+
* Milliseconds between automatic advances. Omitted, nothing advances on its
|
|
41
|
+
* own.
|
|
42
|
+
*
|
|
43
|
+
* The timer restarts on every manual move rather than continuing, so a
|
|
44
|
+
* reader who just pressed *next* gets a full interval to look at what they
|
|
45
|
+
* asked for instead of the remainder of the last one.
|
|
46
|
+
*/
|
|
47
|
+
readonly autoplayMs?: number;
|
|
48
|
+
/** Pixels of horizontal travel before a drag counts as a swipe. */
|
|
49
|
+
readonly swipeThreshold?: number;
|
|
50
|
+
/**
|
|
51
|
+
* How long a move is protected from the next one, in milliseconds.
|
|
52
|
+
*
|
|
53
|
+
* Should match the CSS transition. Too short and a fast swipe queues a
|
|
54
|
+
* second move over an unfinished one; too long and the carousel feels
|
|
55
|
+
* unresponsive.
|
|
56
|
+
*/
|
|
57
|
+
readonly lockMs?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Called with the new index, and only when it actually changed.
|
|
60
|
+
*
|
|
61
|
+
* Never called on creation: the markup already renders whichever slide it
|
|
62
|
+
* renders, and calling would make a project write the first position twice.
|
|
63
|
+
*/
|
|
64
|
+
readonly onChange: (index: number) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Carousel {
|
|
68
|
+
/** The slide showing now. */
|
|
69
|
+
readonly index: number;
|
|
70
|
+
next(): void;
|
|
71
|
+
prev(): void;
|
|
72
|
+
goTo(index: number): void;
|
|
73
|
+
/**
|
|
74
|
+
* Wires one element up, and returns the undo.
|
|
75
|
+
*
|
|
76
|
+
* Starts input *and* autoplay, and the returned function stops both — one
|
|
77
|
+
* lifecycle rather than two. Nothing rotates before `attach`, because a
|
|
78
|
+
* carousel nobody can reach has no business advancing, and nothing rotates
|
|
79
|
+
* after detach, because writing a transform to a track that has left the
|
|
80
|
+
* document is a leak with no visible symptom.
|
|
81
|
+
*
|
|
82
|
+
* One element per carousel. Buttons and dots are wired by the caller to
|
|
83
|
+
* `next`, `prev` and `goTo` — this is only for the surface a gesture lands
|
|
84
|
+
* on, and there is one of those.
|
|
85
|
+
*
|
|
86
|
+
* `index` is not touched by either, which is the point of the split: the
|
|
87
|
+
* slide a reader left it on survives a detach and a re-attach.
|
|
88
|
+
*/
|
|
89
|
+
attach(element: HTMLElement): () => void;
|
|
90
|
+
/**
|
|
91
|
+
* Holds autoplay until the matching `resume`.
|
|
92
|
+
*
|
|
93
|
+
* `attach` already pauses on hover, on focus within, and while the tab is
|
|
94
|
+
* hidden — WCAG 2.2.2 asks for a way to stop anything auto-updating for
|
|
95
|
+
* more than five seconds, and this rotates every six. This is for the rest:
|
|
96
|
+
* a play/pause button, a modal opening over the slider.
|
|
97
|
+
*/
|
|
98
|
+
pause(): void;
|
|
99
|
+
/** Releases a `pause`. Does nothing if something else is still holding. */
|
|
100
|
+
resume(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function carousel(options: CarouselOptions): Carousel {
|
|
104
|
+
const {
|
|
105
|
+
length,
|
|
106
|
+
autoplayMs,
|
|
107
|
+
swipeThreshold = 10,
|
|
108
|
+
lockMs = 500,
|
|
109
|
+
onChange,
|
|
110
|
+
} = options;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether there is anywhere to go.
|
|
114
|
+
*
|
|
115
|
+
* One slide is a picture, not a carousel: nothing advances, nothing is
|
|
116
|
+
* scheduled, and a swipe does nothing rather than wrapping to itself.
|
|
117
|
+
*/
|
|
118
|
+
const canMove = length > 1;
|
|
119
|
+
|
|
120
|
+
let index = 0;
|
|
121
|
+
let locked = false;
|
|
122
|
+
let lockTimer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
let autoplayTimer: ReturnType<typeof setInterval> | undefined;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether anything is wired up.
|
|
127
|
+
*
|
|
128
|
+
* Gates autoplay: a carousel that was built but never attached, or has been
|
|
129
|
+
* detached, has no business scheduling a timer that writes transforms to
|
|
130
|
+
* markup nobody is connected to.
|
|
131
|
+
*/
|
|
132
|
+
let attached = false;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Everything currently holding autoplay still. Empty means it may run.
|
|
136
|
+
*
|
|
137
|
+
* A set of reasons rather than a flag, because the reasons are independent
|
|
138
|
+
* and overlap: leave the tab while hovering, come back with the pointer
|
|
139
|
+
* somewhere else, and `pointerleave` never fires — with one boolean,
|
|
140
|
+
* whichever source wrote last wins and autoplay is stuck off or wrongly on.
|
|
141
|
+
* Each source only ever adds and removes its own key, so no source can
|
|
142
|
+
* release another's hold.
|
|
143
|
+
*/
|
|
144
|
+
const holds = new Set<string>();
|
|
145
|
+
|
|
146
|
+
const hold = (reason: string): void => {
|
|
147
|
+
holds.add(reason);
|
|
148
|
+
clearInterval(autoplayTimer);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const release = (reason: string): void => {
|
|
152
|
+
holds.delete(reason);
|
|
153
|
+
restartAutoplay();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Claims the carousel for one move, or says it is already busy.
|
|
158
|
+
*
|
|
159
|
+
* Everything that moves goes through this, autoplay included — a tick that
|
|
160
|
+
* landed mid-transition without it would let the next swipe start a second
|
|
161
|
+
* move over an unfinished one, which is the exact case the lock exists for.
|
|
162
|
+
*/
|
|
163
|
+
const claim = (): boolean => {
|
|
164
|
+
if (locked) return false;
|
|
165
|
+
locked = true;
|
|
166
|
+
clearTimeout(lockTimer);
|
|
167
|
+
lockTimer = setTimeout(() => {
|
|
168
|
+
locked = false;
|
|
169
|
+
}, lockMs);
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const restartAutoplay = (): void => {
|
|
174
|
+
clearInterval(autoplayTimer);
|
|
175
|
+
// Nothing to rotate through, nobody watching, or no autoplay asked
|
|
176
|
+
// for — in each case, nothing to schedule.
|
|
177
|
+
if (autoplayMs === undefined || !canMove || !attached) return;
|
|
178
|
+
if (holds.size > 0) return;
|
|
179
|
+
autoplayTimer = setInterval(() => {
|
|
180
|
+
// No `restartAutoplay` here: the interval already paces itself, and
|
|
181
|
+
// resetting it from inside its own tick would only churn timers.
|
|
182
|
+
if (claim()) move(index + 1);
|
|
183
|
+
}, autoplayMs);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** The only place `index` changes. `at` may be out of range or negative. */
|
|
187
|
+
const move = (at: number): void => {
|
|
188
|
+
if (!canMove) return;
|
|
189
|
+
// `+ length` before the modulo: in JavaScript `-1 % 5` is `-1`, not
|
|
190
|
+
// `4`, so going backwards from the first slide without it lands on
|
|
191
|
+
// nothing.
|
|
192
|
+
const next = ((at % length) + length) % length;
|
|
193
|
+
if (next === index) return;
|
|
194
|
+
|
|
195
|
+
index = next;
|
|
196
|
+
onChange(index);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** A move a reader asked for: refuses during one, and resets autoplay. */
|
|
200
|
+
const request = (at: number): void => {
|
|
201
|
+
if (!canMove || !claim()) return;
|
|
202
|
+
move(at);
|
|
203
|
+
restartAutoplay();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Nothing is scheduled here: autoplay begins on the first `attach` and ends
|
|
207
|
+
// with the last detach, so a carousel that was built but never wired up
|
|
208
|
+
// does not sit rotating against markup nobody has connected to it.
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
get index() {
|
|
212
|
+
return index;
|
|
213
|
+
},
|
|
214
|
+
next: () => request(index + 1),
|
|
215
|
+
prev: () => request(index - 1),
|
|
216
|
+
goTo: (at: number) => request(at),
|
|
217
|
+
pause: () => hold("manual"),
|
|
218
|
+
resume: () => release("manual"),
|
|
219
|
+
|
|
220
|
+
attach(element: HTMLElement): () => void {
|
|
221
|
+
let startX: number | null = null;
|
|
222
|
+
let startY = 0;
|
|
223
|
+
let lastX = 0;
|
|
224
|
+
|
|
225
|
+
const down = (event: PointerEvent): void => {
|
|
226
|
+
startX = event.clientX;
|
|
227
|
+
startY = event.clientY;
|
|
228
|
+
lastX = event.clientX;
|
|
229
|
+
// Capture, so a drag that leaves the element still finishes
|
|
230
|
+
// here rather than being lost to whatever it passed over.
|
|
231
|
+
element.setPointerCapture(event.pointerId);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const pointermove = (event: PointerEvent): void => {
|
|
235
|
+
if (startX === null) return;
|
|
236
|
+
lastX = event.clientX;
|
|
237
|
+
// Traveling more vertically than horizontally means they are
|
|
238
|
+
// scrolling the page, not the carousel. Abandon rather than
|
|
239
|
+
// compete: a carousel that eats a scroll is worse than one that
|
|
240
|
+
// misses a swipe.
|
|
241
|
+
if (
|
|
242
|
+
Math.abs(event.clientY - startY) >
|
|
243
|
+
Math.abs(event.clientX - startX)
|
|
244
|
+
) {
|
|
245
|
+
startX = null;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const up = (): void => {
|
|
250
|
+
if (startX === null) return;
|
|
251
|
+
const traveled = startX - lastX;
|
|
252
|
+
startX = null;
|
|
253
|
+
|
|
254
|
+
if (traveled > swipeThreshold) request(index + 1);
|
|
255
|
+
else if (traveled < -swipeThreshold) request(index - 1);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const cancel = (): void => {
|
|
259
|
+
startX = null;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const wheel = (event: WheelEvent): void => {
|
|
263
|
+
// Horizontal intent only — a track-pad reports both axes, and a
|
|
264
|
+
// mouse wheel reports `deltaY` alone, which is the page's.
|
|
265
|
+
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
|
|
266
|
+
if (Math.abs(event.deltaX) <= swipeThreshold) return;
|
|
267
|
+
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
request(event.deltaX > 0 ? index + 1 : index - 1);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// One controller for the lot, rather than a `removeEventListener`
|
|
273
|
+
// mirroring each `addEventListener`. Those pairs have to match on
|
|
274
|
+
// both the function reference and the options, and the failure is
|
|
275
|
+
// silent: a listener added later without its twin simply survives
|
|
276
|
+
// teardown. Here the undo cannot drift from what it undoes.
|
|
277
|
+
const listeners = new AbortController();
|
|
278
|
+
const { signal } = listeners;
|
|
279
|
+
|
|
280
|
+
element.addEventListener("pointerdown", down, { signal });
|
|
281
|
+
element.addEventListener("pointermove", pointermove, { signal });
|
|
282
|
+
element.addEventListener("pointerup", up, { signal });
|
|
283
|
+
element.addEventListener("pointercancel", cancel, { signal });
|
|
284
|
+
// `passive: false` is load-bearing: a wheel listener is passive by
|
|
285
|
+
// default, where `preventDefault` silently does nothing and the
|
|
286
|
+
// page scrolls sideways underneath the carousel.
|
|
287
|
+
element.addEventListener("wheel", wheel, {
|
|
288
|
+
passive: false,
|
|
289
|
+
signal,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Pointer and keyboard, kept apart: a reader hovering a slide and a
|
|
293
|
+
// reader tabbing through its links are both looking at it, but
|
|
294
|
+
// either can end while the other continues.
|
|
295
|
+
element.addEventListener("pointerenter", () => hold("hover"), {
|
|
296
|
+
signal,
|
|
297
|
+
});
|
|
298
|
+
element.addEventListener("pointerleave", () => release("hover"), {
|
|
299
|
+
signal,
|
|
300
|
+
});
|
|
301
|
+
element.addEventListener("focusin", () => hold("focus"), {
|
|
302
|
+
signal,
|
|
303
|
+
});
|
|
304
|
+
element.addEventListener("focusout", () => release("focus"), {
|
|
305
|
+
signal,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// On the document, not the element: a hidden tab is not a fact
|
|
309
|
+
// about the carousel. Registered with the same signal, so it goes
|
|
310
|
+
// when the carousel does rather than outliving it.
|
|
311
|
+
document.addEventListener(
|
|
312
|
+
"visibilitychange",
|
|
313
|
+
() => {
|
|
314
|
+
if (document.hidden) hold("hidden");
|
|
315
|
+
else release("hidden");
|
|
316
|
+
},
|
|
317
|
+
{ signal }
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
attached = true;
|
|
321
|
+
// A tab that was already hidden when this ran gets no event, so the
|
|
322
|
+
// initial state is read rather than waited for.
|
|
323
|
+
if (document.hidden) holds.add("hidden");
|
|
324
|
+
restartAutoplay();
|
|
325
|
+
|
|
326
|
+
return () => {
|
|
327
|
+
listeners.abort();
|
|
328
|
+
attached = false;
|
|
329
|
+
clearInterval(autoplayTimer);
|
|
330
|
+
|
|
331
|
+
// The automatic holds belong to this attachment: an element
|
|
332
|
+
// detached while hovered would otherwise keep "hover" forever,
|
|
333
|
+
// and no `pointerleave` is ever coming for it — autoplay would
|
|
334
|
+
// never resume on re-attach. A manual pause is a deliberate
|
|
335
|
+
// choice and survives.
|
|
336
|
+
holds.delete("hover");
|
|
337
|
+
holds.delete("focus");
|
|
338
|
+
holds.delete("hidden");
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A development-only panel that failures announce themselves in.
|
|
3
|
+
*
|
|
4
|
+
* The problem it solves is narrow and real: a client component that fails to
|
|
5
|
+
* wire itself looks exactly like one that had nothing to do. A `console.error`
|
|
6
|
+
* is correct and invisible — nobody has devtools open while reading a page, and
|
|
7
|
+
* least of all the person who broke it ten seconds ago. This puts the same
|
|
8
|
+
* message where it cannot be missed.
|
|
9
|
+
*
|
|
10
|
+
* **Every call site must be behind `import.meta.env.DEV`.** The check belongs
|
|
11
|
+
* to the caller rather than to this module, so the whole thing — panel, styles
|
|
12
|
+
* and message strings — is dead code in a production build and gets dropped
|
|
13
|
+
* instead of shipped and never called.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* if (import.meta.env.DEV) reportDevError(this.localName, error);
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Written as the bare expression, without an optional chain: Vite substitutes
|
|
20
|
+
* `import.meta.env.DEV` with a literal, so the branch collapses and everything
|
|
21
|
+
* below is dropped. `import.meta.env?.DEV` is replaced as `import.meta.env` —
|
|
22
|
+
* an object literal whose property access a minifier has to fold rather than
|
|
23
|
+
* simply delete.
|
|
24
|
+
*
|
|
25
|
+
* Not a custom element, deliberately. It would need registering, it would
|
|
26
|
+
* collide with a project that registered the same name, and nothing ever
|
|
27
|
+
* extends it — the panel is an artefact of a broken page, not a part of the
|
|
28
|
+
* page's design.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const PANEL_ID = "atlas-dev-log";
|
|
32
|
+
|
|
33
|
+
/** Everything already reported, so a repeated failure counts rather than spams. */
|
|
34
|
+
const seen = new Map<string, HTMLElement>();
|
|
35
|
+
|
|
36
|
+
/** The message a line shows, for anything that might be thrown. */
|
|
37
|
+
function describe(error: unknown): string {
|
|
38
|
+
if (error instanceof Error) return error.message;
|
|
39
|
+
if (typeof error === "object" && error !== null) {
|
|
40
|
+
// A thrown plain object stringifies to "[object Object]", which is the
|
|
41
|
+
// same non-message this panel exists to replace.
|
|
42
|
+
try {
|
|
43
|
+
return JSON.stringify(error);
|
|
44
|
+
} catch {
|
|
45
|
+
return Object.prototype.toString.call(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return String(error);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function panel(): HTMLElement {
|
|
52
|
+
const existing = document.getElementById(PANEL_ID);
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
|
|
55
|
+
// Either nothing has failed yet, or the document this lived in was swapped
|
|
56
|
+
// out from under it — a client router replacing `<body>`. Either way every
|
|
57
|
+
// line in `seen` is detached, and counting into one would report nothing.
|
|
58
|
+
seen.clear();
|
|
59
|
+
|
|
60
|
+
const created = document.createElement("div");
|
|
61
|
+
created.id = PANEL_ID;
|
|
62
|
+
// Inline, because a project's stylesheet is not this module's to depend on
|
|
63
|
+
// — and a panel that needed setting up would not be there the one time it
|
|
64
|
+
// mattered. `z-index` at the maximum: whatever is broken, this outranks it.
|
|
65
|
+
created.style.cssText = [
|
|
66
|
+
"position:fixed",
|
|
67
|
+
"inset:auto 0 0 0",
|
|
68
|
+
"z-index:2147483647",
|
|
69
|
+
"max-height:40vh",
|
|
70
|
+
"overflow:auto",
|
|
71
|
+
"background:#7f1d1d",
|
|
72
|
+
"color:#fff",
|
|
73
|
+
"font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace",
|
|
74
|
+
].join(";");
|
|
75
|
+
|
|
76
|
+
// Dismissable, because a panel pinned over the bottom 40% of the viewport
|
|
77
|
+
// will eventually be covering the component being debugged.
|
|
78
|
+
const close = document.createElement("button");
|
|
79
|
+
close.type = "button";
|
|
80
|
+
close.textContent = "✕";
|
|
81
|
+
close.setAttribute("aria-label", "Dismiss");
|
|
82
|
+
close.style.cssText =
|
|
83
|
+
"position:sticky;top:0;float:right;border:0;background:transparent;color:#fff;cursor:pointer;font:inherit;padding:.4rem .6rem";
|
|
84
|
+
close.addEventListener("click", () => {
|
|
85
|
+
created.remove();
|
|
86
|
+
seen.clear();
|
|
87
|
+
});
|
|
88
|
+
created.append(close);
|
|
89
|
+
|
|
90
|
+
document.body.append(created);
|
|
91
|
+
return created;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reports a failure to the console and to the panel.
|
|
96
|
+
*
|
|
97
|
+
* `source` names what broke — an element's `localName`, a module's name —
|
|
98
|
+
* because the message alone rarely says which of six carousels it came from.
|
|
99
|
+
*
|
|
100
|
+
* Identical reports collapse into one line with a count. Six broken instances
|
|
101
|
+
* of the same component are one bug, and six identical lines would bury the
|
|
102
|
+
* second bug underneath them.
|
|
103
|
+
*/
|
|
104
|
+
export function reportDevError(source: string, error: unknown): void {
|
|
105
|
+
const text = `${source} — ${describe(error)}`;
|
|
106
|
+
|
|
107
|
+
console.error(text, error);
|
|
108
|
+
|
|
109
|
+
// `isConnected`, not merely present: after a body swap the remembered line
|
|
110
|
+
// is detached, and incrementing its count would report into a node nobody
|
|
111
|
+
// can see. Falling through rebuilds it in the new document — the count
|
|
112
|
+
// restarts, which is right, since it is a different page.
|
|
113
|
+
const already = seen.get(text);
|
|
114
|
+
if (already?.isConnected) {
|
|
115
|
+
const count = Number(already.dataset.count ?? "1") + 1;
|
|
116
|
+
already.dataset.count = String(count);
|
|
117
|
+
already.textContent = `${text} ×${count}`;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const line = document.createElement("p");
|
|
122
|
+
line.style.cssText =
|
|
123
|
+
"margin:0;padding:.4rem .6rem;border-top:1px solid rgba(255,255,255,.25)";
|
|
124
|
+
line.textContent = text;
|
|
125
|
+
|
|
126
|
+
seen.set(text, line);
|
|
127
|
+
panel().append(line);
|
|
128
|
+
}
|
package/src/astro/dom.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped element lookups, without the ceremony.
|
|
3
|
+
*
|
|
4
|
+
* `querySelector` returns `Element`, so anything that wants to set `hidden`,
|
|
5
|
+
* read `dataset` or call `focus` has to say `<HTMLElement>` at every call — long
|
|
6
|
+
* enough that the line wraps, on lookups that are otherwise trivial. And
|
|
7
|
+
* `querySelectorAll` returns a `NodeList`, which needs spreading before it will
|
|
8
|
+
* `map` or `entries`. Both are noise, and both hide the one thing worth reading:
|
|
9
|
+
* what is being looked for.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const { one, all } = within(root);
|
|
13
|
+
* const track = one("[data-carousel-track]");
|
|
14
|
+
* const dots = all<HTMLButtonElement>("[data-carousel-dot]");
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The root is bound once, which is the point: a client script that looks
|
|
18
|
+
* everything up inside the element it was given cannot reach a second copy of
|
|
19
|
+
* itself elsewhere on the page. `document` is a valid root, and should appear
|
|
20
|
+
* exactly once per script — to find the roots.
|
|
21
|
+
*
|
|
22
|
+
* **A selector with a combinator still matches against the whole document.**
|
|
23
|
+
* `within(form).one("form p")` can match a `<p>` whose ancestor `<form>` is not
|
|
24
|
+
* the one bound here; only the final filter is scoped. Simple selectors — an
|
|
25
|
+
* attribute, a class — are unaffected, which is what these are for. Use
|
|
26
|
+
* `:scope` if a combinator is ever genuinely needed.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Constrained to `Element` but defaulting to `HTMLElement`.
|
|
31
|
+
*
|
|
32
|
+
* The default is what almost every lookup wants — `hidden`, `dataset` and
|
|
33
|
+
* `focus` all live on `HTMLElement`, and having to name it at each call is the
|
|
34
|
+
* noise this exists to remove. The wider constraint is for the rest: inline
|
|
35
|
+
* `<svg>` and `<use>` are `SVGElement`, which is an `Element` and not an
|
|
36
|
+
* `HTMLElement`, so a narrower bound would refuse a perfectly ordinary lookup.
|
|
37
|
+
*/
|
|
38
|
+
export interface Within {
|
|
39
|
+
/** The first match inside the root, or `null`. */
|
|
40
|
+
one<T extends Element = HTMLElement>(selector: string): T | null;
|
|
41
|
+
/** Every match inside the root, as an array rather than a `NodeList`. */
|
|
42
|
+
all<T extends Element = HTMLElement>(selector: string): T[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function within(root: ParentNode): Within {
|
|
46
|
+
return {
|
|
47
|
+
one: <T extends Element = HTMLElement>(selector: string) =>
|
|
48
|
+
root.querySelector<T>(selector),
|
|
49
|
+
all: <T extends Element = HTMLElement>(selector: string) => [
|
|
50
|
+
...root.querySelectorAll<T>(selector),
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|