wc-mixin/test/types.test-d.ts
Buddy c961d01fc4 Add typed WebComponent mixin factory
WebComponent(spec) builds a custom-element base class from a spec:
shadow root or declarative shadow DOM, template, shared stylesheet
(adopted, or a <style> once per root for light DOM), reflected props
with two-way attribute sync, a per-connection AbortSignal, host event
wiring, and typed element refs.

Types derive exact observedAttributes, prop value types, ref element
types from selectors, and reject prop names that collide with the base
class or mixin members.

Tooling: strict tsconfig, type-aware oxlint, oxfmt, and Vitest browser
mode (Chromium) with type tests.
2026-11-06 00:08:56 -08:00

174 lines
5.9 KiB
TypeScript

import { describe, expectTypeOf, test } from 'vitest';
import {
WebComponent,
choice,
disable,
enable,
json,
number,
string,
type AttributesOf,
type Kebab,
type PropChange,
type RefType,
} from '../src/index.ts';
const SPEC = {
shadow: 'open',
events: ['click', 'keydown'],
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', () => {
expectTypeOf<Parameters<Example['handleEvent']>[0]>().toEqualTypeOf<
HTMLElementEventMap['click' | 'keydown']
>();
});
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('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('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>();
});
});