@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
823 lines (770 loc) • 21.7 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Effects Tests</title>
</head>
<body>
<update-text text="Text from Attribute">
<p>Text from Server</p>
</update-text>
<update-property value="Value from Attribute">
<input type="text" value="Value from Server" />
</update-property>
<update-attribute required>
<input type="text" required />
<p id="update-attribute-error">Please fill this required field</p>
</update-attribute>
<update-class active="0">
<ul>
<li data-index="0">Item 1</li>
<li data-index="1">Item 2</li>
<li class="selected" data-index="2">Item 3</li>
</ul>
</update-class>
<update-style color="red">
<p style="color: blue">Text from Server</p>
</update-style>
<dangerous-inner-html id="dangerous"></dangerous-inner-html>
<shadow-dangerous-inner-html
id="shadow-dangerous"
></shadow-dangerous-inner-html>
<dangerous-with-scripts></dangerous-with-scripts>
<create-element>
<ul></ul>
</create-element>
<remove-element>
<ul>
<li data-key="1">Item 1</li>
<li data-key="2">Item 2</li>
<li data-key="3">Item 3</li>
</ul>
</remove-element>
<insert-template id="insert-light">
<template class="li">
<li></li>
</template>
<template class="p">
<p></p>
</template>
<ul></ul>
</insert-template>
<insert-template id="insert-shadow">
<template class="li">
<li></li>
</template>
<template class="p">
<p></p>
</template>
<template shadowrootmode="open">
<ul></ul>
</template>
</insert-template>
<script type="module">
import { runTests } from "@web/test-runner-mocha";
import { assert } from "@esm-bundle/chai";
import {
component,
first,
all,
on,
computed,
effect,
RESET,
UNSET,
asBoolean,
asInteger,
asNumber,
asString,
setText,
setProperty,
setAttribute,
toggleAttribute,
toggleClass,
setStyle,
dangerouslySetInnerHTML,
insertOrRemoveElement,
} from "../index.dev.js";
const animationFrame = async () =>
new Promise(requestAnimationFrame);
const normalizeText = (text) => text.replace(/\s+/g, " ").trim();
component(
"update-text",
{
text: asString(),
},
() => [first("p", setText("text"))],
);
component(
"update-property",
{
value: asString(),
},
() => [first("input", setProperty("value"))],
);
component(
"update-attribute",
{
required: asBoolean,
ariaInvalid: "false",
},
(el) => [
first(
"input",
on("change", (e) => {
el.ariaInvalid = String(!e.target.checkValidity());
}),
toggleAttribute("required"),
setAttribute("aria-errormessage", () =>
el.required && el.ariaInvalid !== "false"
? el.querySelector("p").id
: RESET,
),
),
],
);
component(
"update-class",
{
active: asInteger(),
},
(el) => [
all(
"li",
toggleClass(
"selected",
(target) =>
el.active === parseInt(target.dataset.index),
),
),
],
);
component(
"update-style",
{
color: asString(),
},
() => [first("p", setStyle("color"))],
);
component(
"dangerous-inner-html",
{
content: "<p>Initial content</p>",
},
() => [dangerouslySetInnerHTML("content")],
);
component(
"shadow-dangerous-inner-html",
{
content: "<p>Initial shadow content</p>",
},
() => [dangerouslySetInnerHTML("content", "open")],
);
component(
"dangerous-with-scripts",
{
content: '<p id="test-p-shadow">Original</p>',
},
() => [dangerouslySetInnerHTML("content", "open", true)],
);
component(
"create-element",
{
before: 0,
prepend: 0,
append: 0,
after: 0,
},
(el) => [
first(
"ul",
insertOrRemoveElement("before", {
create: () => {
const p = document.createElement("p");
p.textContent = "Before";
return p;
},
position: "beforebegin",
}),
insertOrRemoveElement("prepend", {
create: () => {
const li = document.createElement("li");
li.textContent = "Prepend";
li.setAttribute("value", "foo");
return li;
},
position: "afterbegin",
}),
insertOrRemoveElement("append", {
create: () => document.createElement("li"),
}),
insertOrRemoveElement("after", {
create: () => {
const p = document.createElement("p");
p.setAttribute("value", "bar");
return p;
},
position: "afterend",
}),
),
],
);
component(
"remove-element",
{
items: [1, 2, 3],
},
(el) => [
all(
"li",
insertOrRemoveElement((li) =>
el.items.includes(parseInt(li.dataset.key))
? 0
: -1,
),
),
],
);
component(
"insert-template",
{
before: 0,
prepend: 0,
append: 0,
after: 0,
},
(el) => {
const pTemplate = el.querySelector(".p");
const liTemplate = el.querySelector(".li");
return [
first(
"ul",
insertOrRemoveElement("before", {
create: () =>
document.importNode(pTemplate.content, true)
.firstElementChild,
position: "beforebegin",
}),
insertOrRemoveElement("prepend", {
create: () =>
document.importNode(
liTemplate.content,
true,
).firstElementChild,
position: "afterbegin",
}),
insertOrRemoveElement("append", {
create: () =>
document.importNode(
liTemplate.content,
true,
).firstElementChild,
}),
insertOrRemoveElement("after", {
create: () =>
document.importNode(pTemplate.content, true)
.firstElementChild,
position: "afterend",
}),
),
];
},
);
runTests(() => {
describe("setText", function () {
it("should prove setText() working correctly", async function () {
const component = document.querySelector("update-text");
const paragraph = component.querySelector("p");
await animationFrame();
let textContent = normalizeText(paragraph.textContent);
assert.equal(
textContent,
"Text from Attribute",
"Should display text content from attribute",
);
component.text = "New Text";
await animationFrame();
textContent = normalizeText(paragraph.textContent);
assert.equal(
textContent,
"New Text",
"Should update text content from text signal",
);
component.text = RESET;
await animationFrame();
textContent = normalizeText(paragraph.textContent);
assert.equal(
textContent,
"Text from Server",
"Should revert text content to server-rendered version",
);
});
});
describe("setProperty", function () {
it("should prove setProperty() working correctly", async function () {
const component =
document.querySelector("update-property");
const input = component.querySelector("input");
await animationFrame();
assert.equal(
input.value,
"Value from Attribute",
"Should display value from attribute",
);
component.value = "New Value";
await animationFrame();
assert.equal(
input.value,
"New Value",
"Should update value from text signal",
);
component.value = RESET;
await animationFrame();
assert.equal(
input.value,
"Value from Server",
"Should revert value to server-rendered version",
);
});
});
describe("setAttribute and toggleAttribute", function () {
it("should prove setAttribute() and toggleAttribute() working correctly", async function () {
const component =
document.querySelector("update-attribute");
const input = component.querySelector("input");
await animationFrame();
assert.equal(
input.required,
true,
"Should set required property from attribute",
);
assert.equal(
input.hasAttribute("aria-errormessage"),
false,
"Should not have aria-errormessage before interaction",
);
input.value = "New Value";
input.dispatchEvent(new Event("change"));
await animationFrame();
assert.equal(
input.hasAttribute("aria-errormessage"),
false,
"Should not have aria-errormessage if field is not empty",
);
input.value = "";
input.dispatchEvent(new Event("change"));
await animationFrame();
assert.equal(
input.hasAttribute("aria-errormessage"),
true,
"Should have aria-errormessage if field is empty and required",
);
component.toggleAttribute("required");
await animationFrame();
assert.equal(
input.hasAttribute("aria-errormessage"),
false,
"Should not have aria-errormessage if field is not required",
);
component.required = RESET;
await animationFrame();
assert.equal(
input.required,
true,
"Should revert required attribute to server-rendered version",
);
});
});
describe("toggleClass", function () {
it("should prove toggleClass() working correctly", async function () {
const component =
document.querySelector("update-class");
const items = Array.from(
component.querySelectorAll("li"),
);
await animationFrame();
assert.equal(
items[0].classList.contains("selected"),
true,
"First item should have selected class from active attribute",
);
assert.equal(
items[2].classList.contains("selected"),
false,
"Third item should not have selected class removed",
);
component.active = 1;
await animationFrame();
assert.equal(
items[1].classList.contains("selected"),
true,
"Second item should have selected class from active signal",
);
assert.equal(
items[0].classList.contains("selected"),
false,
"First item should not have selected class removed",
);
component.active = RESET;
await animationFrame();
assert.equal(
items[1].classList.contains("selected"),
false,
"Second item should have selected class removed",
);
// restore can't work because the selected class for each item is derived on the fly and not stored in a signal
// assert.equal(items[2].classList.contains('selected'), true, 'Third item should not have selected class restored to server-rendered version')
});
});
describe("setStyle", function () {
it("should prove setStyle() working correctly", async function () {
const component =
document.querySelector("update-style");
const paragraph = component.querySelector("p");
await animationFrame();
assert.equal(
paragraph.style.color,
"red",
"Should set color from attribute",
);
component.color = "green";
await animationFrame();
assert.equal(
paragraph.style.color,
"green",
"Should update color from color signal",
);
component.color = RESET;
await animationFrame();
assert.equal(
paragraph.style.color,
"blue",
"Should revert color to server-rendered version",
);
});
});
describe("dangerouslySetInnerHTML", () => {
let dangerous, shadowDangerous;
before(() => {
dangerous = document.getElementById("dangerous");
shadowDangerous =
document.getElementById("shadow-dangerous");
});
it("should set inner HTML for non-shadow component", async () => {
assert.equal(
dangerous.innerHTML,
"<p>Initial content</p>",
);
dangerous.content = "<div>Updated content</div>";
await animationFrame();
assert.equal(
dangerous.innerHTML,
"<div>Updated content</div>",
);
});
it("should set inner HTML for shadow component", async () => {
assert.equal(
shadowDangerous.shadowRoot.innerHTML,
"<p>Initial shadow content</p>",
);
shadowDangerous.content =
"<div>Updated shadow content</div>";
await animationFrame();
assert.equal(
shadowDangerous.shadowRoot.innerHTML,
"<div>Updated shadow content</div>",
);
});
it("should ignore empty content", async () => {
dangerous.content = "";
await animationFrame();
assert.equal(
dangerous.innerHTML,
"<div>Updated content</div>",
);
shadowDangerous.content = "";
await animationFrame();
assert.equal(
shadowDangerous.shadowRoot.innerHTML,
"<div>Updated shadow content</div>",
);
});
it("should not execute scripts by default, but allow execution when specified", async () => {
const scriptContent =
'ipt>document.getElementById("test-p").textContent = "Modified";</scr';
const shadowScriptContent =
'ipt>document.querySelector("dangerous-with-scripts").shadowRoot.getElementById("test-p-shadow").textContent = "Modified";</scr';
// Test default behavior (scripts not executed)
dangerous.content = `<p id="test-p">Original</p><scr${scriptContent}ipt>`;
await animationFrame();
assert.equal(
dangerous.querySelector("#test-p").textContent,
"Original",
"Script should not modify content by default",
);
const dangerousWithScripts = document.querySelector(
"dangerous-with-scripts",
);
dangerousWithScripts.content = `<p id="test-p-shadow">Original</p><scr${shadowScriptContent}ipt>`;
await animationFrame();
assert.equal(
dangerousWithScripts.shadowRoot.querySelector(
"#test-p-shadow",
).textContent,
"Modified",
"Script should modify content when allowScripts is true",
);
});
});
describe("createElement", function () {
let createElementComponent;
before(() => {
createElementComponent =
document.querySelector("create-element");
});
it("should insert a paragraph before the UL", async function () {
createElementComponent.before = 1;
await animationFrame();
const insertedParagraph =
createElementComponent.querySelector(
"p:first-child",
);
assert.isNotNull(
insertedParagraph,
"Paragraph should be inserted before the UL",
);
assert.equal(
insertedParagraph.textContent,
"Before",
"Paragraph should have correct text content",
);
assert.equal(
createElementComponent.before,
0,
"Before signal should be reset to 0",
);
});
it("should insert a LI at the beginning of the UL", async function () {
createElementComponent.prepend = 1;
await animationFrame();
const insertedLi =
createElementComponent.querySelector(
"ul li:first-child",
);
assert.isNotNull(
insertedLi,
"LI should be inserted at the beginning of the UL",
);
assert.equal(
insertedLi.textContent,
"Prepend",
"LI should have correct text content",
);
assert.equal(
insertedLi.getAttribute("value"),
"foo",
"LI should have correct attribute",
);
assert.equal(
createElementComponent.prepend,
0,
"Prepend signal should be reset to 0",
);
});
it("should insert a LI at the end of the UL", async function () {
createElementComponent.append = 1;
await animationFrame();
const insertedLi =
createElementComponent.querySelector(
"ul li:last-child",
);
assert.isNotNull(
insertedLi,
"LI should be inserted at the end of the UL",
);
assert.equal(
insertedLi.textContent,
"",
"LI should have empty text content",
);
assert.equal(
createElementComponent.append,
0,
"Append signal should be reset to 0",
);
});
it("should insert a paragraph after the UL", async function () {
createElementComponent.after = 1;
await animationFrame();
const insertedParagraph =
createElementComponent.querySelector("ul + p");
assert.isNotNull(
insertedParagraph,
"Paragraph should be inserted after the UL",
);
assert.equal(
insertedParagraph.textContent,
"",
"Paragraph should have empty text content",
);
assert.equal(
insertedParagraph.getAttribute("value"),
"bar",
"Paragraph should have correct attribute",
);
assert.equal(
createElementComponent.after,
0,
"After signal should be reset to 0",
);
});
it("should allow re-triggering effects", async function () {
// Re-trigger the 'before' effect
createElementComponent.before = true;
await animationFrame();
const beforeParagraph2 =
createElementComponent.querySelector(
"p:first-child + p",
);
assert.isNotNull(
beforeParagraph2,
"LI should be inserted at the beginning of the UL",
);
// Re-trigger the 'prepend' effect
createElementComponent.prepend = true;
await animationFrame();
const prependLis =
createElementComponent.querySelectorAll(
'li[value="foo"]',
);
assert.equal(
prependLis.length,
2,
"Should insert another LI at the beginning of the UL",
);
// Verify that all signals are reset to false
assert.equal(
createElementComponent.before,
false,
"Before signal should be reset to false",
);
assert.equal(
createElementComponent.prepend,
false,
"Prepend signal should be reset to false",
);
assert.equal(
createElementComponent.append,
false,
"Append signal should be reset to false",
);
assert.equal(
createElementComponent.after,
false,
"After signal should be reset to false",
);
});
});
describe("removeElement", function () {
let removeElementComponent;
before(() => {
removeElementComponent =
document.querySelector("remove-element");
});
it("should remove an item using immutable update (toSpliced)", async function () {
removeElementComponent.items =
removeElementComponent.items.toSpliced(1, 1);
await animationFrame();
const items =
removeElementComponent.querySelectorAll("li");
assert.equal(
items.length,
2,
"Should have 2 items after removal",
);
assert.equal(
items[0].textContent,
"Item 1",
"First item should remain",
);
assert.equal(
items[1].textContent,
"Item 3",
"Third item should now be second",
);
});
/** Mutable updates don't work
* @TODO log a warning
it('should remove an item using mutable update (splice)', async function () {
removeElementComponent.set('items', v => v.splice(0, 1));
await animationFrame();
const items = removeElementComponent.querySelectorAll('li');
assert.equal(items.length, 1, 'Should have 1 item after removal');
assert.equal(items[0].textContent, 'Item 3', 'Third item should remain last');
}); */
it("should handle removing all items", async function () {
removeElementComponent.items = [];
await animationFrame();
const items =
removeElementComponent.querySelectorAll("li");
assert.equal(
items.length,
0,
"Should remove all items",
);
});
});
describe("insertTemplate", function () {
const testInsertTemplate = async (id) => {
const component = document.getElementById(id);
const root =
id === "insert-shadow"
? component.shadowRoot
: component;
const ul = root.querySelector("ul");
await animationFrame();
assert.equal(
ul.children.length,
0,
"Initially, ul should be empty",
);
component.before = 1;
await animationFrame();
assert.equal(
ul.previousElementSibling.tagName,
"P",
"Should insert p element before ul",
);
component.prepend = 1;
await animationFrame();
assert.equal(
ul.firstElementChild.tagName,
"LI",
"Should prepend li element to ul",
);
component.append = 1;
await animationFrame();
assert.equal(
ul.lastElementChild.tagName,
"LI",
"Should append li element to ul",
);
component.after = 1;
await animationFrame();
assert.equal(
ul.nextElementSibling.tagName,
"P",
"Should insert p element after ul",
);
};
it("should prove insertTemplate() working correctly in light DOM", async function () {
await testInsertTemplate("insert-light");
});
it("should prove insertTemplate() working correctly in Shadow DOM", async function () {
await testInsertTemplate("insert-shadow");
});
});
});
</script>
</body>
</html>