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