@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
package/src/astro/dom.ts CHANGED
@@ -1,40 +1,20 @@
1
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.
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, 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.
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>` 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.
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) =>
@@ -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, holding the parts every one of them gets wrong.
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
- * #count = 0;
14
- *
15
- * protected setup(): void { … } // once, ever
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
- * **Two lifetimes, and confusing them is the whole reason this class exists.**
26
- * An element is constructed once and may be connected many times: moving it in
27
- * the DOM runs `disconnectedCallback` then `connectedCallback` again. So
28
- * `setup` is for what the element *is* state, defaults, anything a reader
29
- * would be annoyed to lose — and `connect` is for what a connection *owns* —
30
- * listeners, timers, observers. Anything registered with `signal` is torn down
31
- * on every disconnect and re-registered on every connect, so a re-entry rebinds
32
- * rather than duplicating.
33
- *
34
- * **Subclasses implement `setup`, `connect` and `disconnect` never
35
- * `connectedCallback` or `disconnectedCallback`.** Overriding those replaces
36
- * this class's, the controller is never created, and every listener leaks on
37
- * each disconnect. `connect` is abstract, so forgetting it is a compile error;
38
- * the adjacent mistake is caught at construction in development, below.
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
- * **The script that defines a subclass must stay a deferred module.** Astro
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
- * Cancels this connection's listeners, and only this connection's.
60
- *
61
- * Remade on every connect rather than held in a field initializer, because
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
- // Written as the bare expression Vite substitutes: `import.meta.env.DEV`
75
- // is replaced with a literal, so this collapses to `if (false)` and the
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
- * An override is an *own* property of the subclass prototype, where the
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 rather than an object: `Object.entries` widens its keys
92
- // back to `string`, so the pairing would stop being checked exactly
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, so
110
- // `connectedCallback`'s catch has not been entered and the
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. The spec pairs the callbacks, so this should never find a
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 here so one broken element logs and sits inert rather than
146
- // taking its siblings with it: the browser calls this once per element,
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. Setting the flag first would mean a
153
- // `setup` that threw was never retried, and the next connect
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 without running a
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 `signal.aborted` and
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
- * Throws rather than returning `T | null`, because a nullable return puts an
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);