Props live on instances, so statics on the generated class or the base can't collide with them. Only instance members of the base and the mixin are reserved now.
329 lines
11 KiB
TypeScript
329 lines
11 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);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|