import { WebComponent, custom, type HandledEvent } from '../src/index.ts'; import { dispatch } from './dispatch.ts'; import type { Theme } from './themes.ts'; import css from './main-context.css' with { type: 'css' }; /** Every update components can share through ``: 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}`]?: (value: Updates[K]) => void; }; /** The `wc:register` detail. The context drops the callbacks when `signal` aborts. */ export type Registration = Callbacks & { readonly signal: AbortSignal }; /** A ``'s raw input; the context turns it into an update by `name`. */ export interface FieldInput { readonly name: string; readonly value: string; } /** * Ask the nearest `` 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(el, 'wc:register', { ...callbacks, signal: el.signal }); } /** Share an update; the nearest `` calls every matching `on…` callback. */ export function share(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:` events; both bubble * up to here, and an update is handed to every registered `on`. 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, events: { 'wc:register': custom(), 'wc:field-input': custom(), 'wc:headline': custom(), 'wc:tagline': custom(), 'wc:theme': custom(), 'wc:secret': custom(), 'wc:stars': custom(), }, } as const; export class MainContext extends WebComponent(SPEC) { readonly #registered = new Set(); override handleEvent(event: HandledEvent): 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; } }