@escape-game-over/atlas 0.1.17 → 0.1.19
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 +57 -0
- package/package.json +1 -1
- package/src/astro/dom.ts +8 -26
- package/src/astro/element.ts +46 -124
- package/src/astro/filters-view.ts +46 -147
- package/src/astro/filters.ts +88 -248
- package/src/astro/markup.ts +279 -116
package/docs/client-scripts.md
CHANGED
|
@@ -428,6 +428,59 @@ lookup: it finds only elements carrying its own marker, writes only its own
|
|
|
428
428
|
attributes, and sets no class, `aria` or `hidden`. The names are the project's,
|
|
429
429
|
so nothing in this package has to be matched.
|
|
430
430
|
|
|
431
|
+
### `component`: roles owned by one custom element
|
|
432
|
+
|
|
433
|
+
`markup` leaves two things to the project, and both bite: a name per role, which
|
|
434
|
+
two components can pick alike, and scope, since `querySelectorAll` from an
|
|
435
|
+
element also finds everything inside a nested copy of that element. `component`
|
|
436
|
+
ties every role to a custom element and settles both.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
export const faq = component("go-faq", {
|
|
440
|
+
row: { key: { kind: "text" }, text: { kind: "text" } },
|
|
441
|
+
search: {},
|
|
442
|
+
});
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
```astro
|
|
446
|
+
<faq.tag>
|
|
447
|
+
<input {...faq.search.attrs()} type="search">
|
|
448
|
+
<details {...faq.row.attrs({ key, text })}>…</details>
|
|
449
|
+
</faq.tag>
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
class Faq extends AtlasElement {
|
|
454
|
+
protected connect(): void {
|
|
455
|
+
for (const { element, values } of faq.row.all(this)) { … }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
faq.define(Faq);
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
- **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
|
|
462
|
+
field, and the browser refuses to define one tag twice, so two components
|
|
463
|
+
cannot share an attribute. Two roles of one component that would write the
|
|
464
|
+
same attribute — `search` with a field `clear`, and a role `searchClear` — are
|
|
465
|
+
refused when the component is created.
|
|
466
|
+
- **Lookups stay in their instance.** `all`, `one` and `require` return only
|
|
467
|
+
elements whose nearest ancestor with this tag is the root's. A nested copy
|
|
468
|
+
keeps its elements, and a lookup from an element inside the instance, like a
|
|
469
|
+
dropdown's own panel, still counts as that instance. Elements are filtered
|
|
470
|
+
before they are read, so a broken nested copy cannot fail the outer lookup.
|
|
471
|
+
- **The tag is written once.** `faq.tag` is what the template renders and
|
|
472
|
+
`faq.define` registers the class under it.
|
|
473
|
+
- **The names are checked in the editor.** A tag without a hyphen, or a role
|
|
474
|
+
that is not camelCase, is a compile error where it is written — the rules are
|
|
475
|
+
types, character by character, as in `i18n/placeholders.ts`. Nothing is
|
|
476
|
+
validated at run time, because a name that reached the browser malformed would
|
|
477
|
+
already have been refused there: by `setAttribute`, by `querySelectorAll`, or
|
|
478
|
+
by `customElements.define`.
|
|
479
|
+
|
|
480
|
+
`tag` and `define` are the component's own keys, so no role may take them. A role
|
|
481
|
+
that lives outside every instance, such as a footer button that reopens a
|
|
482
|
+
banner, is still plain `markup`.
|
|
483
|
+
|
|
431
484
|
## `youtube`
|
|
432
485
|
|
|
433
486
|
A YouTube video that loads nothing from YouTube until a reader asks for it. The
|
|
@@ -565,6 +618,10 @@ right properties is a faithful stand-in.
|
|
|
565
618
|
root that answers bare attribute selectors, and round-trips every field kind
|
|
566
619
|
through `attrs` and `read`. `type-tests/markup.ts` pins the half that is a
|
|
567
620
|
compile error.
|
|
621
|
+
- `tests/component.test.ts` builds a small element tree with parents and
|
|
622
|
+
`closest`, and pins the scoping: a nested instance, two side by side, a root
|
|
623
|
+
inside the instance, and a broken nested element that must not be read.
|
|
624
|
+
`type-tests/component.ts` pins the role types and the reserved keys.
|
|
568
625
|
|
|
569
626
|
`carousel`, `consent`, `element` and `dom` have none. They need a real DOM and
|
|
570
627
|
this package carries no environment for one; adding `happy-dom` as a dev
|
package/package.json
CHANGED
package/src/astro/dom.ts
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scoped element lookups,
|
|
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.
|
|
2
|
+
* Scoped element lookups, typed.
|
|
10
3
|
*
|
|
11
4
|
* ```ts
|
|
12
5
|
* const { one, all } = within(root);
|
|
13
|
-
* const track = one("[data-carousel-track]");
|
|
14
6
|
* const dots = all<HTMLButtonElement>("[data-carousel-dot]");
|
|
15
7
|
* ```
|
|
16
8
|
*
|
|
17
|
-
* The root is bound once,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* exactly once per script — to find the roots.
|
|
9
|
+
* The root is bound once, so a script cannot reach a second copy of itself
|
|
10
|
+
* elsewhere on the page. `document` belongs in one place per script: finding
|
|
11
|
+
* the roots.
|
|
21
12
|
*
|
|
22
13
|
* **A selector with a combinator still matches against the whole document.**
|
|
23
|
-
* `within(form).one("form p")` can match a `<p>`
|
|
24
|
-
* the
|
|
25
|
-
* attribute, a class — are unaffected, which is what these are for. Use
|
|
26
|
-
* `:scope` if a combinator is ever genuinely needed.
|
|
14
|
+
* `within(form).one("form p")` can match a `<p>` under a different form; only
|
|
15
|
+
* the final filter is scoped. Use `:scope` if a combinator is needed.
|
|
27
16
|
*/
|
|
28
17
|
|
|
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
18
|
export interface Within {
|
|
39
19
|
/** The first match inside the root, or `null`. */
|
|
40
20
|
one<T extends Element = HTMLElement>(selector: string): T | null;
|
|
@@ -42,6 +22,8 @@ export interface Within {
|
|
|
42
22
|
all<T extends Element = HTMLElement>(selector: string): T[];
|
|
43
23
|
}
|
|
44
24
|
|
|
25
|
+
// `HTMLElement` by default because `hidden`, `dataset` and `focus` live there;
|
|
26
|
+
// the bound stays `Element` so inline `<svg>` lookups are not refused.
|
|
45
27
|
export function within(root: ParentNode): Within {
|
|
46
28
|
return {
|
|
47
29
|
one: <T extends Element = HTMLElement>(selector: string) =>
|
package/src/astro/element.ts
CHANGED
|
@@ -2,19 +2,13 @@ import { reportDevError } from "./dev-log.ts";
|
|
|
2
2
|
import { within } from "./dom.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* A base for custom elements
|
|
6
|
-
*
|
|
7
|
-
* In `astro/` because it touches the DOM, which the core is type-checked
|
|
8
|
-
* without — this, `consent.ts`, `carousel.ts` and `dom.ts` are the folder
|
|
9
|
-
* allowed it.
|
|
5
|
+
* A base for custom elements: two lifetimes, and one abort signal.
|
|
10
6
|
*
|
|
11
7
|
* ```ts
|
|
12
8
|
* class Thing extends AtlasElement {
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* protected connect(): void { // every time it enters the document
|
|
17
|
-
* this.require("form").addEventListener("submit", this.#send, {
|
|
9
|
+
* protected setup(): void { … } // once, ever: what the element is
|
|
10
|
+
* protected connect(): void { // every connection: bind with signal
|
|
11
|
+
* this.require("form").addEventListener("submit", send, {
|
|
18
12
|
* signal: this.signal,
|
|
19
13
|
* });
|
|
20
14
|
* }
|
|
@@ -22,75 +16,49 @@ import { within } from "./dom.ts";
|
|
|
22
16
|
* customElements.define("atlas-thing", Thing);
|
|
23
17
|
* ```
|
|
24
18
|
*
|
|
25
|
-
* **
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
19
|
+
* - **An element is constructed once and connected many times.** Moving it in
|
|
20
|
+
* the DOM runs `disconnectedCallback` then `connectedCallback` again, so
|
|
21
|
+
* state belongs in `setup` and listeners in `connect`. Anything registered
|
|
22
|
+
* with `signal` is dropped on disconnect and rebound on the next connect.
|
|
23
|
+
* - **Override `setup`, `connect` and `disconnect`, never the callbacks.**
|
|
24
|
+
* Overriding `connectedCallback` skips the controller and leaks every
|
|
25
|
+
* listener; that mistake throws in development, below.
|
|
26
|
+
* - **The defining script must stay a deferred module.** Astro emits
|
|
27
|
+
* `<script src>` as `type="module"`, so the element has its children when it
|
|
28
|
+
* is upgraded. `is:inline` runs it too early and every lookup finds nothing.
|
|
29
|
+
* - **`attributeChangedCallback` runs before `connectedCallback`**, where
|
|
30
|
+
* `signal` is not readable yet. Record what changed and act on it in
|
|
31
|
+
* `connect`.
|
|
32
|
+
* - **`disconnect` is not a destructor.** A move fires it and connects again a
|
|
33
|
+
* moment later, so it is connection teardown and nothing else.
|
|
39
34
|
*
|
|
40
|
-
*
|
|
41
|
-
* emits `<script>` and `<script src="./…">` as `type="module"`, which runs after
|
|
42
|
-
* the document is parsed, so an element has its children by the time it is
|
|
43
|
-
* upgraded. `is:inline` opts out and runs the script where it sits — usually
|
|
44
|
-
* above the element it wires — and every lookup then finds nothing. The
|
|
45
|
-
* protection comes from the bundling, not from custom elements.
|
|
46
|
-
*
|
|
47
|
-
* **`attributeChangedCallback` runs before `connectedCallback`** for attributes
|
|
48
|
-
* present in the initial markup, so `signal` is not available there and reading
|
|
49
|
-
* it throws. A subclass with `observedAttributes` should record what changed and
|
|
50
|
-
* act on it in `connect`.
|
|
51
|
-
*
|
|
52
|
-
* What is deliberately *not* modelled is destruction. `disconnect` fires on
|
|
53
|
-
* every move, so it is connection teardown and nothing else — it is not the
|
|
54
|
-
* place to flush state or release something genuinely scarce, because the
|
|
55
|
-
* element may well be back a microtask later.
|
|
35
|
+
* See docs/client-scripts.md.
|
|
56
36
|
*/
|
|
57
37
|
export abstract class AtlasElement extends HTMLElement {
|
|
58
38
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* an `AbortController` is single-use: a controller that outlived the first
|
|
63
|
-
* connection would come back already aborted, `addEventListener` with an
|
|
64
|
-
* aborted signal silently adds nothing, and the element would return looking
|
|
65
|
-
* perfectly normal and be inert forever.
|
|
39
|
+
* This connection's listeners, and only this connection's. Remade on every
|
|
40
|
+
* connect: an `AbortController` is single-use, and a reused one comes back
|
|
41
|
+
* already aborted, leaving the element inert but normal-looking.
|
|
66
42
|
*/
|
|
67
43
|
#ac?: AbortController;
|
|
68
44
|
|
|
69
|
-
/** Whether `setup` has run. See the two-lifetimes note above. */
|
|
70
45
|
#ready = false;
|
|
71
46
|
|
|
72
47
|
constructor() {
|
|
73
48
|
super();
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// method below is dropped from the bundle. An optional chain would be
|
|
77
|
-
// replaced as `import.meta.env` instead — an object literal, whose
|
|
78
|
-
// `.DEV` a minifier has to fold rather than simply delete.
|
|
49
|
+
// The bare expression Vite substitutes, so this collapses to
|
|
50
|
+
// `if (false)` and the method below leaves the bundle.
|
|
79
51
|
if (import.meta.env.DEV) this.#assertHooks();
|
|
80
52
|
}
|
|
81
53
|
|
|
82
54
|
/**
|
|
83
|
-
* Refuses a subclass that overrode the wrong lifecycle method.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* inherited one is not — so walking up to this class's prototype finds it.
|
|
87
|
-
* The loop rather than a single check, because an intermediate base class
|
|
88
|
-
* could be the one that got it wrong.
|
|
55
|
+
* Refuses a subclass that overrode the wrong lifecycle method. An override
|
|
56
|
+
* is an *own* property of the subclass prototype; the loop covers an
|
|
57
|
+
* intermediate base class that got it wrong.
|
|
89
58
|
*/
|
|
90
59
|
#assertHooks(): void {
|
|
91
|
-
// A tuple array
|
|
92
|
-
// back to `string
|
|
93
|
-
// where a typo would matter.
|
|
60
|
+
// A tuple array, not an object: `Object.entries` would widen the keys
|
|
61
|
+
// back to `string` and stop checking the pairing.
|
|
94
62
|
const wrong = [
|
|
95
63
|
["connectedCallback", "connect"],
|
|
96
64
|
["disconnectedCallback", "disconnect"],
|
|
@@ -106,11 +74,8 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
106
74
|
const error = new Error(
|
|
107
75
|
`override "${hook}", not "${callback}" — see AtlasElement`
|
|
108
76
|
);
|
|
109
|
-
// Reported before throwing: this runs during upgrade,
|
|
110
|
-
//
|
|
111
|
-
// throw would surface only as an uncaught error in the
|
|
112
|
-
// console — invisible, for the one mistake this whole
|
|
113
|
-
// apparatus exists to catch.
|
|
77
|
+
// Reported before throwing: this runs during upgrade, where
|
|
78
|
+
// a throw would only surface as an uncaught console error.
|
|
114
79
|
reportDevError(this.constructor.name, error);
|
|
115
80
|
throw error;
|
|
116
81
|
}
|
|
@@ -120,10 +85,7 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
120
85
|
|
|
121
86
|
/**
|
|
122
87
|
* Pass to `addEventListener`, observers, anything that should stop when the
|
|
123
|
-
* element leaves the document.
|
|
124
|
-
*
|
|
125
|
-
* Throws when read outside a connection, which is a programming error
|
|
126
|
-
* rather than a state to handle: there is nothing sensible to return.
|
|
88
|
+
* element leaves the document. Throws outside a connection.
|
|
127
89
|
*/
|
|
128
90
|
protected get signal(): AbortSignal {
|
|
129
91
|
if (!this.#ac) {
|
|
@@ -135,25 +97,17 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
135
97
|
}
|
|
136
98
|
|
|
137
99
|
connectedCallback(): void {
|
|
138
|
-
// Insurance
|
|
139
|
-
// live controller — but if it ever did, overwriting one would orphan
|
|
140
|
-
// every listener it owned, silently, which is the failure this class
|
|
141
|
-
// exists to make impossible.
|
|
100
|
+
// Insurance: overwriting a live controller would orphan its listeners.
|
|
142
101
|
this.#ac?.abort();
|
|
143
102
|
this.#ac = new AbortController();
|
|
144
103
|
|
|
145
|
-
// Caught
|
|
146
|
-
//
|
|
147
|
-
// so the blast radius is already one. That is what lets `require`
|
|
148
|
-
// throw instead of returning something every call site has to check.
|
|
104
|
+
// Caught so one broken element logs and sits inert rather than taking
|
|
105
|
+
// its siblings with it — which is what lets `require` throw.
|
|
149
106
|
try {
|
|
150
107
|
if (!this.#ready) {
|
|
151
108
|
this.setup();
|
|
152
|
-
// Only once it returned
|
|
153
|
-
//
|
|
154
|
-
// would run `connect` against state that was never initialized
|
|
155
|
-
// — a component that renders perfectly and does nothing, which
|
|
156
|
-
// is the failure this class exists to make impossible.
|
|
109
|
+
// Only once it returned, so a `setup` that threw is retried
|
|
110
|
+
// rather than leaving `connect` to run against nothing.
|
|
157
111
|
this.#ready = true;
|
|
158
112
|
}
|
|
159
113
|
this.connect();
|
|
@@ -163,18 +117,13 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
163
117
|
}
|
|
164
118
|
|
|
165
119
|
disconnectedCallback(): void {
|
|
166
|
-
// Idempotent, so `adoptedCallback` can delegate here
|
|
167
|
-
// subclass's teardown twice.
|
|
120
|
+
// Idempotent, so `adoptedCallback` can delegate here.
|
|
168
121
|
if (!this.#ac) return;
|
|
169
122
|
this.#ac.abort();
|
|
170
123
|
|
|
171
|
-
// Caught for the same reason as `connect`, and it matters more: a
|
|
172
|
-
// throw here escapes into whatever is swapping the DOM — a router, a
|
|
173
|
-
// view transition — rather than staying in the component.
|
|
174
124
|
try {
|
|
175
125
|
// Before `#ac` is cleared, so `this.signal` is readable and already
|
|
176
|
-
// aborted: an async continuation can check
|
|
177
|
-
// bail rather than finishing against a detached element.
|
|
126
|
+
// aborted: an async continuation can check it and bail.
|
|
178
127
|
this.disconnect();
|
|
179
128
|
} catch (error) {
|
|
180
129
|
this.#report(error);
|
|
@@ -183,13 +132,7 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
183
132
|
this.#ac = undefined;
|
|
184
133
|
}
|
|
185
134
|
|
|
186
|
-
/**
|
|
187
|
-
* Fires when the element moves to another document.
|
|
188
|
-
*
|
|
189
|
-
* Delegates, because the controller belongs to the connection in the old
|
|
190
|
-
* document and nothing else would tear it down. Exotic — `adoptNode` and
|
|
191
|
-
* iframe work — and one line either way.
|
|
192
|
-
*/
|
|
135
|
+
/** Moving to another document ends the old document's connection. */
|
|
193
136
|
adoptedCallback(): void {
|
|
194
137
|
this.disconnectedCallback();
|
|
195
138
|
}
|
|
@@ -202,30 +145,13 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
202
145
|
console.error(`${this.localName}:`, error);
|
|
203
146
|
}
|
|
204
147
|
|
|
205
|
-
/**
|
|
206
|
-
* Runs once per element, before its first `connect`.
|
|
207
|
-
*
|
|
208
|
-
* Where state belongs. A move re-runs `connect` but never this, so anything
|
|
209
|
-
* initialised here survives one — which is the difference between a reader
|
|
210
|
-
* coming back to the slide they left and coming back to the first one.
|
|
211
|
-
*/
|
|
148
|
+
/** Runs once per element, before its first `connect`. State belongs here. */
|
|
212
149
|
protected setup(): void {}
|
|
213
150
|
|
|
214
151
|
/** Runs on every connect, with `signal` and the children both available. */
|
|
215
152
|
protected abstract connect(): void;
|
|
216
153
|
|
|
217
|
-
/**
|
|
218
|
-
* Runs on every disconnect, once the signal has been aborted.
|
|
219
|
-
*
|
|
220
|
-
* `signal` is still readable here and reports `aborted` — which is what an
|
|
221
|
-
* async continuation should check before touching a now-detached element.
|
|
222
|
-
* What it is *not* is a destructor: a move fires this and then `connect`
|
|
223
|
-
* again a moment later, so it is connection teardown and nothing else.
|
|
224
|
-
* Flushing state or releasing something scarce does not belong here.
|
|
225
|
-
*
|
|
226
|
-
* Empty by default: anything registered with `signal` is already gone, and
|
|
227
|
-
* most elements have nothing else to undo.
|
|
228
|
-
*/
|
|
154
|
+
/** Runs on every disconnect, once the signal has been aborted. */
|
|
229
155
|
protected disconnect(): void {}
|
|
230
156
|
|
|
231
157
|
/** The first match inside this element, or `null`. */
|
|
@@ -239,13 +165,9 @@ export abstract class AtlasElement extends HTMLElement {
|
|
|
239
165
|
}
|
|
240
166
|
|
|
241
167
|
/**
|
|
242
|
-
* The first match, or a thrown error naming what was missing.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* `?.` at every call site — and that optional chain swallows the failure
|
|
246
|
-
* just as thoroughly as the missing element did, which is the thing worth
|
|
247
|
-
* avoiding. `connectedCallback` catches it, so one element with broken
|
|
248
|
-
* markup logs and stops while every other element on the page is untouched.
|
|
168
|
+
* The first match, or a thrown error naming what was missing. Throws rather
|
|
169
|
+
* than returning `null`, because the `?.` at every call site swallows the
|
|
170
|
+
* failure as thoroughly as the missing element did.
|
|
249
171
|
*/
|
|
250
172
|
protected require<T extends Element = HTMLElement>(selector: string): T {
|
|
251
173
|
const found = this.one<T>(selector);
|