wc-mixin/test/types.test-d.ts
Buddy be8ee0198f 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.
2026-11-10 09:59:09 -08:00

284 lines
10 KiB
TypeScript

import { describe, expectTypeOf, test } from 'vitest';
import {
WebComponent,
choice,
custom,
disable,
enable,
json,
listen,
number,
string,
type AttributesOf,
type HandledEvent,
type Kebab,
type PropChange,
type RefType,
} from '../src/index.ts';
const SPEC = {
shadow: 'open',
events: { click: true, keydown: true },
element: { input: 'input.name', go: 'form > button#go', thing: '.thing' },
elements: { items: 'ul li' },
props: {
userId: number(),
userName: string(),
min: number({ default: 1 }),
max: number({ default: 10 }),
ready: enable({ attribute: 'is-ready' }),
fetch: disable(),
size: choice(['s', 'm', 'l'], { default: 'm' }),
points: json<[number, number][]>({ default: [] }),
},
} as const;
class Example extends WebComponent(SPEC) {}
describe('kebab', () => {
test('mirrors the runtime conversion', () => {
expectTypeOf<Kebab<'userId'>>().toEqualTypeOf<'user-id'>();
expectTypeOf<Kebab<'aBC'>>().toEqualTypeOf<'a-b-c'>();
expectTypeOf<Kebab<'x2y'>>().toEqualTypeOf<'x2y'>();
});
});
describe('attributes', () => {
test('observedAttributes is the exact union', () => {
type Expected =
| 'user-id'
| 'user-name'
| 'min'
| 'max'
| 'is-ready'
| 'disable-fetch'
| 'size'
| 'points';
expectTypeOf(Example.observedAttributes).toEqualTypeOf<readonly Expected[]>();
expectTypeOf<AttributesOf<typeof Example>>().toEqualTypeOf<Expected>();
expectTypeOf<AttributesOf<typeof SPEC>>().toEqualTypeOf<Expected>();
});
test('attributes maps each key to its attribute', () => {
expectTypeOf(Example.attributes.fetch).toEqualTypeOf<'disable-fetch'>();
expectTypeOf(Example.attributes.ready).toEqualTypeOf<'is-ready'>();
});
test('dataPrefix applies only to derived names', () => {
const Base = WebComponent({
dataPrefix: true,
props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) },
});
expectTypeOf<AttributesOf<typeof Base>>().toEqualTypeOf<
'data-user-id' | 'data-disable-fetch' | 'is-ready'
>();
});
test('inline specs infer without `as const`', () => {
const Base = WebComponent({ props: { fooBar: string({ attribute: 'fb' }), bazQux: number() } });
expectTypeOf<AttributesOf<typeof Base>>().toEqualTypeOf<'fb' | 'baz-qux'>();
});
});
describe('prop values', () => {
test('defaults decide optionality', () => {
const el = new Example();
expectTypeOf(el.userId).toEqualTypeOf<number | undefined>();
expectTypeOf(el.userName).toEqualTypeOf<string | undefined>();
expectTypeOf(el.min).toEqualTypeOf<number>();
expectTypeOf(el.ready).toEqualTypeOf<boolean>();
expectTypeOf(el.fetch).toEqualTypeOf<boolean>();
expectTypeOf(el.size).toEqualTypeOf<'s' | 'm' | 'l'>();
expectTypeOf(el.points).toEqualTypeOf<[number, number][]>();
const Named = WebComponent({
props: { data: json<{ a: number }, 'payload'>({ attribute: 'payload' }) },
});
expectTypeOf(new Named().data).toEqualTypeOf<{ a: number } | undefined>();
expectTypeOf<AttributesOf<typeof Named>>().toEqualTypeOf<'payload'>();
});
test('props are writable and type-checked', () => {
const el = new Example();
el.min = 2;
el.userId = undefined;
// @ts-expect-error -- wrong type
el.min = '2';
// @ts-expect-error -- not in the choice list
el.size = 'xl';
});
test('onPropChange receives a discriminated union', () => {
expectTypeOf<PropChange<typeof Example>>().toEqualTypeOf<PropChange<typeof SPEC>>();
class Watcher extends WebComponent(SPEC) {
override onPropChange(change: PropChange<typeof SPEC>): void {
if (change.name === 'min') expectTypeOf(change.value).toEqualTypeOf<number>();
if (change.name === 'userId')
expectTypeOf(change.oldValue).toEqualTypeOf<number | undefined>();
}
}
expectTypeOf<Watcher>().toExtend<HTMLElement>();
});
});
describe('members', () => {
test('root is a ShadowRoot when shadow is set', () => {
expectTypeOf(new Example().root).toEqualTypeOf<ShadowRoot>();
const Light = WebComponent({});
expectTypeOf(new Light().root).toEqualTypeOf<ShadowRoot | HTMLElement>();
});
test('handleEvent gets the union of listed events, tagged by type', () => {
expectTypeOf<Parameters<Example['handleEvent']>[0]>().toEqualTypeOf<
(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', () => {
const { refs } = new Example();
expectTypeOf(refs.input).toEqualTypeOf<HTMLInputElement | null>();
expectTypeOf(refs.go).toEqualTypeOf<HTMLButtonElement | null>();
expectTypeOf(refs.thing).toEqualTypeOf<Element | null>();
expectTypeOf(refs.items).toEqualTypeOf<HTMLLIElement[]>();
expectTypeOf<RefType<'svg circle.dot'>>().toEqualTypeOf<SVGCircleElement>();
expectTypeOf<RefType<'input:checked'>>().toEqualTypeOf<HTMLInputElement>();
});
test('[selector, Class] refs are typed as the class', () => {
class Widget extends HTMLElement {}
const El = WebComponent({
element: { thing: ['.thing', HTMLElement], widget: ['.w', Widget] },
elements: { items: ['.item', HTMLLIElement] },
});
const { refs } = new El();
expectTypeOf(refs.thing).toEqualTypeOf<HTMLElement | null>();
expectTypeOf(refs.widget).toEqualTypeOf<Widget | null>();
expectTypeOf(refs.items).toEqualTypeOf<HTMLLIElement[]>();
});
test('the class must be an Element class', () => {
// @ts-expect-error -- Date isn't an Element
WebComponent({ element: { when: ['.when', Date] } });
});
test('still an HTMLElement', () => {
expectTypeOf(new Example()).toExtend<HTMLElement>();
expectTypeOf(new Example().signal).toEqualTypeOf<AbortSignal>();
});
});
describe('conflicts', () => {
test('prop names colliding with HTMLElement members are rejected', () => {
// @ts-expect-error -- `title` is on HTMLElement
WebComponent({ props: { title: string() } });
// @ts-expect-error -- `hidden` is on HTMLElement
WebComponent({ props: { hidden: enable() } });
});
test('prop names colliding with mixin members are rejected', () => {
// @ts-expect-error -- `signal` is a mixin member
WebComponent({ props: { signal: string() } });
});
test('static names are allowed as props', () => {
class WithStatic extends HTMLElement {
static shine = 1;
}
const El = WebComponent({
base: WithStatic,
props: { shine: enable(), observedAttributes: string() },
});
expectTypeOf(new El().shine).toEqualTypeOf<boolean>();
expectTypeOf(El.shine).toEqualTypeOf<number>();
});
test('conflicts are checked against the base class', () => {
class Fancy extends HTMLElement {
sparkle = true;
}
// @ts-expect-error -- `sparkle` is on the base
WebComponent({ base: Fancy, props: { sparkle: enable() } });
const El = WebComponent({ base: Fancy, props: { glow: enable() } });
expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>();
expectTypeOf(new El().glow).toEqualTypeOf<boolean>();
});
});
describe('ref conflicts', () => {
test('a key in both element and elements is rejected', () => {
WebComponent({
// @ts-expect-error -- `input` is also in elements
element: { input: 'input', button: 'button' },
// @ts-expect-error -- `input` is also in element
elements: { input: 'textarea', items: 'li' },
});
});
test('the tuple form conflicts too', () => {
WebComponent({
// @ts-expect-error -- `input` is also in elements
element: { input: ['input', HTMLInputElement] },
// @ts-expect-error -- `input` is also in element
elements: { input: 'textarea' },
});
});
test('distinct keys are fine', () => {
const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } });
expectTypeOf(new El().refs.input).toEqualTypeOf<HTMLInputElement | null>();
expectTypeOf(new El().refs.inputs).toEqualTypeOf<HTMLInputElement[]>();
});
});