Add typed events map: true, listener options, listen<E>(), or custom<T>()

events maps a name to true, listener options, listen<E>(options?) for an
explicit event type, or custom<T>(options?) for a CustomEvent<T> (the same as
listen<CustomEvent<T>>()). Otherwise an event is typed from HTMLElementEventMap
by name. The array form is gone. handleEvent gets HandledEvent<S>, each event
tagged with its literal type so event.type narrows.
This commit is contained in:
Buddy 2026-11-10 09:59:09 -08:00
parent 6ece00c8cc
commit be8ee0198f
7 changed files with 226 additions and 19 deletions

View File

@ -9,7 +9,7 @@ const SPEC = {
shadow: 'open', // omit for light DOM shadow: 'open', // omit for light DOM
template: '<input class="name"><button>Go</button>', template: '<input class="name"><button>Go</button>',
css: ':host { display: block; }', css: ':host { display: block; }',
events: ['click'], events: { click: true },
element: { element: {
input: 'input.name', // refs.input: HTMLInputElement | null (from the tag) input: 'input.name', // refs.input: HTMLInputElement | null (from the tag)
thing: '.thing', // refs.thing: Element | null (no tag to go on) thing: '.thing', // refs.thing: Element | null (no tag to go on)
@ -47,6 +47,7 @@ ExampleElement.observedAttributes; // readonly ('user-id' | 'min' | 'is-ready' |
- **Light DOM**: `template` is appended on first connect; `css` is added once per element type, as a `<style>` in `<head>` or adopted into the enclosing shadow root. - **Light DOM**: `template` is appended on first connect; `css` is added once per element type, as a `<style>` in `<head>` or adopted into the enclosing shadow root.
- **Refs**: a selector string is typed from its tag (last compound: `'form > button#go'` → `HTMLButtonElement`), falling back to `Element`. `[selector, Class]` types the ref as `Class`; reading it throws a `TypeError` if a match isn't an instance of `Class`. A key can't be in both `element` and `elements`. - **Refs**: a selector string is typed from its tag (last compound: `'form > button#go'` → `HTMLButtonElement`), falling back to `Element`. `[selector, Class]` types the ref as `Class`; reading it throws a `TypeError` if a match isn't an instance of `Class`. A key can't be in both `element` and `elements`.
- **`signal`** aborts on disconnect; a new one is created on each connect. `events` are listened on the host with it. - **`signal`** aborts on disconnect; a new one is created on each connect. `events` are listened on the host with it.
- **Events**: `events` maps a name to `true`, listener options, `listen<E>(options?)` for an explicit event type, or `custom<T>(options?)` for a `CustomEvent<T>` (the same as `listen<CustomEvent<T>>()`): `{ click: true, scroll: { passive: true }, 'wc:key': listen<KeyboardEvent>(), 'wc:pick': custom<number>() }`. Other names are typed from `HTMLElementEventMap` (`click` → `PointerEvent`), else `Event`. `handleEvent` receives `HandledEvent<typeof SPEC>`, a union whose `type` is literal, so `if (event.type === 'wc:pick') event.detail` narrows. A host listener can't see into a closed shadow root, so `composedPath()` stops at the host there.
- **`base`** extends another autonomous element class; prop names that collide with its members (or `HTMLElement`'s, like `title`/`hidden`) are type errors. - **`base`** extends another autonomous element class; prop names that collide with its members (or `HTMLElement`'s, like `title`/`hidden`) are type errors.
- Don't declare a subclass field with a prop's name; it would shadow the reflected accessor. - Don't declare a subclass field with a prop's name; it would shadow the reflected accessor.

111
src/events.ts Normal file
View File

@ -0,0 +1,111 @@
import type { Spec } from './types.ts';
/** Listener options for a host event. `signal` is the element's own and can't be set. */
export type EventOptions = Omit<AddEventListenerOptions, 'signal'>;
/**
* An event with an explicit type, made by {@link listen} or {@link custom}.
* `E` is type-only: it's what `handleEvent` receives for this entry; `never`
* means "type it from the name", like `true`.
*/
export interface EventDef<E extends Event = Event> {
readonly options: EventOptions | undefined;
/** Type-only carrier for `E`; never set at runtime. */
readonly event?: E;
}
/**
* One entry in a spec's `events` map:
*
* - `true` — listen; the event is typed from its name (`click` →
* `PointerEvent`), or `Event` for a name `HTMLElementEventMap` doesn't know.
* - `{ passive: true }` — the same, with listener options.
* - `listen<E>(options?)` — an event of type `E`, for a name
* `HTMLElementEventMap` doesn't know (or doesn't type the way it's dispatched).
* - `custom<T>(options?)` — a `CustomEvent<T>`; short for `listen<CustomEvent<T>>()`.
*/
export type EventEntry = true | EventOptions | EventDef;
/**
* An event of type `E`, for a spec's `events` map. Without a type argument
* it's the same as `true` (or the options object on its own):
*
* ```ts
* events: {
* 'wc:key': listen<KeyboardEvent>(),
* 'wc:pick': listen<CustomEvent<number>>({ capture: true }),
* }
* ```
*/
// `E` is only carried in the return type, for `handleEvent`; that's its purpose.
// `NoInfer` keeps the spec's contextual `EventDef<Event>` from inferring
// `E = Event`, so an untyped `listen()` stays `never` and is typed from its name.
// oxlint-disable-next-line no-unnecessary-type-parameters
export function listen<E extends Event = never>(options?: EventOptions): EventDef<NoInfer<E>> {
return { options };
}
/**
* A `CustomEvent` whose `detail` is `T`; the same as `listen<CustomEvent<T>>()`:
*
* ```ts
* events: {
* click: true,
* scroll: { passive: true },
* 'wc:pick': custom<number>(),
* }
* ```
*/
// oxlint-disable-next-line no-unnecessary-type-parameters
export function custom<T>(options?: EventOptions): EventDef<CustomEvent<T>> {
return listen<CustomEvent<T>>(options);
}
// ---------------------------------------------------------------------------
// Types pulled out of a spec
/** The built-in type for an event name, or `Event` for a name `HTMLElementEventMap` doesn't know. */
type NamedEvent<N extends string> = N extends keyof HTMLElementEventMap
? HTMLElementEventMap[N]
: Event;
/** The event one `events` entry delivers: its explicit type, else the one its name gives. */
export type EventOf<N extends string, D> =
D extends EventDef<infer E> ? ([E] extends [never] ? NamedEvent<N> : E) : NamedEvent<N>;
/** No events given: an empty map, so `HandledEvent` is `never`. Empty on purpose. */
// oxlint-disable-next-line no-generated-empty-object-type
type NoEvents = Record<never, never>;
/** Event name → event type for a spec. */
export type EventMapOf<S> = S extends {
readonly events: infer M extends Readonly<Record<string, EventEntry>>;
}
? { [K in keyof M & string]: EventOf<K, M[K]> }
: NoEvents;
/**
* What `handleEvent` receives: a union of the listed events, each tagged with
* its literal `type` so `switch (event.type)` narrows to that event (`type` is
* plain `string` on every DOM event otherwise).
*/
export type HandledEvent<S> = {
[K in keyof EventMapOf<S>]: EventMapOf<S>[K] & { readonly type: K };
}[keyof EventMapOf<S>];
// ---------------------------------------------------------------------------
// Runtime
function isEventDef(entry: EventOptions | EventDef): entry is EventDef {
return 'options' in entry;
}
/** `events` as `[type, options]` pairs, for `addEventListener`. */
export function resolveEvents(
events: Spec['events'],
): [type: string, options: EventOptions | undefined][] {
return Object.entries(events ?? {}).map(([type, entry]) => {
if (entry === true) return [type, undefined];
return [type, isEventDef(entry) ? entry.options : entry];
});
}

View File

@ -1,4 +1,14 @@
export { WebComponent } from './web-component.ts'; export { WebComponent } from './web-component.ts';
export {
custom,
listen,
type EventDef,
type EventEntry,
type EventMapOf,
type EventOf,
type EventOptions,
type HandledEvent,
} from './events.ts';
export { export {
attributeName, attributeName,
choice, choice,

View File

@ -1,3 +1,4 @@
import type { EventEntry, HandledEvent } from './events.ts';
import type { AnyPropDef, Kebab } from './props.ts'; import type { AnyPropDef, Kebab } from './props.ts';
/** A constructor {@link WebComponent} can extend: `HTMLElement` or any autonomous subclass of it. */ /** A constructor {@link WebComponent} can extend: `HTMLElement` or any autonomous subclass of it. */
@ -16,8 +17,11 @@ export interface Spec {
readonly css?: string; readonly css?: string;
/** Prefix every *derived* attribute name with `data-`. Explicit `attribute`s are used verbatim. */ /** Prefix every *derived* attribute name with `data-`. Explicit `attribute`s are used verbatim. */
readonly dataPrefix?: boolean; readonly dataPrefix?: boolean;
/** Host events routed to `handleEvent`, listened with the element's `signal`. */ /**
readonly events?: readonly (keyof HTMLElementEventMap)[]; * Host events routed to `handleEvent`, listened with the element's `signal`:
* name → `true`, listener options, {@link custom} for a typed `CustomEvent`, or {@link listen}.
*/
readonly events?: Readonly<Record<string, EventEntry>>;
/** `refs.<name>`: the first match in `root`, or `null`. See {@link RefSpec}. */ /** `refs.<name>`: the first match in `root`, or `null`. See {@link RefSpec}. */
readonly element?: Readonly<Record<string, RefSpec>>; readonly element?: Readonly<Record<string, RefSpec>>;
/** `refs.<name>`: every match in `root`, as an array. See {@link RefSpec}. */ /** `refs.<name>`: every match in `root`, as an array. See {@link RefSpec}. */
@ -42,11 +46,7 @@ export type BaseOf<S> = S extends { readonly base: infer B extends ElementConstr
? B ? B
: typeof HTMLElement; : typeof HTMLElement;
export type DataPrefixOf<S> = S extends { readonly dataPrefix: true } ? true : false; export type DataPrefixOf<S> = S extends { readonly dataPrefix: true } ? true : false;
export type EventsOf<S> = S extends {
readonly events: readonly (infer E extends keyof HTMLElementEventMap)[];
}
? E
: never;
type ElementRefsOf<S> = S extends { type ElementRefsOf<S> = S extends {
readonly element: infer E extends Readonly<Record<string, RefSpec>>; readonly element: infer E extends Readonly<Record<string, RefSpec>>;
} }
@ -183,10 +183,8 @@ export interface WebComponentMembers<S> {
// Re-mapped inline (not `Refs<S>` directly) so editors show the resolved // Re-mapped inline (not `Refs<S>` directly) so editors show the resolved
// `{ input: HTMLInputElement | null; ... }` rather than the alias. // `{ input: HTMLInputElement | null; ... }` rather than the alias.
readonly refs: { readonly [K in keyof Refs<S>]: Refs<S>[K] }; readonly refs: { readonly [K in keyof Refs<S>]: Refs<S>[K] };
/** Receives every event listed in `events`. */ /** Receives every event listed in `events`; narrow it with `event.type`. */
handleEvent( handleEvent(event: [HandledEvent<S>] extends [never] ? Event : HandledEvent<S>): void;
event: [EventsOf<S>] extends [never] ? Event : HTMLElementEventMap[EventsOf<S>],
): void;
/** Called at the end of `connectedCallback`. */ /** Called at the end of `connectedCallback`. */
onConnect(): void; onConnect(): void;
/** Called at the end of `disconnectedCallback`, after `signal` aborts. */ /** Called at the end of `disconnectedCallback`, after `signal` aborts. */

View File

@ -1,3 +1,4 @@
import { resolveEvents } from './events.ts';
import { attributeName, type AnyPropDef } from './props.ts'; import { attributeName, type AnyPropDef } from './props.ts';
import type { import type {
ElementClass, ElementClass,
@ -47,7 +48,7 @@ export function WebComponent<const S extends Spec>(
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop])); const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
const element = resolveRefs(spec.element); const element = resolveRefs(spec.element);
const elements = resolveRefs(spec.elements); const elements = resolveRefs(spec.elements);
const events = spec.events ?? []; const events = resolveEvents(spec.events);
// Parsed lazily, once per class, so defining the class needs no DOM work. // Parsed lazily, once per class, so defining the class needs no DOM work.
let template: HTMLTemplateElement | undefined; let template: HTMLTemplateElement | undefined;
@ -150,8 +151,8 @@ export function WebComponent<const S extends Spec>(
this.#upgradeProperties(); this.#upgradeProperties();
} }
if (!this.#root) this.#renderLightDom(); if (!this.#root) this.#renderLightDom();
for (const type of events) { for (const [type, options] of events) {
this.addEventListener(type, this, { signal: this.signal }); this.addEventListener(type, this, { ...options, signal: this.signal });
} }
this.onConnect(); this.onConnect();
} }

View File

@ -2,12 +2,15 @@ import { describe, expectTypeOf, test } from 'vitest';
import { import {
WebComponent, WebComponent,
choice, choice,
custom,
disable, disable,
enable, enable,
json, json,
listen,
number, number,
string, string,
type AttributesOf, type AttributesOf,
type HandledEvent,
type Kebab, type Kebab,
type PropChange, type PropChange,
type RefType, type RefType,
@ -15,7 +18,7 @@ import {
const SPEC = { const SPEC = {
shadow: 'open', shadow: 'open',
events: ['click', 'keydown'], events: { click: true, keydown: true },
element: { input: 'input.name', go: 'form > button#go', thing: '.thing' }, element: { input: 'input.name', go: 'form > button#go', thing: '.thing' },
elements: { items: 'ul li' }, elements: { items: 'ul li' },
props: { props: {
@ -124,10 +127,62 @@ describe('members', () => {
expectTypeOf(new Light().root).toEqualTypeOf<ShadowRoot | HTMLElement>(); expectTypeOf(new Light().root).toEqualTypeOf<ShadowRoot | HTMLElement>();
}); });
test('handleEvent gets the union of listed events', () => { test('handleEvent gets the union of listed events, tagged by type', () => {
expectTypeOf<Parameters<Example['handleEvent']>[0]>().toEqualTypeOf< expectTypeOf<Parameters<Example['handleEvent']>[0]>().toEqualTypeOf<
HTMLElementEventMap['click' | 'keydown'] (PointerEvent & { readonly type: 'click' }) | (KeyboardEvent & { readonly type: 'keydown' })
>(); >();
const Plain = WebComponent({});
expectTypeOf<Parameters<InstanceType<typeof Plain>['handleEvent']>[0]>().toEqualTypeOf<Event>();
});
test('events are typed by name, by listen<E>(), or by custom<T>()', () => {
type FieldChange = { name: string; value: string };
const Base = WebComponent({
events: {
click: true,
scroll: { passive: true },
keydown: listen(),
'wc:field-change': custom<FieldChange>(),
'wc:key': listen<KeyboardEvent>({ capture: true }),
'wc:untyped': true,
},
});
type Handled = Parameters<InstanceType<typeof Base>['handleEvent']>[0];
expectTypeOf<Extract<Handled, { type: 'click' }>>().toEqualTypeOf<
PointerEvent & { readonly type: 'click' }
>();
expectTypeOf<Extract<Handled, { type: 'scroll' }>>().toEqualTypeOf<
Event & { readonly type: 'scroll' }
>();
expectTypeOf<Extract<Handled, { type: 'wc:field-change' }>>().toEqualTypeOf<
CustomEvent<FieldChange> & { readonly type: 'wc:field-change' }
>();
expectTypeOf<Extract<Handled, { type: 'keydown' }>>().toEqualTypeOf<
KeyboardEvent & { readonly type: 'keydown' }
>();
expectTypeOf<Extract<Handled, { type: 'wc:key' }>>().toEqualTypeOf<
KeyboardEvent & { readonly type: 'wc:key' }
>();
expectTypeOf<Extract<Handled, { type: 'wc:untyped' }>>().toEqualTypeOf<
Event & { readonly type: 'wc:untyped' }
>();
});
test('custom<T>() is listen<CustomEvent<T>>()', () => {
expectTypeOf(custom<number>()).toEqualTypeOf(listen<CustomEvent<number>>());
});
test('event.type narrows handleEvent', () => {
const PICK = {
events: { click: true, 'wc:pick': custom<number>() },
} as const;
class El extends WebComponent(PICK) {
override handleEvent(event: HandledEvent<typeof PICK>): void {
if (event.type === 'click') expectTypeOf(event.button).toEqualTypeOf<number>();
else expectTypeOf(event.detail).toEqualTypeOf<number>();
}
}
void El;
}); });
test('refs are typed from their selectors', () => { test('refs are typed from their selectors', () => {

View File

@ -2,11 +2,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { import {
WebComponent, WebComponent,
choice, choice,
custom,
disable, disable,
enable, enable,
json, json,
listen,
number, number,
string, string,
type HandledEvent,
type PropChange, type PropChange,
} from '../src/index.ts'; } from '../src/index.ts';
@ -218,7 +221,7 @@ describe('light DOM', () => {
describe('signal and events', () => { describe('signal and events', () => {
test('events go to handleEvent until disconnect; reconnect re-listens', () => { test('events go to handleEvent until disconnect; reconnect re-listens', () => {
const handle = vi.fn(); const handle = vi.fn();
class El extends WebComponent({ events: ['click'] }) { class El extends WebComponent({ events: { click: true } }) {
override handleEvent(event: PointerEvent): void { override handleEvent(event: PointerEvent): void {
handle(event.type); handle(event.type);
} }
@ -239,6 +242,34 @@ describe('signal and events', () => {
expect(handle).toHaveBeenCalledTimes(2); expect(handle).toHaveBeenCalledTimes(2);
}); });
test('custom events and listener options', () => {
const SPEC = {
events: {
'wc:pick': custom<number>(),
'wc:once': { once: true },
'wc:key': listen<KeyboardEvent>({ once: true }),
},
} as const;
const seen: string[] = [];
class El extends WebComponent(SPEC) {
override handleEvent(event: HandledEvent<typeof SPEC>): void {
if (event.type === 'wc:pick') seen.push(`pick ${event.detail}`);
else if (event.type === 'wc:key') seen.push(`key ${event.key}`);
else seen.push(event.type);
}
}
const el = new (define(El))();
document.body.append(el);
el.dispatchEvent(new CustomEvent('wc:pick', { detail: 3 }));
el.dispatchEvent(new Event('wc:once'));
el.dispatchEvent(new Event('wc:once'));
el.dispatchEvent(new KeyboardEvent('wc:key', { key: 'a' }));
el.dispatchEvent(new KeyboardEvent('wc:key', { key: 'b' }));
el.remove();
el.dispatchEvent(new CustomEvent('wc:pick', { detail: 4 }));
expect(seen).toEqual(['pick 3', 'wc:once', 'key a']);
});
test('onConnect / onDisconnect', () => { test('onConnect / onDisconnect', () => {
const calls: string[] = []; const calls: string[] = [];
class El extends WebComponent({}) { class El extends WebComponent({}) {