@escape-game-over/atlas 0.1.25 → 0.1.26

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.
@@ -1,105 +1,91 @@
1
1
  import { reportDevError } from "./dev-log.ts";
2
- import {
3
- type CheckedKeys,
4
- type CheckedTagName,
5
- type Component,
6
- type ComponentRoles,
7
- component,
8
- type Marked,
9
- type MarkupFields,
10
- type Reserved,
11
- ROOT_ATTRIBUTE,
12
- } from "./markup.ts";
2
+
3
+ /**
4
+ * What `AtlasElement` puts on every root: one name, so a project gives every
5
+ * root a display in a single stylesheet rule, and so `element` can say when a
6
+ * root was rendered by hand.
7
+ */
8
+ export const ROOT_ATTRIBUTE = "data-atlas-root";
13
9
 
14
10
  /** What is left to undo when the element leaves; usually nothing. */
15
11
  // biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
16
12
  type Undo = void | (() => void);
17
13
 
18
- /**
19
- * One role's lookups inside this instance. A role with no fields is found for
20
- * the element alone; one with fields comes back with its values read.
21
- */
22
- type Lookups<F extends MarkupFields> = [keyof F] extends [never]
23
- ? {
24
- all<T extends Element = HTMLElement>(): T[];
25
- one<T extends Element = HTMLElement>(): T | null;
26
- require<T extends Element = HTMLElement>(): T;
27
- }
28
- : {
29
- all<T extends Element = HTMLElement>(): Marked<T, F>[];
30
- one<T extends Element = HTMLElement>(): Marked<T, F> | null;
31
- require<T extends Element = HTMLElement>(): Marked<T, F>;
32
- };
33
-
34
- /** Every role of an element, bound to one instance. */
35
- export type ElementRoles<R extends ComponentRoles> = {
36
- readonly [K in keyof R]: Lookups<R[K]>;
37
- };
14
+ export interface ElementContext {
15
+ readonly host: HTMLElement;
16
+ /** Aborted when the element leaves, taking its listeners with it. */
17
+ readonly signal: AbortSignal;
18
+ }
38
19
 
39
20
  /**
40
- * What an element does while it is on the page. `signal` is aborted when it
41
- * leaves, so listeners registered with it come down on their own; anything else
42
- * to undo is the returned function.
21
+ * What an element does while it is on the page. Anything `signal` does not
22
+ * already undo is the returned function.
43
23
  */
44
- export type Connect<R extends ComponentRoles> = (
45
- host: HTMLElement,
46
- roles: ElementRoles<R>,
47
- signal: AbortSignal
48
- ) => Undo;
24
+ export type Connect = (context: ElementContext) => Undo;
25
+
26
+ /** What a template renders the element from, with `AtlasElement`. */
27
+ export interface ElementTag<Tag extends string = string> {
28
+ readonly tag: Tag;
29
+ }
49
30
 
50
31
  /**
51
- * A custom element: its markup contract and its behaviour, in one call.
32
+ * A custom element's behaviour, registered under `tag`.
52
33
  *
53
34
  * ```ts
54
- * // faq.ts — imported by the template, and by the page's script
55
- * export const faq = element("go-faq", { row: { key: { kind: "text" } }, search: marker },
56
- * (host, { row, search }, signal) => { … });
35
+ * // faq.ts — imported by the template for the tag, and by the page's script
36
+ * export const faq = element("go-faq", ({ host, signal }) => {
37
+ * const input = ref<HTMLInputElement>(host, "[data-faq-search]");
38
+ * …
39
+ * });
57
40
  * ```
58
41
  * ```astro
59
42
  * <script src="./faq.ts"></script>
43
+ * <AtlasElement of={faq}><input data-faq-search type="search"></AtlasElement>
60
44
  * ```
61
45
  *
62
46
  * Registers itself when loaded in a browser and does nothing in Node, so the
63
- * template imports the same file for the contract.
47
+ * template imports the same file.
64
48
  *
65
49
  * - **An element can enter the page more than once.** Moving it runs the undo
66
50
  * and `connect` again; Astro's `ClientRouter` does so on every navigation.
67
51
  * State that must survive belongs in a `WeakMap` keyed by `host`.
68
52
  * - **The page's script must stay a bundled module** (a plain `<script>`), so
69
53
  * the element has its children when it upgrades.
70
- * - **A `connect` that throws leaves that one element inert** and reports it.
54
+ * - **Failures show in dev.** A `connect` that throws leaves that one element
55
+ * inert, and it, or any uncaught error on the page, lands in the dev panel.
71
56
  * - **A tag defined twice keeps the first definition** and reports the second.
72
57
  */
73
- export function element<
74
- const Tag extends string,
75
- const R extends ComponentRoles &
76
- CheckedKeys<R> & { readonly [K in Reserved]?: never },
77
- >(
78
- tag: Tag & CheckedTagName<Tag>,
79
- roles: R,
80
- connect: Connect<R>
81
- ): Component<Tag, R> {
82
- // Already checked by this function's own signature.
83
- const contract = component(tag as never, roles) as Component<Tag, R>;
58
+ export function element<const Tag extends string>(
59
+ tag: Tag,
60
+ connect: Connect
61
+ ): ElementTag<Tag> {
84
62
  if (typeof customElements !== "undefined") {
85
- define(contract, roles, connect);
63
+ watchDevErrors();
64
+ // Defining upgrades elements already on the page synchronously; a
65
+ // microtask later, `const x = element(…)` is assigned first.
66
+ queueMicrotask(() => define(tag, connect));
86
67
  }
87
- return contract;
68
+ return { tag };
88
69
  }
89
70
 
90
- /** The shape this file needs off a role, without its field types. */
91
- interface AnyRole {
92
- all(root: ParentNode): Marked<Element, MarkupFields>[];
93
- one(root: ParentNode): Marked<Element, MarkupFields> | null;
94
- require(root: ParentNode): Marked<Element, MarkupFields>;
71
+ let watching = false;
72
+
73
+ /**
74
+ * Puts errors thrown outside `connect` — in a listener, after an `await` — in
75
+ * the dev panel too. Once per page load: the listeners outlive a body swap.
76
+ */
77
+ function watchDevErrors(): void {
78
+ if (!import.meta.env.DEV || watching) return;
79
+ watching = true;
80
+ window.addEventListener("error", (event) => {
81
+ reportDevError("page", event.error ?? event.message);
82
+ });
83
+ window.addEventListener("unhandledrejection", (event) => {
84
+ reportDevError("page", event.reason);
85
+ });
95
86
  }
96
87
 
97
- function define<R extends ComponentRoles>(
98
- contract: Component<string, R>,
99
- roles: R,
100
- connect: Connect<R>
101
- ): void {
102
- const { tag } = contract;
88
+ function define(tag: string, connect: Connect): void {
103
89
  if (customElements.get(tag) !== undefined) {
104
90
  reportDevError(
105
91
  tag,
@@ -108,27 +94,6 @@ function define<R extends ComponentRoles>(
108
94
  return;
109
95
  }
110
96
 
111
- const bind = (host: HTMLElement): ElementRoles<R> => {
112
- const bound: Record<string, unknown> = {};
113
- for (const [name, fields] of Object.entries(roles)) {
114
- const role = (contract as unknown as Record<string, AnyRole>)[name];
115
- if (role === undefined) continue;
116
- bound[name] =
117
- Object.keys(fields).length === 0
118
- ? {
119
- all: () => role.all(host).map((each) => each.element),
120
- one: () => role.one(host)?.element ?? null,
121
- require: () => role.require(host).element,
122
- }
123
- : {
124
- all: () => role.all(host),
125
- one: () => role.one(host),
126
- require: () => role.require(host),
127
- };
128
- }
129
- return bound as ElementRoles<R>;
130
- };
131
-
132
97
  customElements.define(
133
98
  tag,
134
99
  class extends HTMLElement {
@@ -146,11 +111,11 @@ function define<R extends ComponentRoles>(
146
111
  !this.hasAttribute(ROOT_ATTRIBUTE)
147
112
  ) {
148
113
  throw new Error(
149
- `<${tag}> carries no ${ROOT_ATTRIBUTE} — does the template spread {...x.root} on it?`
114
+ `<${tag}> carries no ${ROOT_ATTRIBUTE} — is it rendered with <AtlasElement of={…}>?`
150
115
  );
151
116
  }
152
117
  this.#undo =
153
- connect(this, bind(this), ac.signal) ?? undefined;
118
+ connect({ host: this, signal: ac.signal }) ?? undefined;
154
119
  } catch (error) {
155
120
  reportDevError(this.localName, error);
156
121
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Finding the elements a script works on, by a selector the template wrote as
3
+ * a plain attribute — `[data-faq-search]` — so what the script asks for is what
4
+ * devtools shows.
5
+ */
6
+
7
+ /** Names the root in an error, for a selector that matched nothing. */
8
+ const where = (root: ParentNode): string =>
9
+ "localName" in root && typeof root.localName === "string"
10
+ ? `<${root.localName}>`
11
+ : "the document";
12
+
13
+ /**
14
+ * The first match under `root`, or a thrown error naming the selector. Inside
15
+ * `element`'s `connect`, that error lands in the dev panel.
16
+ */
17
+ export function ref<T extends Element = HTMLElement>(
18
+ root: ParentNode,
19
+ selector: string
20
+ ): T {
21
+ const found = root.querySelector<T>(selector);
22
+ if (found === null) {
23
+ throw new Error(`${where(root)} has nothing matching ${selector}`);
24
+ }
25
+ return found;
26
+ }
27
+
28
+ /**
29
+ * An element's `data-<name>` value, or a thrown error naming the element and
30
+ * the attribute. For an optional one, read `dataset` directly.
31
+ */
32
+ export function data(element: Element, name: string): string {
33
+ const value = element.getAttribute(`data-${name}`);
34
+ if (value === null) {
35
+ throw new Error(`<${element.localName}> has no data-${name}`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ /** Every match under `root`. None is an answer here, not an error. */
41
+ export function refs<T extends Element = HTMLElement>(
42
+ root: ParentNode,
43
+ selector: string
44
+ ): T[] {
45
+ return [...root.querySelectorAll<T>(selector)];
46
+ }