wc-mixin/test/types.test-d.ts

201 lines
7.0 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('attributeFor maps each key to its attribute', () => {
expectTypeOf(Example.attributeFor.fetch).toEqualTypeOf<'disable-fetch'>();
expectTypeOf(Example.attributeFor.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() } });
// @ts-expect-error -- `attributeFor` is reserved for the static lookup
WebComponent({ props: { attributeFor: string() } });
// @ts-expect-error -- `observedAttributes` is a static of the class
WebComponent({ props: { observedAttributes: 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() } });
class WithStatic extends HTMLElement {
static shine = 1;
}
// @ts-expect-error -- `shine` is a static on the base
WebComponent({ base: WithStatic, props: { shine: 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('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[]>();
});
});