wc-mixin/test/web-component.test.ts

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.attributeFor).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');
});
});