@escape-game-over/atlas 0.1.17 → 0.1.18

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,53 @@ 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
+
474
+ `tag` and `define` are the component's own keys, so no role may take them. A role
475
+ that lives outside every instance, such as a footer button that reopens a
476
+ banner, is still plain `markup`.
477
+
431
478
  ## `youtube`
432
479
 
433
480
  A YouTube video that loads nothing from YouTube until a reader asks for it. The
@@ -565,6 +612,10 @@ right properties is a faithful stand-in.
565
612
  root that answers bare attribute selectors, and round-trips every field kind
566
613
  through `attrs` and `read`. `type-tests/markup.ts` pins the half that is a
567
614
  compile error.
615
+ - `tests/component.test.ts` builds a small element tree with parents and
616
+ `closest`, and pins the scoping: a nested instance, two side by side, a root
617
+ inside the instance, and a broken nested element that must not be read.
618
+ `type-tests/component.ts` pins the role types and the reserved keys.
568
619
 
569
620
  `carousel`, `consent`, `element` and `dom` have none. They need a real DOM and
570
621
  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.18",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -430,3 +430,145 @@ export function markup<const F extends MarkupFields = Record<never, never>>(
430
430
  },
431
431
  };
432
432
  }
433
+
434
+ /** A component's roles, by name: each is the fields of one `markup` role. */
435
+ export type ComponentRoles = Readonly<Record<string, MarkupFields>>;
436
+
437
+ /** Keys the component object uses itself, so no role may take them. */
438
+ type Reserved = "tag" | "define";
439
+
440
+ export type Component<Tag extends string, R extends ComponentRoles> = {
441
+ /** The custom element's name: what the template renders. */
442
+ readonly tag: Tag;
443
+ /** Registers the element's class under `tag`. */
444
+ define(element: CustomElementConstructor): void;
445
+ } & { readonly [K in keyof R]: Markup<R[K]> };
446
+
447
+ /** A custom element name, as far as a pattern can check it: needs a hyphen. */
448
+ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
449
+
450
+ /**
451
+ * A custom element and the roles inside it, with lookups scoped to one
452
+ * instance.
453
+ *
454
+ * ```ts
455
+ * export const faq = component("go-faq", {
456
+ * row: { key: { kind: "text" }, text: { kind: "text" } },
457
+ * search: {},
458
+ * });
459
+ *
460
+ * // template: <faq.tag> … <details {...faq.row.attrs({ key, text })}>
461
+ * // script: faq.row.all(this); faq.define(Faq);
462
+ * ```
463
+ *
464
+ * Two things `markup` alone leaves to the project:
465
+ *
466
+ * - **Names.** Every attribute is `data-<tag>-<role>[-<field>]`. The browser
467
+ * refuses to define one tag twice, so two components cannot share an
468
+ * attribute, and clashes inside one component are refused here.
469
+ * - **Scope.** A lookup returns only elements owned by the same instance as
470
+ * its root — the owner being the nearest ancestor with this tag. A nested
471
+ * instance keeps its elements to itself, and the root may be any element
472
+ * inside the instance, not only the instance itself.
473
+ *
474
+ * Roles outside any instance, like a footer button that reopens a banner, are
475
+ * what plain `markup` is still for.
476
+ */
477
+ export function component<
478
+ const Tag extends string,
479
+ const R extends ComponentRoles & { readonly [K in Reserved]?: never },
480
+ >(tag: Tag, roles: R): Component<Tag, R> {
481
+ if (!TAG.test(tag)) {
482
+ throw new Error(
483
+ `component(${show(tag)}): a custom element name is lowercase words with at least one hyphen, as in "go-faq"`
484
+ );
485
+ }
486
+
487
+ // Every attribute the component writes, so two that coincide are refused
488
+ // rather than left to overwrite each other.
489
+ const claimed = new Map<string, string>();
490
+ const claim = (attribute: string, by: string): void => {
491
+ const earlier = claimed.get(attribute);
492
+ if (earlier !== undefined) {
493
+ throw new Error(
494
+ `component(${show(tag)}): ${by} and ${earlier} would both write ${attribute}`
495
+ );
496
+ }
497
+ claimed.set(attribute, by);
498
+ };
499
+
500
+ /** The instance a root belongs to; `null` for the document. */
501
+ const ownerOf = (root: ParentNode): Element | null =>
502
+ "closest" in root && typeof root.closest === "function"
503
+ ? root.closest(tag)
504
+ : null;
505
+
506
+ const scoped = (
507
+ role: string,
508
+ fields: MarkupFields
509
+ ): Markup<MarkupFields> => {
510
+ if (role === "tag" || role === "define") {
511
+ throw new Error(
512
+ `component(${show(tag)}): ${show(role)} is taken by the component itself`
513
+ );
514
+ }
515
+ if (!FIELD.test(role)) {
516
+ throw new Error(
517
+ `component(${show(tag)}): the role ${show(role)} must be camelCase — it becomes part of the attribute`
518
+ );
519
+ }
520
+
521
+ const base = markup(`${tag}-${hyphenate(role)}`, fields);
522
+ claim(base.selector.slice(1, -1), `the role ${show(role)}`);
523
+ for (const field of Object.keys(fields)) {
524
+ claim(base.attribute(field), `${role}.${field}`);
525
+ }
526
+
527
+ // Filtered before anything is read, so a malformed element in a nested
528
+ // instance cannot fail a lookup that was never going to return it.
529
+ const mine = <T extends Element>(root: ParentNode): T[] => {
530
+ const owner = ownerOf(root);
531
+ return [...root.querySelectorAll<T>(base.selector)].filter(
532
+ (element) => element.closest(tag) === owner
533
+ );
534
+ };
535
+
536
+ const one = <T extends Element = HTMLElement>(
537
+ root: ParentNode
538
+ ): Marked<T, MarkupFields> | null => {
539
+ const [found] = mine<T>(root);
540
+ return found === undefined
541
+ ? null
542
+ : { element: found, values: base.read(found) };
543
+ };
544
+
545
+ return {
546
+ ...base,
547
+ all: <T extends Element = HTMLElement>(root: ParentNode) =>
548
+ mine<T>(root).map((element) => ({
549
+ element,
550
+ values: base.read(element),
551
+ })),
552
+ one,
553
+ require<T extends Element = HTMLElement>(root: ParentNode) {
554
+ const found = one<T>(root);
555
+ if (found === null) {
556
+ throw new Error(
557
+ `nothing in this <${tag}> carries ${base.selector.slice(1, -1)} — does the template spread its attrs inside the element?`
558
+ );
559
+ }
560
+ return found;
561
+ },
562
+ };
563
+ };
564
+
565
+ const built: Record<string, unknown> = {
566
+ tag,
567
+ define: (element: CustomElementConstructor) =>
568
+ customElements.define(tag, element),
569
+ };
570
+ for (const [role, fields] of Object.entries(roles)) {
571
+ built[role] = scoped(role, fields);
572
+ }
573
+ return built as Component<Tag, R>;
574
+ }