typed-dom
Version:
A value-level and type-level DOM builder.
744 lines (684 loc) • 26.3 kB
text/typescript
import { Shadow, HTML, SVG, El, Attrs, shadow, frag, html, define } from '../../index';
import { Coroutine } from 'spica/coroutine';
import { Sequence } from 'spica/sequence';
import { wait } from 'spica/timer';
import i18next from 'i18next';
declare global {
interface ShadowHostHTMLElementTagNameMap {
'custom-tag': HTMLCustomElement;
}
interface HTMLElementTagNameMap {
'custom': HTMLElement;
}
interface HTMLCustomElement extends HTMLElement {
custom: true;
}
}
declare const _: { shuffle<T>(as: T[]): T[]; };
const doc = Shadow.section([]);
document.body.appendChild(doc.element);
describe('Integration: Package', function () {
describe('spec', function () {
it('call', function () {
assert(HTML('p').element.outerHTML === '<p></p>');
assert(HTML('p', 'a').element.outerHTML === '<p>a</p>');
assert(HTML('p', { class: 'class' }, 'a', (h, t) => h(t, { id: 'id' })).element.outerHTML === '<p id="id" class="class">a</p>');
});
it('html', function () {
assert(HTML.html().element instanceof HTMLElement);
assert(HTML.html().element.outerHTML === '<html></html>');
});
it('svg', function () {
assert(SVG.svg().element instanceof SVGElement);
assert(SVG.svg().element.outerHTML === '<svg></svg>');
});
it('empty', function () {
const dom = HTML.p();
assert(dom.element.outerHTML === '<p></p>');
assert(dom.children === undefined);
assert.deepStrictEqual({ ...dom.element }, {});
});
it('factory', function () {
const dom = HTML.p({ class: 'test' }, [HTML.a()], (h, tag, _, children) => {
assert(children.every(child => child.element instanceof HTMLAnchorElement));
return html('div').appendChild(h(tag, { id: 'test' }));
});
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert(dom.children.length === 1);
assert(dom.children.every(child => child.element instanceof HTMLAnchorElement));
});
it('node', function () {
const dom = Shadow.p(frag(['a', html('br'), 'b']));
assert(dom.element.shadowRoot?.innerHTML === 'a<br>b');
assert(dom.children instanceof DocumentFragment);
assert(dom.children.childNodes.length === 0);
dom.children = frag(['c']);
assert(dom.element.shadowRoot?.innerHTML === 'c');
assert(dom.children.childNodes.length === 0);
});
it('text', function () {
const dom = HTML.p('a');
assert(dom.element.outerHTML === '<p>a</p>');
assert(dom.children === 'a');
});
it('text children update', function () {
const dom = HTML.p('a');
// @ts-expect-error
() => dom.children = undefined;
dom.children = 'b';
assert(dom.element.outerHTML === '<p>b</p>');
assert(dom.children === 'b');
});
it('text with factory', function () {
const dom = HTML.p('a', (h, tag) =>
h(tag, { id: 'test' }));
assert(dom.element.id === 'test');
assert(dom.children === 'a');
});
it('collection', function () {
const dom = HTML.ul([
HTML.li('1'),
HTML.li('2'),
]);
assert(dom.element.outerHTML === '<ul><li>1</li><li>2</li></ul>');
assert(dom.children.length === 2);
assert(dom.children.every(({ element }, i) => element === dom.element.children[i]));
});
it('collection children update', function () {
this.timeout(9 * 1e3);
const dom = HTML.ul([
HTML.li('1'),
]);
assert.doesNotThrow(() => dom.children = dom.children);
assert.throws(() => dom.children = HTML.ul([HTML.li('1')]).children);
dom.children = [
HTML.li('2'),
HTML.li('3'),
];
assert(dom.element.outerHTML === '<ul><li>2</li><li>3</li></ul>');
assert(dom.children.length === 2);
assert(dom.children.every(({ element }, i) => element === dom.element.children[i]));
dom.children = [
HTML.li('4'),
];
assert(dom.element.outerHTML === '<ul><li>4</li></ul>');
assert(dom.children.length === 1);
assert(dom.children.every(({ element }, i) => element === dom.element.children[i]));
dom.children = [
HTML.li('4'),
HTML.li('5'),
];
assert(dom.element.outerHTML === '<ul><li>4</li><li>5</li></ul>');
assert(dom.children.length === 2);
assert(dom.children.every(({ element }, i) => element === dom.element.children[i]));
// Property test
const el = HTML.ul([]);
const es = Sequence.from([HTML.li('1'), HTML.li('2'), HTML.li('3')]);
el.children = [];
Sequence.from([
...es.permutations(),
...es.permutations().bind(es => Sequence.from(es).subsequences()),
])
.extract()
.forEach(es => {
el.children = es;
assert.deepStrictEqual(el.children, es);
});
});
it('collection children partial update', function () {
const dom = HTML.ul([
HTML.li(),
]);
// @ts-expect-error
() => dom.children[0] = dom.children[0];
//() => dom.children[0] = HTML.li();
//() => dom.children.push(HTML.li());
//() => dom.children.pop();
//() => dom.children.length = 0;
// @ts-expect-error
() => dom.children = [undefined];
// @ts-expect-error
() => dom.children = [HTML.li(), undefined];
assert(dom.children.length === 1);
assert(dom.children.every(({ element }, i) => element === dom.element.children[i]));
});
it('collection with factory', function () {
const dom = HTML.ul([], (h, tag) =>
h(tag, { id: 'test' }));
assert(dom.element.id === 'test');
assert.deepStrictEqual(dom.children, []);
});
it('struct', function () {
const dom = HTML.article({
title: HTML.h1('title'),
content: HTML.p(),
});
assert(dom.element.outerHTML === '<article><h1>title</h1><p></p></article>');
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.content.element === dom.element.lastChild);
});
it('struct empty', function () {
const dom = HTML.div({}, {});
assert.deepStrictEqual(dom.children, {});
});
it('struct children update', function () {
const dom = HTML.article({
title: HTML.h1('a'),
content: HTML.p(),
});
assert.doesNotThrow(() => dom.children = dom.children);
assert.throws(() => dom.children = HTML.article({ title: HTML.h1('b') }).children);
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.title.element.textContent === 'a');
assert(dom.children.title.children === 'a');
dom.children = {
content: HTML.p(),
title: HTML.h1('b'),
};
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.title.element.textContent === 'b');
assert(dom.children.title.children === 'b');
});
it('struct children partial update', function () {
const dom = HTML.article({
title: HTML.h1('a'),
content: HTML.p(),
});
assert.doesNotThrow(() => dom.children.title = dom.children.title);
assert.throws(() => dom.children.title = HTML.article({ title: HTML.h1('b') }).children.title);
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.title.element.textContent === 'a');
assert(dom.children.title.children === 'a');
dom.children.title = HTML.h1('b');
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.title.element.textContent === 'b');
assert(dom.children.title.children === 'b');
dom.children.title.children = 'c';
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.title.element.textContent === 'c');
assert(dom.children.title.children === 'c');
dom.children = {
title: HTML.h1('d'),
};
assert(dom.children.title.element === dom.element.firstChild);
assert(dom.children.title.element.textContent === 'd');
assert(dom.children.title.children === 'd');
dom.children = {
title: HTML.h1('e'),
content: undefined,
};
assert(dom.children.content.element === dom.element.lastChild);
assert(dom.element.outerHTML === '<article><h1>e</h1><p></p></article>');
});
it('struct with factory', function () {
const dom = HTML.article(
{
content: HTML.p('b'),
},
(h, tag, _, children) =>
h(tag, { id: 'test' }, [
html('h1', 'a'),
children.content.element,
'z',
]));
assert(dom.element.id === 'test');
assert.deepStrictEqual(dom.children, { content: dom.children.content });
assert(dom.element.innerHTML === '<h1>a</h1><p>b</p>z');
dom.children.content.children = 'c';
assert(dom.element.innerHTML === '<h1>a</h1><p>c</p>z');
dom.children.content = HTML.p('d');
assert(dom.element.innerHTML === '<h1>a</h1><p>d</p>z');
});
it('attr', function () {
(): void => HTML.div({}).children;
assert.deepStrictEqual(HTML.div({}).children, undefined);
const dom = HTML.div({ id: 'test', class: 'test' });
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert(dom.children === undefined);
});
it('attr with factory', function () {
const dom = HTML.div({ id: 'test' }, (h, tag) =>
h(tag, { id: 'id', class: 'test' }, 'test'));
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert(dom.children === undefined);
assert(HTML.div({}, (h, tag) => h(tag, 'test')).element.textContent === 'test');
});
it('attr with text', function () {
const dom = HTML.div({ id: 'test', class: 'test' }, '');
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert(dom.children === '');
});
it('attr with collection', function () {
const dom = HTML.div({ id: 'test', class: 'test' }, []);
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert.deepStrictEqual(dom.children, []);
});
it('attr with struct', function () {
const dom = HTML.div({ id: 'test', class: 'test' }, {});
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert.deepStrictEqual(dom.children, {});
});
it('attr with text and factory', function () {
const dom = HTML.div({ id: 'test' }, '', (h, tag) =>
h(tag, { id: 'id', class: 'test' }));
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert(dom.children === '');
});
it('attr with collection and factory', function () {
const dom = HTML.div({ id: 'test' }, [], (h, tag) =>
h(tag, { id: 'id', class: 'test' }));
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert.deepStrictEqual(dom.children, []);
});
it('attr with struct and factory', function () {
const dom = HTML.div({ id: 'test' }, {}, (h, tag) =>
h(tag, { id: 'id', class: 'test' }));
assert(dom.element.id === 'test');
assert(dom.element.className === 'test');
assert.deepStrictEqual(dom.children, {});
assert.deepStrictEqual(HTML('div', undefined, {}, html => html('div', 'a')).children, {});
assert.deepStrictEqual(HTML.div(undefined, {}, html => html('div', 'a')).children, {});
});
it('listen', function (done) {
HTML.a({ onclick: ev => void assert(ev instanceof Event) ?? done() }).element.click();
});
it('sanitize', function () {
const dom = HTML.div('<script>');
assert(dom.element.innerHTML === '<script>');
assert(dom.children === '<script>');
dom.children = '<script>';
assert(dom.element.innerHTML === '<script>');
assert(dom.children === '<script>');
});
it('clear', function () {
assert(HTML.p(() => HTML.p('a').element).element.innerHTML === 'a');
assert(HTML.p(() => HTML.p('a').element).children === undefined);
assert(HTML.p('', () => HTML.p('a').element).element.innerHTML === '');
assert(HTML.p('', () => HTML.p('a').element).children === '');
assert(HTML.p([], () => HTML.p('a').element).element.innerHTML === '');
assert.deepStrictEqual(HTML.p([], () => HTML.p('a').element).children, []);
assert(HTML.p({}, {}, () => HTML.p('a').element).element.innerHTML === 'a');
assert.deepStrictEqual(HTML.p({}, {}, () => HTML.p('a').element).children, {});
});
it('fragment', function () {
HTML.div([HTML.p(() => document.createDocumentFragment().appendChild(html('p')))]);
});
it('parameter combination', function () {
Sequence.from([
[{ id: 'id' }],
[undefined, '', [], {}],
[() => html('div')]] as const)
.mapM(v => Sequence.from(v))
.bind(v => Sequence.from(v).filterM(() => Sequence.from([false, true])))
.extract()
.forEach(params => {
HTML.div(...params as any);
});
});
it('extend', function () {
assert(HTML.custom().element.outerHTML === '<custom></custom>');
window.customElements.define('custom-tag', class extends HTMLElement {
custom = true;
});
assert(Shadow('custom-tag').element.outerHTML === '<custom-tag></custom-tag>');
assert(HTML('custom-tag').element.outerHTML === '<custom-tag></custom-tag>');
assert(HTML('custom-tag').element.custom === true);
window.customElements.define('custom-div', class extends HTMLDivElement {
static get observedAttributes() {
return ['is', 'class'];
}
attributeChangedCallback(name: string) {
assert(name !== 'is');
this.textContent += name;
}
}, { extends: 'div' });
assert(HTML.div({ is: 'custom-div' }).element.outerHTML === '<div is="custom-div"></div>');
assert(HTML.div({ is: 'custom-div', class: '' }).element.outerHTML === '<div is="custom-div" class="">class</div>');
});
it('swap', function () {
const dom = HTML.article([HTML.p()]);
const children = dom.children;
assert.throws(() => HTML.article(children));
dom.children = [HTML.p()];
assert(HTML.article(children));
});
it('observe text', function () {
const dom = HTML.span(
{
onmutate: ({ currentTarget: el }) =>
el.textContent += el.textContent!,
},
'a');
assert.deepStrictEqual({ ...dom.element }, {});
assert(dom.element['onmutate'] === '');
assert(dom.children === 'aa');
dom.children = 'b';
assert(dom.children === 'bb');
dom.element['onmutate'] = null;
assert(dom.element['onmutate'] === null);
});
it('observe collection', function () {
const attrs: Attrs = {
onconnect: ({ currentTarget: el }) =>
el.textContent += el.textContent![0].toUpperCase(),
ondisconnect: ({ currentTarget: el }) =>
el.textContent += el.textContent![0].toLowerCase(),
};
const dom = HTML.ul([
HTML.li(attrs, 'a'),
HTML.li(attrs, 'b'),
]);
assert(dom.element['onconnect'] === undefined);
assert(dom.element['ondisconnect'] === undefined);
assert.deepStrictEqual(
dom.children.map(child => child.children),
[
'a',
'b',
]);
doc.children = [dom];
assert.deepStrictEqual(
dom.children.map(child => child.children),
[
'aA',
'bB',
]);
assert.deepStrictEqual(
dom.children.map(v => v.element),
[...dom.element.children]);
dom.children = [
dom.children[1],
HTML.li(attrs, 'c'),
];
assert.deepStrictEqual(
dom.children.map(child => child.children),
[
'bB',
'cC',
]);
assert.deepStrictEqual(
dom.children.map(v => v.element),
[...dom.element.children]);
doc.children = [];
assert.deepStrictEqual(
dom.children.map(child => child.children),
[
'bBb',
'cCc',
]);
assert.deepStrictEqual(
dom.children.map(v => v.element),
[...dom.element.children]);
});
it('observe record', function () {
const attrs: Attrs = {
onconnect: ({ currentTarget: el }) =>
el.textContent += el.textContent![0].toUpperCase(),
ondisconnect: ({ currentTarget: el }) =>
el.textContent += el.textContent![0].toLowerCase(),
};
const dom = HTML.ul({
a: HTML.li(attrs, 'a'),
b: HTML.li(attrs, 'b'),
c: HTML.li(attrs, 'c'),
d: HTML.li(attrs, 'd'),
e: HTML.li(attrs, 'e'),
});
assert.deepStrictEqual(
Object.entries(dom.children).map(([k, v]) => [k, v.children]),
[
['a', 'a'],
['b', 'b'],
['c', 'c'],
['d', 'd'],
['e', 'e'],
]);
doc.children = [Shadow.section([dom])];
assert.deepStrictEqual(
Object.entries(dom.children).map(([k, v]) => [k, v.children]),
[
['a', 'aA'],
['b', 'bB'],
['c', 'cC'],
['d', 'dD'],
['e', 'eE'],
]);
assert.deepStrictEqual(
[...Object.values(dom.children)].map(v => v.element),
[...dom.element.children]);
dom.children = {
a: dom.children.a,
b: dom.children.c,
c: dom.children.b,
d: HTML.li(attrs, 'f'),
e: dom.children.e,
};
dom.children.e = HTML.li(attrs, 'g');
assert.deepStrictEqual(
Object.entries(dom.children).map(([k, v]) => [k, v.children]),
[
['a', 'aA'],
['b', 'cC'],
['c', 'bB'],
['d', 'fF'],
['e', 'gG'],
]);
assert.deepStrictEqual(
[...Object.values(dom.children)].map(v => v.element),
[...dom.element.children]);
doc.children = [];
assert.deepStrictEqual(
Object.entries(dom.children).map(([k, v]) => [k, v.children]),
[
['a', 'aAa'],
['b', 'cCc'],
['c', 'bBb'],
['d', 'fFf'],
['e', 'gGg'],
]);
assert.deepStrictEqual(
[...Object.values(dom.children)].map(v => v.element),
[...dom.element.children]);
});
it('shadow', function () {
assert(Shadow('section', [HTML.slot()]).element.outerHTML === '<section></section>');
assert(Shadow.section([HTML.slot()]).element.outerHTML === '<section></section>');
assert(Shadow.section([HTML.slot()]).element.shadowRoot!.innerHTML === '<slot></slot>');
assert(Shadow.section([HTML.slot()]).children[0].element.outerHTML === '<slot></slot>');
assert(Shadow.section([HTML.slot()], (h, t) => shadow(h(t), { mode: 'closed' }).host as HTMLElement).element.shadowRoot === null);
assert(Shadow.section([HTML.slot()], (h, t) => shadow(h(t), { mode: 'closed' }).host as HTMLElement).children[0].element.outerHTML === '<slot></slot>');
const dom = HTML.div([Shadow.section([HTML.slot('a')])]);
assert(dom.element.outerHTML === '<div><section></section></div>');
assert(dom.children[0].children[0].element.outerHTML === '<slot>a</slot>');
dom.children[0].children[0].children = 'b';
assert(dom.element.outerHTML === '<div><section></section></div>');
assert(dom.element.firstElementChild!.shadowRoot!.innerHTML === '<slot>b</slot>');
});
});
describe('usage', function () {
it('component', function () {
class Component implements El {
private readonly dom = HTML.section({
style: HTML.style('@scope { & { color: red; } }'),
content: HTML.ul([
HTML.li('item'),
]),
});
public readonly tag = this.dom.tag;
public readonly element = this.dom.element;
public get children() {
return this.dom.children.content.children;
}
public set children(children) {
this.dom.children.content.children = children;
}
}
(): El => new Component();
// @ts-expect-error
(): El<''> => new Component();
// @ts-expect-error
() => HTML.div().children = '';
// @ts-expect-error
() => HTML.div().children = [new Component()];
// @ts-expect-error
() => HTML.div().children = { a: new Component() };
const dom = new Component();
assert(dom.children[0].children === 'item');
dom.children = [
HTML.li('Item'),
];
assert(dom.children[0].children === 'Item');
assert(HTML.div([dom]));
});
it('component shadow', function () {
class Component implements El {
private readonly dom = Shadow.section({
style: HTML.style('@scope { & { color: red; } }'),
content: HTML.ul([
HTML.li('item'),
]),
});
public readonly tag = this.dom.tag;
public readonly element = this.dom.element;
public get children() {
return this.dom.children.content.children;
}
public set children(children) {
this.dom.children.content.children = children;
}
}
const dom = new Component();
assert(dom.children[0].children === 'item');
dom.children = [
HTML.li('Item'),
];
assert(dom.children[0].children === 'Item');
assert(HTML.div([dom]));
});
it('component coroutine', async function () {
class Component extends Coroutine<number> implements El {
constructor() {
super(async function* (this: Component) {
assert(this.element);
let count = 0;
this.text = count;
while (true) {
this.element.isConnected || await new Promise<unknown>(resolve =>
this.element.addEventListener('connect', resolve, { once: true }));
yield this.text = ++count;
}
}, { trigger: 'element', interval: 100 });
}
private set text(count: number) {
this.children = `Counted ${count} times.`;
}
private readonly dom = Shadow.section({ onconnect: '' }, {
style: HTML.style('@scope { & { color: red; } }'),
content: HTML.p(''),
});
public readonly tag = this.dom.tag;
public readonly element = this.dom.element;
public get children() {
return this.dom.children.content.children;
}
public set children(children) {
this.dom.children.content.children = children;
}
}
const dom = new Component();
assert(dom.children === 'Counted 0 times.');
doc.children = [dom];
await 0;
assert(dom.children === 'Counted 1 times.');
await wait(110);
assert(dom.children === 'Counted 2 times.');
doc.children = [];
await wait(110);
assert(dom.children === 'Counted 2 times.');
doc.children = [dom];
await 0;
assert(dom.children === 'Counted 3 times.');
for await (const count of dom) {
switch (count) {
case 3:
case 4:
case 5:
assert(dom.children === `Counted ${count} times.`);
continue;
case 6:
doc.children = [];
return;
default:
assert(false);
}
}
});
it('translate', function () {
interface TransDataMap {
'Greeting': { name: string; };
}
const translator = i18next.createInstance({
lng: 'en',
resources: {
en: {
translation: {
'Greeting': 'Hello, {{name}}.',
},
},
} satisfies Record<string, { translation: { [P in keyof TransDataMap]: string; }; }>,
});
translator.init();
function intl
<K extends keyof TransDataMap>
(text: K, data: TransDataMap[K], factory?: El.Factory<HTMLElementTagNameMap, El.Children.Void>)
: El.Factory<HTMLElementTagNameMap, El.Children.Void> {
return (html, tag) => {
const el = factory?.(html, tag, {}) ?? html(tag);
// @ts-ignore
el.textContent = translator.t(text, data)
?? `{% Failed to translate "${text}". %}`;
return el;
};
}
assert(HTML.span(intl('Greeting', { name: 'world' })).children === undefined);
assert(HTML.span(intl('Greeting', { name: 'world' })).element.textContent === 'Hello, world.');
assert(HTML.span({}, intl('Greeting', { name: 'world' })).children === undefined);
assert(HTML.span({}, intl('Greeting', { name: 'world' })).element.textContent === 'Hello, world.');
// @ts-expect-error
() => HTML.span(intl('Greeting', {}));
// @ts-expect-error
() => HTML.span(intl('', { name: 'world' }));
// @ts-expect-error
() => HTML.span('', intl('Greeting', { name: 'world' }));
function data
<K extends keyof TransDataMap>
(data: TransDataMap[K], factory?: El.Factory<HTMLElementTagNameMap, K>)
: El.Factory<HTMLElementTagNameMap, K> {
return (html, tag, _, children) =>
define(factory?.(html, tag, {}, children) ?? html(tag), {
onmutate: ev => {
// @ts-ignore
ev.currentTarget.textContent = translator.t(children, data)
?? `{% Failed to translate "${children}". %}`;
},
});
}
assert(HTML.span('Greeting', data({ name: 'world' })).children === 'Hello, world.');
assert(HTML.span('Greeting', data({ name: 'world' })).element.textContent === 'Hello, world.');
// @ts-expect-error
() => HTML.span('Greeting', data({}));
// @ts-expect-error
() => HTML.span('', data({ name: 'world' }));
// @ts-expect-error
() => HTML.span(data({ name: 'world' }));
});
});
});