DSD, open/closed template, and light-DOM components that talk through <main-context>: children register on… callbacks and share updates as events. Served by Vite via npm run examples; examples/ is linted, formatted and typechecked.
143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
import { WebComponent, custom, type HandledEvent } from '../src/index.ts';
|
|
import { dispatch } from './dispatch.ts';
|
|
import type { Theme } from './themes.ts';
|
|
|
|
/** Every update components can share through `<main-context>`: name → value. */
|
|
export interface Updates {
|
|
headline: string;
|
|
tagline: string;
|
|
theme: Theme;
|
|
secret: string;
|
|
stars: number;
|
|
}
|
|
|
|
/** `onHeadline`, `onTheme`, …: each is called with that update's value. */
|
|
export type Callbacks = {
|
|
readonly [K in keyof Updates as `on${Capitalize<K>}`]?: (value: Updates[K]) => void;
|
|
};
|
|
|
|
/** The `wc:register` detail. The context drops the callbacks when `signal` aborts. */
|
|
export type Registration = Callbacks & { readonly signal: AbortSignal };
|
|
|
|
/** A `<labeled-field>`'s raw input; the context turns it into an update by `name`. */
|
|
export interface FieldInput {
|
|
readonly name: string;
|
|
readonly value: string;
|
|
}
|
|
|
|
/**
|
|
* Ask the nearest `<main-context>` to call `callbacks` on updates. Call it from
|
|
* `onConnect`: the element's `signal` unregisters it on disconnect.
|
|
*/
|
|
export function register(
|
|
el: HTMLElement & { readonly signal: AbortSignal },
|
|
callbacks: Callbacks,
|
|
): void {
|
|
dispatch<Registration>(el, 'wc:register', { ...callbacks, signal: el.signal });
|
|
}
|
|
|
|
/** Share an update; the nearest `<main-context>` calls every matching `on…` callback. */
|
|
export function share<K extends keyof Updates>(el: HTMLElement, name: K, value: Updates[K]): void {
|
|
dispatch(el, `wc:${name}`, value);
|
|
}
|
|
|
|
/**
|
|
* The shared ancestor siblings talk through. Children register callbacks with
|
|
* a `wc:register` event and share updates with `wc:<name>` events; both bubble
|
|
* up to here, and an update is handed to every registered `on<Name>`. Neither
|
|
* side knows about the other, only about this element.
|
|
*
|
|
* It must be defined before the elements that register, or their
|
|
* `wc:register` fires before anything is listening.
|
|
*/
|
|
const SPEC = {
|
|
// Light DOM with no template: a pure wrapper that doesn't affect layout.
|
|
css: 'main-context { display: contents; }',
|
|
events: {
|
|
'wc:register': custom<Registration>(),
|
|
'wc:field-input': custom<FieldInput>(),
|
|
'wc:headline': custom<Updates['headline']>(),
|
|
'wc:tagline': custom<Updates['tagline']>(),
|
|
'wc:theme': custom<Updates['theme']>(),
|
|
'wc:secret': custom<Updates['secret']>(),
|
|
'wc:stars': custom<Updates['stars']>(),
|
|
},
|
|
} as const;
|
|
|
|
export class MainContext extends WebComponent(SPEC) {
|
|
readonly #registered = new Set<Registration>();
|
|
|
|
override handleEvent(event: HandledEvent<typeof SPEC>): void {
|
|
// The nearest context owns the event; an outer one never sees it.
|
|
event.stopPropagation();
|
|
switch (event.type) {
|
|
case 'wc:register': {
|
|
this.#register(event.detail);
|
|
break;
|
|
}
|
|
case 'wc:field-input': {
|
|
this.#fieldInput(event.detail);
|
|
break;
|
|
}
|
|
case 'wc:headline': {
|
|
for (const r of this.#registered) r.onHeadline?.(event.detail);
|
|
break;
|
|
}
|
|
case 'wc:tagline': {
|
|
for (const r of this.#registered) r.onTagline?.(event.detail);
|
|
break;
|
|
}
|
|
case 'wc:theme': {
|
|
for (const r of this.#registered) r.onTheme?.(event.detail);
|
|
break;
|
|
}
|
|
case 'wc:secret': {
|
|
for (const r of this.#registered) r.onSecret?.(event.detail);
|
|
break;
|
|
}
|
|
case 'wc:stars': {
|
|
for (const r of this.#registered) r.onStars?.(event.detail);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
#register(registration: Registration): void {
|
|
this.#registered.add(registration);
|
|
registration.signal.addEventListener(
|
|
'abort',
|
|
() => {
|
|
this.#registered.delete(registration);
|
|
},
|
|
{ once: true, signal: this.signal },
|
|
);
|
|
}
|
|
|
|
// Fields are generic inputs, so the context decides what each one means:
|
|
// it re-shares them as typed updates from itself, which comes back through
|
|
// `handleEvent` like any other update.
|
|
#fieldInput({ name, value }: FieldInput): void {
|
|
switch (name) {
|
|
case 'headline':
|
|
case 'tagline':
|
|
case 'secret': {
|
|
share(this, name, value);
|
|
break;
|
|
}
|
|
case 'stars': {
|
|
const stars = Number(value);
|
|
if (Number.isInteger(stars) && stars >= 1 && stars <= 10) share(this, 'stars', stars);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('main-context', MainContext);
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'main-context': MainContext;
|
|
}
|
|
}
|