WebComponent(spec) builds a custom-element base class from a spec: shadow root or declarative shadow DOM, template, shared stylesheet (adopted, or a <style> once per root for light DOM), reflected props with two-way attribute sync, a per-connection AbortSignal, host event wiring, and typed element refs. Types derive exact observedAttributes, prop value types, ref element types from selectors, and reject prop names that collide with the base class or mixin members. Tooling: strict tsconfig, type-aware oxlint, oxfmt, and Vitest browser mode (Chromium) with type tests.
174 lines
5.9 KiB
TypeScript
174 lines
5.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('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('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>();
|
|
});
|
|
});
|