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>().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('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>().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, tagged by type', () => { expectTypeOf[0]>().toEqualTypeOf< (PointerEvent & { readonly type: 'click' }) | (KeyboardEvent & { readonly type: 'keydown' }) >(); const Plain = WebComponent({}); expectTypeOf['handleEvent']>[0]>().toEqualTypeOf(); }); test('events are typed by name, by listen(), or by custom()', () => { type FieldChange = { name: string; value: string }; const Base = WebComponent({ events: { click: true, scroll: { passive: true }, keydown: listen(), 'wc:field-change': custom(), 'wc:key': listen({ capture: true }), 'wc:untyped': true, }, }); type Handled = Parameters['handleEvent']>[0]; expectTypeOf>().toEqualTypeOf< PointerEvent & { readonly type: 'click' } >(); expectTypeOf>().toEqualTypeOf< Event & { readonly type: 'scroll' } >(); expectTypeOf>().toEqualTypeOf< CustomEvent & { readonly type: 'wc:field-change' } >(); expectTypeOf>().toEqualTypeOf< KeyboardEvent & { readonly type: 'keydown' } >(); expectTypeOf>().toEqualTypeOf< KeyboardEvent & { readonly type: 'wc:key' } >(); expectTypeOf>().toEqualTypeOf< Event & { readonly type: 'wc:untyped' } >(); }); test('custom() is listen>()', () => { expectTypeOf(custom()).toEqualTypeOf(listen>()); }); test('event.type narrows handleEvent', () => { const PICK = { events: { click: true, 'wc:pick': custom() }, } as const; class El extends WebComponent(PICK) { override handleEvent(event: HandledEvent): void { if (event.type === 'click') expectTypeOf(event.button).toEqualTypeOf(); else expectTypeOf(event.detail).toEqualTypeOf(); } } void El; }); 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('[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(); expectTypeOf(refs.widget).toEqualTypeOf(); expectTypeOf(refs.items).toEqualTypeOf(); }); 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(); 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() } }); }); 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(); expectTypeOf(El.shine).toEqualTypeOf(); }); 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(); }); }); 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(); expectTypeOf(new El().refs.inputs).toEqualTypeOf(); }); });