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>().toEqualTypeOf<'user-id'>(); expectTypeOf>().toEqualTypeOf<'a-b-c'>(); expectTypeOf>().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(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); }); 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>().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>().toEqualTypeOf<'fb' | 'baz-qux'>(); }); }); describe('prop values', () => { test('defaults decide optionality', () => { const el = new Example(); expectTypeOf(el.userId).toEqualTypeOf(); expectTypeOf(el.userName).toEqualTypeOf(); expectTypeOf(el.min).toEqualTypeOf(); expectTypeOf(el.ready).toEqualTypeOf(); expectTypeOf(el.fetch).toEqualTypeOf(); 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>().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>().toEqualTypeOf>(); class Watcher extends WebComponent(SPEC) { override onPropChange(change: PropChange): void { if (change.name === 'min') expectTypeOf(change.value).toEqualTypeOf(); if (change.name === 'userId') expectTypeOf(change.oldValue).toEqualTypeOf(); } } expectTypeOf().toExtend(); }); }); describe('members', () => { test('root is a ShadowRoot when shadow is set', () => { expectTypeOf(new Example().root).toEqualTypeOf(); const Light = WebComponent({}); expectTypeOf(new Light().root).toEqualTypeOf(); }); test('handleEvent gets the union of listed events', () => { expectTypeOf[0]>().toEqualTypeOf< HTMLElementEventMap['click' | 'keydown'] >(); }); test('refs are typed from their selectors', () => { const { refs } = new Example(); expectTypeOf(refs.input).toEqualTypeOf(); expectTypeOf(refs.go).toEqualTypeOf(); expectTypeOf(refs.thing).toEqualTypeOf(); expectTypeOf(refs.items).toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); }); test('still an HTMLElement', () => { expectTypeOf(new Example()).toExtend(); expectTypeOf(new Example().signal).toEqualTypeOf(); }); }); 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() } }); }); 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(); expectTypeOf(new El().glow).toEqualTypeOf(); }); });