wc-mixin/test/types.test-d.ts
Buddy 6ece00c8cc Allow static names as props and rename attributeFor back to attributes
Props live on instances, so statics on the generated class or the base
can't collide with them. Only instance members of the base and the
mixin are reserved now.
2026-11-07 12:25:11 -08:00

229 lines
7.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('[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[]>();
});
});