227 lines
8.0 KiB
TypeScript
227 lines
8.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('[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() } });
|
|
// @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('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[]>();
|
|
});
|
|
});
|