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.
284 lines
10 KiB
TypeScript
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[]>();
|
|
});
|
|
});
|