wc-mixin/examples/main-context.ts
Buddy 8865b49f4e Add examples page with a context manager
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.
2026-11-10 09:59:09 -08:00

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;
}
}