@escape-game-over/atlas 0.1.25 → 0.1.27

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,71 +1,88 @@
1
1
  ---
2
2
  /**
3
3
  * Renders what `site.rich()` returns. The site supplies the look: a class per
4
- * `[v:name]` variant and one for links; everything else is decided here.
4
+ * `[v:name]` variant and one for links; everything else is decided here. Only
5
+ * the runs — the caller writes the element they sit in.
5
6
  *
6
7
  * ```astro
7
- * <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} />
8
+ * <p class="…">
9
+ * <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} />
10
+ * </p>
8
11
  * ```
9
12
  */
10
- import type { HTMLTag } from "astro/types";
11
13
  import type { RichText } from "../content/index.ts";
12
14
  import { warn } from "../warn.ts";
13
15
 
14
16
  interface Props {
15
17
  readonly spans: RichText;
16
- /** The element wrapping the runs. Defaults to `p`. */
17
- readonly as?: HTMLTag;
18
- readonly class?: string;
19
18
  /** `[v:name]` → classes. */
20
19
  readonly variants?: Readonly<Record<string, string>>;
20
+ /** `[v:name]` → attributes, so a script can find that run. */
21
+ readonly attrs?: Readonly<Record<string, Readonly<Record<string, string>>>>;
21
22
  /** Classes on every link: `[a:]`, `[mail]` and `[tel]`. */
22
23
  readonly link?: string;
23
24
  }
24
25
 
25
- const {
26
- spans,
27
- as: Tag = "p",
28
- class: className,
29
- variants = {},
30
- link,
31
- }: Props = Astro.props;
26
+ const { spans, variants = {}, attrs = {}, link }: Props = Astro.props;
32
27
 
33
28
  // The words still render: losing the sentence is worse than losing its style.
29
+ // A run with attributes and no class is a script's hook, not a missing style.
34
30
  const variantClass = (variant: string): string | undefined => {
35
- if (!Object.hasOwn(variants, variant)) {
31
+ if (!Object.hasOwn(variants, variant) && !Object.hasOwn(attrs, variant)) {
36
32
  warn(
37
33
  Astro.url.pathname,
38
- `[v:${variant}] has no class in RichText's variants.`
34
+ `[v:${variant}] has no class in RichText's variants and no attrs.`
39
35
  );
40
36
  }
41
- return variants[variant];
37
+ return Object.hasOwn(variants, variant) ? variants[variant] : undefined;
42
38
  };
39
+
40
+ const variantAttrs = (variant: string) =>
41
+ Object.hasOwn(attrs, variant) ? attrs[variant] : undefined;
43
42
  ---
44
43
 
45
- <Tag class={className}>
46
- {spans.map((span) => {
47
- switch (span.kind) {
48
- case "text":
49
- return span.text;
50
- case "bold":
51
- return <strong>{span.text}</strong>;
52
- case "variant":
53
- return <span class={variantClass(span.variant)}>{span.text}</span>;
54
- case "break":
55
- return <br />;
56
- case "link":
57
- return span.to === "external" ? (
58
- <a href={span.href} class={link} target="_blank" rel="noopener noreferrer">
59
- {span.text}
60
- </a>
61
- ) : (
62
- <a href={span.href} class={link}>{span.text}</a>
63
- );
64
- case "mail":
65
- case "tel":
66
- return <a href={span.href} class={link}>{span.text}</a>;
67
- default:
68
- return span satisfies never;
69
- }
70
- })}
71
- </Tag>
44
+ {spans.map((span) => {
45
+ switch (span.kind) {
46
+ case "text":
47
+ return span.text;
48
+ case "bold":
49
+ return <strong>{span.text}</strong>;
50
+ case "variant":
51
+ return (
52
+ <span
53
+ {...variantAttrs(span.variant)}
54
+ class={variantClass(span.variant)}>
55
+ {span.text}
56
+ </span>
57
+ );
58
+ case "break":
59
+ return <br />;
60
+ case "link":
61
+ return span.to === "external" ? (
62
+ <a
63
+ href={span.href}
64
+ class={link}
65
+ target='_blank'
66
+ rel='noopener noreferrer'>
67
+ {span.text}
68
+ </a>
69
+ ) : (
70
+ <a
71
+ href={span.href}
72
+ class={link}>
73
+ {span.text}
74
+ </a>
75
+ );
76
+ case "mail":
77
+ case "tel":
78
+ return (
79
+ <a
80
+ href={span.href}
81
+ class={link}>
82
+ {span.text}
83
+ </a>
84
+ );
85
+ default:
86
+ return span satisfies never;
87
+ }
88
+ })}
@@ -20,14 +20,13 @@ export {
20
20
  consentApplies,
21
21
  consentStore,
22
22
  } from "./consent.ts";
23
- export { type Connect, type ElementRoles, element } from "./element.ts";
23
+ export {
24
+ type Connect,
25
+ type ElementContext,
26
+ type ElementTag,
27
+ element,
28
+ } from "./element.ts";
24
29
  export * from "./filters.ts";
25
30
  export * from "./filters-view.ts";
26
- export {
27
- type Component,
28
- type Marked,
29
- type Markup,
30
- marker,
31
- markup,
32
- } from "./markup.ts";
31
+ export { data, ref, refs } from "./ref.ts";
33
32
  export * from "./youtube.ts";
@@ -1,12 +1,12 @@
1
1
  import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
2
- import { component, markup } from "./markup.ts";
2
+ import type { ElementTag } from "./element.ts";
3
3
 
4
- /** Internal to `ConsentBanner.astro`: the element and the footer control. */
5
- export const CONSENT_ROLES = {
6
- answer: { choice: { kind: "choice", of: ["granted", "denied"] } },
7
- } as const;
8
- export const consentBanner = component("atlas-consent", CONSENT_ROLES);
9
- export const consentReopen = markup("atlas-consent-reopen");
4
+ /** Internal to `ConsentBanner.astro`: the element, and what its script finds. */
5
+ export const consentBanner: ElementTag<"atlas-consent"> = {
6
+ tag: "atlas-consent",
7
+ };
8
+ export const CONSENT_ANSWER = "data-atlas-consent-answer";
9
+ export const CONSENT_REOPEN = "data-atlas-consent-reopen";
10
10
 
11
11
  /**
12
12
  * What a site's consent markup spreads: the two answer buttons inside
@@ -15,9 +15,9 @@ export const consentReopen = markup("atlas-consent-reopen");
15
15
  * withdraw.
16
16
  */
17
17
  export const consent = {
18
- accept: consentBanner.answer.attrs({ choice: "granted" }),
19
- decline: consentBanner.answer.attrs({ choice: "denied" }),
20
- reopen: { ...consentReopen.attrs(), hidden: "" },
18
+ accept: { [CONSENT_ANSWER]: "granted" },
19
+ decline: { [CONSENT_ANSWER]: "denied" },
20
+ reopen: { [CONSENT_REOPEN]: "", hidden: "" },
21
21
  };
22
22
 
23
23
  /**
@@ -1,105 +1,92 @@
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
+ /** The element itself: where `ref` and `refs` search from. */
16
+ readonly root: HTMLElement;
17
+ /** Aborted when the element leaves, taking its listeners with it. */
18
+ readonly signal: AbortSignal;
19
+ }
38
20
 
39
21
  /**
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.
22
+ * What an element does while it is on the page. Anything `signal` does not
23
+ * already undo is the returned function.
43
24
  */
44
- export type Connect<R extends ComponentRoles> = (
45
- host: HTMLElement,
46
- roles: ElementRoles<R>,
47
- signal: AbortSignal
48
- ) => Undo;
25
+ export type Connect = (context: ElementContext) => Undo;
26
+
27
+ /** What a template renders the element from, with `AtlasElement`. */
28
+ export interface ElementTag<Tag extends string = string> {
29
+ readonly tag: Tag;
30
+ }
49
31
 
50
32
  /**
51
- * A custom element: its markup contract and its behaviour, in one call.
33
+ * A custom element's behaviour, registered under `tag`.
52
34
  *
53
35
  * ```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) => { … });
36
+ * // faq.ts — imported by the template for the tag, and by the page's script
37
+ * export const faq = element("go-faq", ({ root, signal }) => {
38
+ * const input = ref<HTMLInputElement>(root, "[data-faq-search]");
39
+ * …
40
+ * });
57
41
  * ```
58
42
  * ```astro
59
43
  * <script src="./faq.ts"></script>
44
+ * <AtlasElement of={faq}><input data-faq-search type="search"></AtlasElement>
60
45
  * ```
61
46
  *
62
47
  * Registers itself when loaded in a browser and does nothing in Node, so the
63
- * template imports the same file for the contract.
48
+ * template imports the same file.
64
49
  *
65
50
  * - **An element can enter the page more than once.** Moving it runs the undo
66
51
  * and `connect` again; Astro's `ClientRouter` does so on every navigation.
67
- * State that must survive belongs in a `WeakMap` keyed by `host`.
52
+ * State that must survive belongs in a `WeakMap` keyed by `root`.
68
53
  * - **The page's script must stay a bundled module** (a plain `<script>`), so
69
54
  * the element has its children when it upgrades.
70
- * - **A `connect` that throws leaves that one element inert** and reports it.
55
+ * - **Failures show in dev.** A `connect` that throws leaves that one element
56
+ * inert, and it, or any uncaught error on the page, lands in the dev panel.
71
57
  * - **A tag defined twice keeps the first definition** and reports the second.
72
58
  */
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>;
59
+ export function element<const Tag extends string>(
60
+ tag: Tag,
61
+ connect: Connect
62
+ ): ElementTag<Tag> {
84
63
  if (typeof customElements !== "undefined") {
85
- define(contract, roles, connect);
64
+ watchDevErrors();
65
+ // Defining upgrades elements already on the page synchronously; a
66
+ // microtask later, `const x = element(…)` is assigned first.
67
+ queueMicrotask(() => define(tag, connect));
86
68
  }
87
- return contract;
69
+ return { tag };
88
70
  }
89
71
 
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>;
72
+ let watching = false;
73
+
74
+ /**
75
+ * Puts errors thrown outside `connect` — in a listener, after an `await` — in
76
+ * the dev panel too. Once per page load: the listeners outlive a body swap.
77
+ */
78
+ function watchDevErrors(): void {
79
+ if (!import.meta.env.DEV || watching) return;
80
+ watching = true;
81
+ window.addEventListener("error", (event) => {
82
+ reportDevError("page", event.error ?? event.message);
83
+ });
84
+ window.addEventListener("unhandledrejection", (event) => {
85
+ reportDevError("page", event.reason);
86
+ });
95
87
  }
96
88
 
97
- function define<R extends ComponentRoles>(
98
- contract: Component<string, R>,
99
- roles: R,
100
- connect: Connect<R>
101
- ): void {
102
- const { tag } = contract;
89
+ function define(tag: string, connect: Connect): void {
103
90
  if (customElements.get(tag) !== undefined) {
104
91
  reportDevError(
105
92
  tag,
@@ -108,27 +95,6 @@ function define<R extends ComponentRoles>(
108
95
  return;
109
96
  }
110
97
 
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
98
  customElements.define(
133
99
  tag,
134
100
  class extends HTMLElement {
@@ -146,11 +112,11 @@ function define<R extends ComponentRoles>(
146
112
  !this.hasAttribute(ROOT_ATTRIBUTE)
147
113
  ) {
148
114
  throw new Error(
149
- `<${tag}> carries no ${ROOT_ATTRIBUTE} — does the template spread {...x.root} on it?`
115
+ `<${tag}> carries no ${ROOT_ATTRIBUTE} — is it rendered with <AtlasElement of={…}>?`
150
116
  );
151
117
  }
152
118
  this.#undo =
153
- connect(this, bind(this), ac.signal) ?? undefined;
119
+ connect({ root: this, signal: ac.signal }) ?? undefined;
154
120
  } catch (error) {
155
121
  reportDevError(this.localName, error);
156
122
  }
@@ -10,21 +10,18 @@
10
10
  * on a clear button, an empty message or a section with nothing left. No class,
11
11
  * style or `aria`: that is where the choices live.
12
12
  *
13
- * Two notes for the markup:
14
- *
15
- * - **`hidden` needs help in a grid.** Any author rule setting `display` beats
16
- * it. Tailwind v4's preflight ships `[hidden] { display: none !important }`;
17
- * without something like it these writes are inert and nothing filters.
18
- * - **A result count wants `aria-live="polite"`**, in the template.
13
+ * **`hidden` needs help in a grid.** Any author rule setting `display` beats it.
14
+ * Tailwind v4's preflight ships `[hidden] { display: none !important }`; without
15
+ * something like it these writes are inert and nothing filters.
19
16
  *
20
17
  * See docs/client-scripts.md.
21
18
  */
22
19
 
23
- import type { FieldMap, FilterChange, Filters } from "./filters.ts";
20
+ import type { FieldMap, FilterChange, Filters, KindOf } from "./filters.ts";
24
21
 
25
22
  /** The `text` fields of `F`, so a search box cannot be pointed at a flag. */
26
23
  export type TextFieldOf<F extends FieldMap> = {
27
- [K in keyof F]: F[K] extends { kind: "text" } ? K : never;
24
+ [K in keyof F]: KindOf<F[K]> extends "text" ? K : never;
28
25
  }[keyof F];
29
26
 
30
27
  /**
@@ -38,12 +35,6 @@ export interface SearchBoxElements {
38
35
  readonly clear?: HTMLElement | null;
39
36
  /** The "nothing matched" message. Tracks the whole result set. */
40
37
  readonly empty?: HTMLElement | null;
41
- /**
42
- * How many matched, as digits and nothing else: the sentence around them is
43
- * translated, and not something to take apart in a browser. Give the count
44
- * its own element and let the copy sit beside it.
45
- */
46
- readonly count?: HTMLElement | null;
47
38
  }
48
39
 
49
40
  /**
@@ -54,20 +45,20 @@ export interface SearchBoxElements {
54
45
  * ```ts
55
46
  * const list = filters({ fields, items, onChange: … });
56
47
  * const detach = list.attach();
57
- * const unbind = searchBox(list, "q", { input, clear, empty, count });
48
+ * const unbind = searchBox(list, "q", { input, clear, empty });
58
49
  * ```
59
50
  */
60
- export function searchBox<F extends FieldMap>(
61
- list: Filters<F>,
51
+ export function searchBox<F extends FieldMap, T>(
52
+ list: Filters<F, T>,
62
53
  field: TextFieldOf<F>,
63
54
  elements: SearchBoxElements
64
55
  ): () => void {
65
- const { input, clear, empty, count } = elements;
56
+ const { input, clear, empty } = elements;
66
57
  // The narrowing `TextFieldOf` already did: inside a generic function
67
58
  // TypeScript cannot see that this field holds a string.
68
59
  const name = field as Parameters<typeof list.set>[0];
69
60
 
70
- const render = ({ state, matched }: FilterChange<F>): void => {
61
+ const render = ({ state, matched }: FilterChange<F, T>): void => {
71
62
  const query = String(state[name]);
72
63
  // Guarded by inequality: assigning `value` while someone types moves
73
64
  // the caret to the end, and this exists for state that moved without
@@ -75,7 +66,6 @@ export function searchBox<F extends FieldMap>(
75
66
  if (input != null && input.value !== query) input.value = query;
76
67
  if (clear != null) clear.hidden = query === "";
77
68
  if (empty != null) empty.hidden = matched.size > 0;
78
- if (count != null) count.textContent = String(matched.size);
79
69
  };
80
70
 
81
71
  render(list);