css is now string | CSSStyleSheet. A sheet is adopted as-is: into its own
shadow root, an enclosing shadow root, or document.adoptedStyleSheets for
light DOM at the document level. Strings behave as before.
The examples import their styles with `with { type: 'css' }` and their
static templates with `with { type: 'text' }`. Safari doesn't support those
yet, so vite.config.ts rewrites them into modules Vite builds: a constructed
CSSStyleSheet, or the file's text. npm run examples now just runs vite,
with root set in the config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import {
|
|
WebComponent,
|
|
choice,
|
|
custom,
|
|
disable,
|
|
enable,
|
|
json,
|
|
listen,
|
|
number,
|
|
string,
|
|
type HandledEvent,
|
|
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)',
|
|
);
|
|
});
|
|
|
|
test('a CSSStyleSheet is adopted into the document once per element type', () => {
|
|
const sheet = new CSSStyleSheet();
|
|
sheet.replaceSync('p { color: red; }');
|
|
class El extends WebComponent({ css: sheet, template: '<p>hi</p>' }) {}
|
|
const tag = nextTag();
|
|
customElements.define(tag, El);
|
|
const before = document.adoptedStyleSheets.length;
|
|
document.body.append(new El(), new El());
|
|
expect(document.adoptedStyleSheets).toHaveLength(before + 1);
|
|
expect(document.adoptedStyleSheets.at(-1)).toBe(sheet);
|
|
expect(document.head.querySelector(`style[data-wc-mixin="${tag}"]`)).toBeNull();
|
|
expect(
|
|
getComputedStyle(instance(HTMLParagraphElement, document.querySelector(`${tag} p`))).color,
|
|
).toBe('rgb(255, 0, 0)');
|
|
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== sheet);
|
|
});
|
|
|
|
test('a CSSStyleSheet is adopted as-is into its own shadow root', () => {
|
|
const sheet = new CSSStyleSheet();
|
|
sheet.replaceSync('p { color: red; }');
|
|
class El extends WebComponent({ shadow: 'open', css: sheet, template: '<p>hi</p>' }) {}
|
|
customElements.define(nextTag(), El);
|
|
const el = new El();
|
|
document.body.append(el);
|
|
expect(el.shadowRoot?.adoptedStyleSheets).toEqual([sheet]);
|
|
expect(
|
|
getComputedStyle(instance(HTMLParagraphElement, el.shadowRoot?.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: true } }) {
|
|
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('custom events and listener options', () => {
|
|
const SPEC = {
|
|
events: {
|
|
'wc:pick': custom<number>(),
|
|
'wc:once': { once: true },
|
|
'wc:key': listen<KeyboardEvent>({ once: true }),
|
|
},
|
|
} as const;
|
|
const seen: string[] = [];
|
|
class El extends WebComponent(SPEC) {
|
|
override handleEvent(event: HandledEvent<typeof SPEC>): void {
|
|
if (event.type === 'wc:pick') seen.push(`pick ${event.detail}`);
|
|
else if (event.type === 'wc:key') seen.push(`key ${event.key}`);
|
|
else seen.push(event.type);
|
|
}
|
|
}
|
|
const el = new (define(El))();
|
|
document.body.append(el);
|
|
el.dispatchEvent(new CustomEvent('wc:pick', { detail: 3 }));
|
|
el.dispatchEvent(new Event('wc:once'));
|
|
el.dispatchEvent(new Event('wc:once'));
|
|
el.dispatchEvent(new KeyboardEvent('wc:key', { key: 'a' }));
|
|
el.dispatchEvent(new KeyboardEvent('wc:key', { key: 'b' }));
|
|
el.remove();
|
|
el.dispatchEvent(new CustomEvent('wc:pick', { detail: 4 }));
|
|
expect(seen).toEqual(['pick 3', 'wc:once', 'key a']);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test('[selector, Class] refs return matches of the right class', () => {
|
|
class El extends WebComponent({
|
|
shadow: 'open',
|
|
template: '<p class="x">p</p><p class="x">p2</p>',
|
|
element: { para: ['.x', HTMLParagraphElement], missing: ['.nope', HTMLParagraphElement] },
|
|
elements: { paras: ['.x', HTMLParagraphElement] },
|
|
}) {}
|
|
const el = new (define(El))();
|
|
expect(el.refs.para?.textContent).toBe('p');
|
|
expect(el.refs.missing).toBeNull();
|
|
expect(el.refs.paras.map((p) => p.textContent)).toEqual(['p', 'p2']);
|
|
});
|
|
|
|
test('[selector, Class] refs throw on a match of the wrong class', () => {
|
|
class El extends WebComponent({
|
|
shadow: 'open',
|
|
template: '<p class="x">p</p><div class="x">div</div>',
|
|
element: { button: ['.x', HTMLButtonElement] },
|
|
elements: { paras: ['.x', HTMLParagraphElement] },
|
|
}) {}
|
|
const el = new (define(El))();
|
|
expect(() => el.refs.button).toThrow(
|
|
new TypeError(
|
|
`<${el.localName}> ref "button": ".x" matched <p>, which is not a HTMLButtonElement`,
|
|
),
|
|
);
|
|
expect(() => el.refs.paras).toThrow(
|
|
new TypeError(
|
|
`<${el.localName}> ref "paras": ".x" matched <div>, which is not a HTMLParagraphElement`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|