@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.
- package/docs/client-scripts.md +51 -0
- package/package.json +1 -1
- package/src/astro/markup.ts +142 -0
package/docs/client-scripts.md
CHANGED
|
@@ -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
package/src/astro/markup.ts
CHANGED
|
@@ -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
|
+
}
|