packages/__tests__/src/3-runtime-html/process-content.spec.ts
import { IContainer, noop, toArray } from '@aurelia/kernel';
import { Aurelia, BindingMode, bindable, CustomElement, customElement, INode, IPlatform, processContent } from '@aurelia/runtime-html';
import { assert, TestContext } from '@aurelia/testing';
import { createSpecFunction, TestExecutionContext as $TestExecutionContext, TestFunction } from '../util.js';
describe('3-runtime-html/process-content.spec.ts', function () {
interface TestSetupContext {
template: string;
registrations: any[];
enhance: boolean;
}
class TestExecutionContext implements $TestExecutionContext<any> {
private _platform: IPlatform;
public constructor(
public ctx: TestContext,
public container: IContainer,
public host: HTMLElement,
public app: App | null,
public error: Error | null,
) { }
public get platform(): IPlatform { return this._platform ?? (this._platform = this.container.get(IPlatform)); }
}
async function testAuSlot(
testFunction: TestFunction<TestExecutionContext>,
{ template, registrations, enhance = false }: Partial<TestSetupContext> = {}
) {
const ctx = TestContext.create();
const host: HTMLElement = ctx.doc.createElement('div');
ctx.doc.body.appendChild(host);
const container = ctx.container;
const au = new Aurelia(container);
let error: Error | null = null;
let app: App | null = null;
let stop;
try {
au.register(...registrations);
if (enhance) {
host.innerHTML = template;
const enhanceRoot = await au.enhance({ host, component: CustomElement.define({ name: 'app' }, App) });
app = enhanceRoot.controller.viewModel;
stop = () => enhanceRoot.deactivate();
} else {
await au.app({ host, component: CustomElement.define({ name: 'app', template }, App) })
.start();
app = au.root.controller.viewModel;
}
} catch (e) {
error = e;
}
await testFunction(new TestExecutionContext(ctx, container, host, app, error));
if (error === null) {
await au.stop();
await stop?.();
}
ctx.doc.body.removeChild(host);
}
const $it = createSpecFunction(testAuSlot);
class App {
}
class TestData {
public constructor(
public readonly spec: string,
public readonly template: string,
public readonly registrations: any[],
public readonly expectedInnerHtmlMap: Record<string, string>,
public readonly additionalAssertion?: (ctx: TestExecutionContext) => void | Promise<void>,
public readonly enhance: boolean = false,
public readonly only: boolean = false,
) { }
}
function* getTestData() {
{
class MyElement {
public static hookInvoked: boolean = false;
public static processContent(_node: INode, _p: IPlatform) {
this.hookInvoked = true;
}
}
yield new TestData(
'a static processContent method is auto-discovered',
`<my-element normal="foo" bold="bar"></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
},
MyElement
)
],
{},
() => {
assert.strictEqual(MyElement.hookInvoked, true);
}
);
}
{
class MyElement {
public static hookInvoked: boolean = false;
public static processContent(_node: INode, _p: IPlatform) {
this.hookInvoked = true;
}
}
yield new TestData(
'a static function can be used as the processContent hook',
`<my-element normal="foo" bold="bar"></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
processContent: MyElement.processContent
},
MyElement
)
],
{},
() => {
assert.strictEqual(MyElement.hookInvoked, true);
}
);
}
// The following tests don't work. Refer: https://github.com/microsoft/TypeScript/issues/57366
// {
// @processContent(MyElement.processContent)
// @customElement({
// name: 'my-element',
// template: `<div><au-slot></au-slot></div>`,
// })
// class MyElement {
// public static hookInvoked: boolean = false;
// public static processContent(_node: INode, _p: IPlatform) {
// this.hookInvoked = true;
// }
// }
// yield new TestData(
// 'processContent hook can be configured using class-level decorator - function - order 1',
// `<my-element normal="foo" bold="bar"></my-element>`,
// [MyElement],
// {},
// () => {
// assert.strictEqual(MyElement.hookInvoked, true);
// }
// );
// }
// {
// @customElement({
// name: 'my-element',
// template: `<div><au-slot></au-slot></div>`,
// })
// @processContent(MyElement.processContent)
// class MyElement {
// public static hookInvoked: boolean = false;
// public static processContent(_node: INode, _p: IPlatform) {
// this.hookInvoked = true;
// }
// }
// yield new TestData(
// 'processContent hook can be configured using class-level decorator - function - order 2',
// `<my-element normal="foo" bold="bar"></my-element>`,
// [MyElement],
// {},
// () => {
// assert.strictEqual(MyElement.hookInvoked, true);
// }
// );
// }
{
@processContent('processContent')
@customElement({
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
})
class MyElement {
public static hookInvoked: boolean = false;
public static processContent(_node: INode, _p: IPlatform) {
this.hookInvoked = true;
}
}
yield new TestData(
'processContent hook can be configured using class-level decorator - function name - order 1',
`<my-element normal="foo" bold="bar"></my-element>`,
[MyElement],
{},
() => {
assert.strictEqual(MyElement.hookInvoked, true);
}
);
}
{
@customElement({
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
})
@processContent('processContent')
class MyElement {
public static hookInvoked: boolean = false;
public static processContent(_node: INode, _p: IPlatform) {
this.hookInvoked = true;
}
}
yield new TestData(
'processContent hook can be configured using class-level decorator - function name - order 2',
`<my-element normal="foo" bold="bar"></my-element>`,
[MyElement],
{},
() => {
assert.strictEqual(MyElement.hookInvoked, true);
}
);
}
{
function processContent1(this: typeof MyElement, _node: INode, _p: IPlatform) {
this.hookInvoked = true;
}
@processContent(processContent1)
@customElement({
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
})
class MyElement {
public static hookInvoked: boolean = false;
}
yield new TestData(
'processContent hook can be configured using class-level decorator - standalone function',
`<my-element normal="foo" bold="bar"></my-element>`,
[MyElement],
{},
() => {
assert.strictEqual(MyElement.hookInvoked, true);
}
);
}
{
@customElement({
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
})
class MyElement {
public static hookInvoked: boolean = false;
@processContent()
public static processContent(_node: INode, _p: IPlatform, _data: Record<PropertyKey, unknown>) {
this.hookInvoked = true;
}
}
yield new TestData(
'processContent hook can be configured using method-level decorator',
`<my-element normal="foo" bold="bar"></my-element>`,
[MyElement],
{},
() => {
assert.strictEqual(MyElement.hookInvoked, true);
}
);
}
yield new TestData(
'can mutate node content',
`<my-element normal="foo" bold="bar"></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
processContent(node: INode, p: IPlatform) {
const el = (node as Element);
const text = el.getAttribute('normal');
const bold = el.getAttribute('bold');
if (text !== null || bold !== null) {
const projection = p.document.createElement('template');
projection.setAttribute('au-slot', '');
const content = projection.content;
if (text !== null) {
const span = p.document.createElement('span');
span.textContent = text;
el.removeAttribute('normal');
content.append(span);
}
if (bold !== null) {
const strong = p.document.createElement('strong');
strong.textContent = bold;
el.removeAttribute('bold');
content.append(strong);
}
node.appendChild(projection);
}
}
},
class MyElement { }
)
],
{ 'my-element': '<div><span>foo</span><strong>bar</strong></div>' },
);
yield new TestData(
'default au-slot use-case',
`<my-element><span>foo</span><span au-slot="s1">s1 projection</span><strong>bar</strong></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: `<div><au-slot></au-slot><au-slot name="s1"></au-slot></div>`,
processContent(node: INode, p: IPlatform) {
const projection = p.document.createElement('template');
projection.setAttribute('au-slot', '');
const content = projection.content;
for (const child of toArray(node.childNodes)) {
if (!(child as Element).hasAttribute('au-slot')) {
content.append(child);
}
}
if (content.childElementCount > 0) {
node.appendChild(projection);
}
}
},
class MyElement { }
)
],
{ 'my-element': '<div><span>foo</span><strong>bar</strong><span>s1 projection</span></div>' },
);
const SpanCe = CustomElement.define(
{
name: 'span-ce',
template: '<span>${value}</span>',
bindables: { value: { mode: BindingMode.default } },
},
class SpanCe { }
);
const StrongCe = CustomElement.define(
{
name: 'strong-ce',
template: '<strong>${value}</strong>',
bindables: { value: { mode: BindingMode.default } },
},
class StrongCe { }
);
function processContentWithCe(compile: boolean) {
return function (node: INode, p: IPlatform): boolean {
const el = (node as Element);
const text = el.getAttribute('normal');
const bold = el.getAttribute('bold');
if (text !== null || bold !== null) {
const projection = p.document.createElement('template');
projection.setAttribute('au-slot', '');
const content = projection.content;
if (text !== null) {
const span = p.document.createElement('span-ce');
span.setAttribute('value', text);
el.removeAttribute('normal');
content.append(span);
}
if (bold !== null) {
const strong = p.document.createElement('strong-ce');
strong.setAttribute('value', bold);
el.removeAttribute('bold');
content.append(strong);
}
node.appendChild(projection);
}
return compile;
};
}
yield new TestData(
'mutated node content can contain custom-element',
`<my-element normal="foo" bold="bar"></my-element>`,
[
SpanCe,
StrongCe,
CustomElement.define(
{
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
processContent: processContentWithCe(true),
},
class MyElement { }
)
],
{ 'my-element': '<div><span-ce><span>foo</span></span-ce><strong-ce><strong>bar</strong></strong-ce></div>' },
);
function processContentWithNewBinding(compile: boolean) {
return function (node: INode, _p: IPlatform): boolean {
const el = (node as Element);
const l1 = el.getAttribute('normal')?.length ?? 0;
const l2 = el.getAttribute('bold')?.length ?? 0;
el.removeAttribute('normal');
el.removeAttribute('bold');
el.setAttribute('text-length.bind', `${l1} + ${l2}`);
return compile;
};
}
yield new TestData(
'mutated node content can have new bindings for the host element',
`<my-element normal="foo" bold="bar"></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: '${textLength}',
bindables: { textLength: { mode: BindingMode.default } },
processContent: processContentWithNewBinding(true),
},
class MyElement { }
)
],
{ 'my-element': '6' },
);
yield new TestData(
'host compilation cannot be skipped', // as that does not make any sense
`<my-element normal="foo" bold="bar"></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: '${textLength}',
bindables: { textLength: { mode: BindingMode.default } },
processContent: processContentWithNewBinding(false),
},
class MyElement { }
)
],
{ 'my-element': '6' },
);
yield new TestData(
'compilation can be instructed to be skipped - children - w/o additional host binding',
`<my-element normal="foo" bold="bar"></my-element>`,
[
SpanCe,
StrongCe,
CustomElement.define(
{
name: 'my-element',
template: `<div><au-slot></au-slot></div>`,
processContent: processContentWithCe(false),
},
class MyElement { }
)
],
{ 'my-element': '<template au-slot=""><span-ce value="foo"></span-ce><strong-ce value="bar"></strong-ce></template><div></div>' },
);
const rand = Math.random();
yield new TestData(
'compilation can be instructed to be skipped - children - with additional host binding',
`<my-element normal="foo" bold="bar"></my-element>`,
[
SpanCe,
StrongCe,
CustomElement.define(
{
name: 'my-element',
template: '${rand}<div><au-slot></au-slot></div>',
bindables: { rand: { mode: BindingMode.default } },
processContent(node: INode, p: IPlatform) {
const retVal = processContentWithCe(false)(node, p);
(node as Element).setAttribute('rand.bind', rand.toString());
return retVal;
}
},
class MyElement { }
)
],
{ 'my-element': `<template au-slot=""><span-ce value="foo"></span-ce><strong-ce value="bar"></strong-ce></template>${rand}<div></div>` },
);
yield new TestData(
'works with enhance',
`<my-element normal="foo" bold="bar"></my-element>`,
[
SpanCe,
StrongCe,
CustomElement.define(
{
name: 'my-element',
template: '<div><au-slot></au-slot></div>',
processContent: processContentWithCe(true),
},
class MyElement { }
)
],
{ 'my-element': `<div><span-ce><span>foo</span></span-ce><strong-ce><strong>bar</strong></strong-ce></div>` },
noop,
true,
);
/**
* MDN template example: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template#Examples
* Note that this is also possible without `processContent` hook, by adding the named template directly to the CE's own defined template.
* This example only shows that the new nodes added via the `processContent` hook are accessible from the lifecycle hooks as well.
*/
yield new TestData(
'compilation can be instructed to be skipped - children - example of grabbing the inner template',
`<my-element products.bind="[[1,'a'],[2,'b']]"></my-element>`,
[
CustomElement.define(
{
name: 'my-element',
template: `<table><thead><tr><td>UPC_Code</td><td>Product_Name</td></tr></thead><tbody></tbody></table>`,
bindables: { products: { mode: BindingMode.default } },
processContent(node: INode, p: IPlatform) {
/*
<template id="productrow">
<tr>
<td></td>
<td></td>
</tr>
</template>
*/
const template = p.document.createElement('template');
template.setAttribute('id', 'productrow');
const tr = p.document.createElement('tr');
tr.append(p.document.createElement('td'), p.document.createElement('td'));
template.content.append(tr);
node.appendChild(template);
return false;
}
},
class MyElement {
public static inject = [IPlatform];
public products: [number, string][];
public constructor(private readonly platform: IPlatform) { }
public attaching() {
const p = this.platform;
const tbody = p.document.querySelector('tbody');
const template = p.document.querySelector<HTMLTemplateElement>('#productrow');
for (const [code, name] of this.products) {
// Clone the new row and insert it into the table
const clone = template.content.cloneNode(true) as Element;
const td = clone.querySelectorAll('td');
td[0].textContent = code.toString();
td[1].textContent = name;
tbody.appendChild(clone);
}
}
}
)
],
{ 'my-element': '<template id="productrow"><tr><td></td><td></td></tr></template><table><thead><tr><td>UPC_Code</td><td>Product_Name</td></tr></thead><tbody><tr><td>1</td><td>a</td></tr><tr><td>2</td><td>b</td></tr></tbody></table>' },
function (ctx) {
assert.visibleTextEqual(ctx.host, 'UPC_CodeProduct_Name1a2b');
}
);
}
for (const { spec, template, expectedInnerHtmlMap, registrations, additionalAssertion, enhance, only } of getTestData()) {
(only ? $it.only : $it)(spec,
async function (ctx) {
const { host, error } = ctx;
assert.deepEqual(error, null);
for (const [selector, expectedInnerHtml] of Object.entries(expectedInnerHtmlMap)) {
if (selector) {
assert.html.innerEqual(selector, expectedInnerHtml, `${selector}.innerHTML`, host);
} else {
assert.html.innerEqual(host, expectedInnerHtml, `root.innerHTML`);
}
}
if (additionalAssertion != null) {
await additionalAssertion(ctx);
}
},
{ template, registrations, enhance });
}
// A semi-real-life example
{
@customElement({
name: 'tabs',
template: '<div class="header"><au-slot name="header"></au-slot></div><div class="content"><au-slot name="content"></au-slot></div>',
// processContent: Tabs.processTabs // <- this won't work; refer: https://github.com/microsoft/TypeScript/issues/57366
})
class Tabs {
@bindable public activeTabId: string;
public showTab(tabId: string) {
this.activeTabId = tabId;
}
@processContent()
public static processTabs(node: INode, p: IPlatform) {
const el = node as Element;
const headerTemplate = p.document.createElement('template');
headerTemplate.setAttribute('au-slot', 'header');
const contentTemplate = p.document.createElement('template');
contentTemplate.setAttribute('au-slot', 'content');
const tabs = toArray(el.querySelectorAll('tab'));
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
// add header
const header = p.document.createElement('button');
header.setAttribute('class.bind', `$host.activeTabId=='${i}'?'active':''`);
header.setAttribute('click.trigger', `$host.showTab('${i}')`);
header.appendChild(p.document.createTextNode(tab.getAttribute('header')));
headerTemplate.content.appendChild(header);
// add content
const content = p.document.createElement('div');
content.setAttribute('if.bind', `$host.activeTabId=='${i}'`);
content.append(...toArray(tab.childNodes));
contentTemplate.content.appendChild(content);
el.removeChild(tab);
}
el.setAttribute('active-tab-id', '0');
el.append(headerTemplate, contentTemplate);
}
}
$it('semi-real-life example with tabs',
async function (ctx) {
const platform = ctx.platform;
const host = ctx.host;
const tabs = host.querySelector('tabs');
const headers = tabs.querySelectorAll<HTMLButtonElement>('div.header button');
const numTabs = headers.length;
const expectedHeaders = ['Tab1', 'Tab2', 'Tab3'];
const expectedContents = ['<span>content 1</span>', '<span>content 2</span>', 'Nothing to see here.'];
for (let i = numTabs - 1; i > -1; i--) {
// assert content
const header = headers[i];
assert.html.textContent(header, expectedHeaders[i], `header#${i} content`);
// assert the bound trigger
header.click();
platform.domQueue.flush();
for (let j = numTabs - 1; j > -1; j--) {
assert.strictEqual(headers[j].classList.contains('active'), i === j, `header#${j} class`);
assert.html.innerEqual(tabs.querySelector<HTMLButtonElement>('div.content div'), expectedContents[i], `content#${i} content`);
}
}
},
{
registrations: [Tabs],
template: `
<tabs>
<tab header="Tab1">
<span>content 1</span>
</tab>
<tab header="Tab2">
<span>content 2</span>
</tab>
<tab header="Tab3"> Nothing to see here. </tab>
</tabs>
`,
});
}
});