wc-mixin/test/web-component.test.ts
Buddy c961d01fc4 Add typed WebComponent mixin factory
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.
2026-11-06 00:08:56 -08:00

296 lines
9.9 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest';
import {
WebComponent,
choice,
disable,
enable,
json,
number,
string,
type PropChange,
} from '../src/index.ts';
let count = 0;
/** A fresh, unused tag name. */
function nextTag(): string {
count += 1;
return `x-test-${count}`;
}
/** Defines `ctor` under a fresh tag name and returns it, so `new ctor()` works. */
function define<T extends CustomElementConstructor>(ctor: T): T {
customElements.define(nextTag(), ctor);
return ctor;
}
/** Narrows `node` to an instance of `ctor`, failing the test otherwise. */
function instance<T>(ctor: abstract new (...args: never) => T, node: unknown): T {
if (!(node instanceof ctor)) throw new TypeError(`expected a ${ctor.name}`);
return node;
}
afterEach(() => {
document.body.replaceChildren();
for (const style of document.head.querySelectorAll('style[data-wc-mixin]')) style.remove();
});
describe('props', () => {
test('onPropChange fires once per change, from either side', () => {
const SPEC = { props: { count: number({ default: 0 }) } } as const;
const changes: PropChange<typeof SPEC>[] = [];
class El extends WebComponent(SPEC) {
override onPropChange(change: PropChange<typeof SPEC>): void {
changes.push(change);
}
}
const el = new (define(El))();
el.count = 1;
el.count = 1;
el.setAttribute('count', '2');
el.removeAttribute('count');
expect(changes).toEqual([
{ name: 'count', value: 1, oldValue: 0 },
{ name: 'count', value: 2, oldValue: 1 },
{ name: 'count', value: 0, oldValue: 2 },
]);
});
test('parsed attributes populate props on upgrade', () => {
class El extends WebComponent({ props: { userId: number(), ready: enable() } }) {}
const tag = nextTag();
document.body.innerHTML = `<${tag} user-id="7" ready></${tag}>`;
customElements.define(tag, El);
const el = instance(El, document.body.firstElementChild);
expect(el.userId).toBe(7);
expect(el.ready).toBe(true);
});
test('properties set before upgrade go through the accessor', () => {
class El extends WebComponent({ props: { label: string() } }) {}
const tag = nextTag();
const el = document.createElement(tag);
Object.assign(el, { label: 'early' });
customElements.define(tag, El);
customElements.upgrade(el);
document.body.append(el);
expect(Object.hasOwn(el, 'label')).toBe(false);
expect(instance(El, el).label).toBe('early');
expect(el.getAttribute('label')).toBe('early');
});
test('boolean codecs accept ="false"', () => {
class El extends WebComponent({ props: { ready: enable(), fetch: disable() } }) {}
const el = new (define(El))();
el.setAttribute('ready', 'false');
el.setAttribute('disable-fetch', 'false');
expect(el.ready).toBe(false);
expect(el.fetch).toBe(true);
});
test('choice and json', () => {
class El extends WebComponent({
props: { size: choice(['s', 'm', 'l'], { default: 'm' }), data: json<{ a: number }>() },
}) {}
const el = new (define(El))();
el.setAttribute('size', 'l');
expect(el.size).toBe('l');
el.setAttribute('size', 'xl');
expect(el.size).toBe('m');
el.data = { a: 1 };
expect(el.getAttribute('data')).toBe('{"a":1}');
el.setAttribute('data', '{"a":2}');
expect(el.data).toEqual({ a: 2 });
});
test('dataPrefix prefixes derived names only', () => {
class El extends WebComponent({
dataPrefix: true,
props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) },
}) {}
expect(El.observedAttributes).toEqual(['data-user-id', 'data-disable-fetch', 'is-ready']);
expect(El.attributes).toEqual({
userId: 'data-user-id',
fetch: 'data-disable-fetch',
ready: 'is-ready',
});
const el = new (define(El))();
el.fetch = false;
expect(el.dataset['disableFetch']).toBe('');
});
});
describe('shadow DOM', () => {
test('open shadow root gets the template and adopted css', () => {
class El extends WebComponent({
shadow: 'open',
template: '<p>hi</p>',
css: 'p { color: red; }',
}) {}
const el = new (define(El))();
expect(el.shadowRoot).toBe(el.root);
expect(el.root.innerHTML).toBe('<p>hi</p>');
expect(el.root.adoptedStyleSheets).toHaveLength(1);
document.body.append(el);
expect(getComputedStyle(instance(HTMLParagraphElement, el.root.querySelector('p'))).color).toBe(
'rgb(255, 0, 0)',
);
});
test('closed shadow root stays closed but is reachable via root', () => {
class El extends WebComponent({ shadow: 'closed', template: '<p>hi</p>' }) {}
const el = new (define(El))();
expect(el.shadowRoot).toBeNull();
expect(el.root).toBeInstanceOf(ShadowRoot);
expect(el.root.mode).toBe('closed');
});
test('instances share one stylesheet', () => {
class El extends WebComponent({ shadow: 'open', css: 'p {}' }) {}
define(El);
const a = new El();
const b = new El();
expect(a.root.adoptedStyleSheets[0]).toBe(b.root.adoptedStyleSheets[0]);
});
test('declarative shadow DOM wins over shadow/template', () => {
class El extends WebComponent({ shadow: 'open', template: '<p>template</p>', css: 'p {}' }) {}
const tag = nextTag();
document.body.setHTMLUnsafe(
`<${tag}><template shadowrootmode="closed"><p>declarative</p></template></${tag}>`,
);
customElements.define(tag, El);
const el = instance(El, document.body.firstElementChild);
expect(el.root.mode).toBe('closed');
expect(el.root.innerHTML).toBe('<p>declarative</p>');
expect(el.root.adoptedStyleSheets).toHaveLength(1);
});
test('template element is accepted', () => {
const template = document.createElement('template');
template.innerHTML = '<b>x</b>';
class El extends WebComponent({ shadow: 'open', template }) {}
const el = new (define(El))();
expect(el.root.innerHTML).toBe('<b>x</b>');
});
});
describe('light DOM', () => {
test('template is appended once, on first connect', () => {
class El extends WebComponent({ template: '<p>hi</p>' }) {}
const el = new (define(El))();
expect(el.root).toBe(el);
expect(el.childNodes).toHaveLength(0);
document.body.append(el);
el.remove();
document.body.append(el);
expect(el.innerHTML).toBe('<p>hi</p>');
});
test('css becomes a single <style> in the head per element type', () => {
class El extends WebComponent({ css: 'p { color: red; }', template: '<p>hi</p>' }) {}
const tag = nextTag();
customElements.define(tag, El);
document.body.append(new El(), new El());
const styles = document.head.querySelectorAll(`style[data-wc-mixin="${tag}"]`);
expect(styles).toHaveLength(1);
expect(
getComputedStyle(instance(HTMLParagraphElement, document.querySelector(`${tag} p`))).color,
).toBe('rgb(255, 0, 0)');
});
test('css is adopted into an enclosing shadow root instead of the head', () => {
class Inner extends WebComponent({ css: 'p { color: red; }', template: '<p>hi</p>' }) {}
const tag = nextTag();
customElements.define(tag, Inner);
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
document.body.append(host);
shadow.append(new Inner(), new Inner());
expect(shadow.adoptedStyleSheets).toHaveLength(1);
expect(document.head.querySelector(`style[data-wc-mixin="${tag}"]`)).toBeNull();
expect(getComputedStyle(instance(HTMLParagraphElement, shadow.querySelector('p'))).color).toBe(
'rgb(255, 0, 0)',
);
});
});
describe('signal and events', () => {
test('events go to handleEvent until disconnect; reconnect re-listens', () => {
const handle = vi.fn();
class El extends WebComponent({ events: ['click'] }) {
override handleEvent(event: PointerEvent): void {
handle(event.type);
}
}
const el = new (define(El))();
document.body.append(el);
const first = el.signal;
el.click();
el.remove();
expect(first.aborted).toBe(true);
el.click();
expect(handle).toHaveBeenCalledTimes(1);
document.body.append(el);
expect(el.signal).not.toBe(first);
expect(el.signal.aborted).toBe(false);
el.click();
expect(handle).toHaveBeenCalledTimes(2);
});
test('onConnect / onDisconnect', () => {
const calls: string[] = [];
class El extends WebComponent({}) {
override onConnect(): void {
calls.push('connect');
document.addEventListener(
'ping',
() => {
calls.push('ping');
},
{ signal: this.signal },
);
}
override onDisconnect(): void {
calls.push('disconnect');
}
}
const el = new (define(El))();
document.body.append(el);
document.dispatchEvent(new Event('ping'));
el.remove();
document.dispatchEvent(new Event('ping'));
expect(calls).toEqual(['connect', 'ping', 'disconnect']);
});
});
describe('refs', () => {
test('element and elements query the root live', () => {
class El extends WebComponent({
shadow: 'open',
template: '<input class="name"><ul><li>a</li><li>b</li></ul>',
element: { input: 'input.name', missing: 'button' },
elements: { items: 'li' },
}) {}
const el = new (define(El))();
expect(el.refs.input).toBe(el.root.querySelector('input'));
expect(el.refs.missing).toBeNull();
expect(el.refs.items.map((li) => li.textContent)).toEqual(['a', 'b']);
instance(HTMLUListElement, el.root.querySelector('ul')).append(document.createElement('li'));
expect(el.refs.items).toHaveLength(3);
});
});
describe('base', () => {
test('extends the given base class', () => {
class Fancy extends HTMLElement {
readonly sparkle = 'sparkle';
}
class El extends WebComponent({ base: Fancy, props: { glow: enable() } }) {}
const el = new (define(El))();
expect(el).toBeInstanceOf(Fancy);
expect(el.sparkle).toBe('sparkle');
});
});