@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
1,100 lines (1,044 loc) • 30.1 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Component Tests</title>
</head>
<body>
<style>
.visually-hidden {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
my-counter {
display: flex;
flex-direction: row;
gap: 1rem;
& p {
margin-block: 0.2rem;
}
}
tab-group > [role="tablist"] {
display: flex;
gap: 0.2rem;
padding: 0;
& button[aria-selected="true"] {
color: purple;
}
}
lazy-load .error {
color: red;
}
post-reply div {
margin-left: 2rem;
}
</style>
<void-component id="void">
<h1>Hello from Server</h1>
</void-component>
<void-component id="void2">
<h1>Hello from Server</h1>
</void-component>
<causal-component id="causal">
<h1>Hello from Server</h1>
</causal-component>
<causal-component
id="causal-with-ignored-attribute"
heading="Hello from Attribute"
>
<h1>Hello from Server</h1>
</causal-component>
<updating-component id="updating">
<h1>Hello from Server</h1>
<p>Number of unread messages: <span></span></p>
</updating-component>
<updating-component
id="updating-with-string-attribute"
heading="Hello from Attribute"
>
<h1>Hello from Server</h1>
<p>Number of unread messages: <span></span></p>
</updating-component>
<updating-component
id="updating-with-number-attribute"
count="42"
step="0.1"
value="3.14"
>
<h1>Hello from Server</h1>
<p>Number of unread messages: <span></span></p>
<input type="number" />
</updating-component>
<updating-component id="updating-with-boolean-attribute" selected>
<h1>Hello from Server</h1>
<p>Number of unread messages: <span></span></p>
</updating-component>
<my-counter value="42">
<p>Count: <span class="value">42</span></p>
<p>Parity: <span class="parity">even</span></p>
<p>Double: <span class="double">84</span></p>
<div>
<button class="decrement">–</button>
<button class="increment">+</button>
</div>
</my-counter>
<greeting-configurator>
<input-field class="first">
<input type="text" name="first" value="Jane" />
</input-field>
<input-field class="last">
<input type="text" name="last" value="Doe" />
</input-field>
<input-checkbox>
<input type="checkbox" name="fullname" />
</input-checkbox>
<hello-world>
<p>
<span class="greeting">Hello</span>
<span class="name">World</span>
</p>
</hello-world>
</greeting-configurator>
<lazy-load src="/test/mock/lazy-load.html" id="lazy-success">
<p class="loading" role="status">Loading...</p>
<p class="error" role="alert" aria-live="polite" hidden></p>
</lazy-load>
<lazy-load src="/test/mock/404.html" id="lazy-error">
<p class="loading" role="status">Loading...</p>
<p class="error" role="alert" aria-live="polite" hidden></p>
</lazy-load>
<lazy-load src="/test/mock/recursion.html" id="lazy-recursion">
<p class="loading" role="status">Loading...</p>
<p class="error" role="alert" aria-live="polite" hidden></p>
</lazy-load>
<tab-group>
<div role="tablist">
<button
type="button"
role="tab"
id="trigger1"
aria-controls="panel1"
aria-selected="true"
tabindex="0"
>
Tab 1
</button>
<button
type="button"
role="tab"
id="trigger2"
aria-controls="panel2"
aria-selected="false"
tabindex="-1"
>
Tab 2
</button>
<button
type="button"
role="tab"
id="trigger3"
aria-controls="panel3"
aria-selected="false"
tabindex="-1"
>
Tab 3
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="trigger1">
Tab 1 content
</div>
<div role="tabpanel" id="panel2" aria-labelledby="trigger2" hidden>
Tab 2 content
</div>
<div role="tabpanel" id="panel3" aria-labelledby="trigger3" hidden>
Tab 3 content
</div>
</tab-group>
<template id="post-reply-template">
<div>
<p></p>
<post-reply>
<button type="button" class="reply-button">Reply</button>
<form hidden>
<label>
<span>Message:</span>
<textarea required></textarea>
</label>
<button type="submit">Submit</button>
</form>
</post-reply>
</div>
</template>
<post-reply message="My two cents">
<button type="button">Reply</button>
<form hidden>
<label>
<span>Message:</span>
<textarea required></textarea>
</label>
<button type="submit">Submit</button>
</form>
</post-reply>
<script type="module">
import { runTests } from "@web/test-runner-mocha";
import { assert } from "@esm-bundle/chai";
import {
RESET,
component,
first,
all,
on,
pass,
state,
asBoolean,
asInteger,
asNumber,
asString,
setText,
setProperty,
setAttribute,
toggleAttribute,
toggleClass,
setStyle,
dangerouslySetInnerHTML,
insertOrRemoveElement,
} from "../index.dev.js";
const wait = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
const animationFrame = async () =>
new Promise(requestAnimationFrame);
const normalizeText = (text) => text.replace(/\s+/g, " ").trim();
const LOADING_DELAY = 800; // Adjust this if tests for LazyLoad fail; needs to be high enough so initial loading message can be observed before it gets removed when async connectedCallback() finishes
component("void-component", {}, () => []);
component(
"causal-component",
{
heading: "Hello from Internal State",
},
() => [first("h1", setText("heading"))],
);
component(
"updating-component",
{
heading: asString(RESET),
count: asInteger(),
step: asNumber(),
value: (el, value) => {
if (value == null) return RESET;
const parsed = Number.isInteger(el.step)
? parseInt(value, 10)
: parseFloat(value);
return Number.isFinite(parsed) ? parsed : RESET;
},
selected: asBoolean,
},
() => [
first("h1", setText("heading"), toggleClass("selected")),
first("span", setText("count")),
first("input", setAttribute("step"), setProperty("value")),
],
);
component(
"my-counter",
{
value: asInteger(),
},
(el) => [
first(
".decrement",
on("click", () => {
el.value--;
}),
),
first(
".increment",
on("click", () => {
el.value++;
}),
),
first(".value", setText("value")),
first(
".parity",
setText(() => (el.value % 2 ? "odd" : "even")),
),
first(
".double",
setText(() => el.value * 2),
),
],
);
component("greeting-configurator", {}, (el) => [
first(
"hello-world",
pass({
name: () => {
const firstName = el.querySelector(".first");
const lastName = el.querySelector(".last");
const useFullname =
el.querySelector("input-checkbox");
return useFullname.checked
? `${firstName.value} ${lastName.value}`
: firstName.value;
},
}),
),
]);
component(
"hello-world",
{
greeting: asString(RESET),
name: asString("World"),
},
() => [
first(".greeting", setText("greeting")),
first(".name", setText("name")),
],
);
component(
"input-field",
{
value: asString(RESET),
},
(el) => [
first(
"input",
setProperty("value"),
on("change", (e) => {
el.value = e.target.value;
}),
),
],
);
component(
"input-checkbox",
{
checked: asBoolean,
},
(el) => [
first(
"input",
setProperty("checked"),
on("change", (e) => {
el.checked = e.target.checked;
}),
),
],
);
component(
"lazy-load",
{
error: "",
src: (el, v) => {
// Custom attribute parser
if (!v) {
el.error = "No URL provided in src attribute";
return "";
} else if (
(
el.parentElement || el.getRootNode().host
)?.closest(`${el.localName}[src="${v}"]`)
) {
el.error = "Recursive loading detected";
return "";
}
const url = new URL(v, location.href); // Ensure 'src' attribute is a valid URL
if (url.origin === location.origin) {
// Sanity check for cross-origin URLs
el.error = ""; // Success: wipe previous error if there was any
return String(url);
}
el.error = "Invalid URL origin";
return "";
},
content: (el) => async (abort) => {
// Async Computed callback
const url = el.src;
if (!url) return "";
try {
const response = await fetch(url, {
signal: abort,
});
await wait(LOADING_DELAY);
el.querySelector(".loading")?.remove();
if (response.ok) return response.text();
else el.error = response.statusText;
} catch (error) {
el.error = error.message;
}
return "";
},
},
(el) => [
first(
".error",
setText("error"),
setProperty("hidden", () => !el.error),
),
dangerouslySetInnerHTML("content", "open"),
],
);
component(
"tab-group",
{
selected: "",
},
(el) => {
el.selected =
el
.querySelector('[role="tab"][aria-selected="true"]')
.getAttribute("aria-controls") ?? "";
const isSelected = (target) =>
el.selected === target.getAttribute("aria-controls");
const tabs = Array.from(
el.querySelectorAll('[role="tab"]'),
);
let focusIndex = 0;
return [
first(
'[role="tablist"]',
on("keydown", () => {}),
),
all(
'[role="tab"]',
on("click", (e) => {
el.selected =
e.currentTarget.getAttribute(
"aria-controls",
) ?? "";
focusIndex = tabs.findIndex((tab) =>
isSelected(tab),
);
}),
setProperty("ariaSelected", (target) =>
String(isSelected(target)),
),
setProperty("tabIndex", (target) =>
isSelected(target) ? 0 : -1,
),
),
all(
'[role="tabpanel"]',
setProperty(
"hidden",
(target) => el.selected !== target.id,
),
),
];
},
);
component("post-reply", {}, (el) => {
const message = state("");
const formIsHidden = state(true);
return [
first(
"button",
on("click", () => {
formIsHidden.set(false);
}),
),
first(
"form",
on("submit", (e) => {
e.preventDefault();
message.set(el.querySelector("textarea").value);
formIsHidden.set(true);
}),
setProperty("hidden", formIsHidden),
),
insertOrRemoveElement(() => !!message.get(), {
create: () => {
const post = document.importNode(
document.getElementById("post-template")
.content,
true,
);
post.querySelector("h2").textContent =
message.get();
return post;
},
}),
];
});
runTests(() => {
describe("Void component", function () {
it("should be an instance of HTMLElement", async function () {
const voidComponent = document.getElementById("void");
assert.instanceOf(voidComponent, HTMLElement);
assert.equal(voidComponent.localName, "void-component");
});
it("should do nothing at all", async function () {
const voidComponent = document.getElementById("void");
await animationFrame();
const textContent = normalizeText(
voidComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Server",
"Should not change server-side rendered heading",
);
});
it("should return false with in for unset state", async function () {
const voidComponent = document.getElementById("void");
assert.equal("test" in voidComponent, false);
assert.equal(
voidComponent.hasOwnProperty("test"),
false,
);
});
});
describe("Causal component", function () {
it("should update according to internal state", async function () {
const causalComponent =
document.getElementById("causal");
await animationFrame();
const textContent = normalizeText(
causalComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Internal State",
"Should have initial heading from internal state",
);
});
it("should update when state is set", async function () {
const causalComponent =
document.getElementById("causal");
causalComponent.heading = "Hello from State";
await animationFrame();
const textContent = normalizeText(
causalComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from State",
"Should update text content from setting heading state",
);
});
it("should update after a delay when state is set", async function () {
const causalComponent =
document.getElementById("causal");
const delay = Math.floor(Math.random() * 200);
await wait(delay);
causalComponent.heading = "Hello from Delayed State";
await animationFrame();
const textContent = normalizeText(
causalComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Delayed State",
"Should update text content from setting heading state after a delay",
);
});
it("should ignore non-observed attributes", async function () {
const causalComponent = document.getElementById(
"causal-with-ignored-attribute",
);
await animationFrame();
const textContent = normalizeText(
causalComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Internal State",
"Should have initial heading from internal state",
);
});
});
describe("Updating component", function () {
it("should do nothing if attribute is not set", async function () {
const updatingComponent =
document.getElementById("updating");
await animationFrame();
const textContent = normalizeText(
updatingComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Server",
"Should not change server-side rendered heading",
);
});
it("should update from initial string attribute", async function () {
const updatingComponent = document.getElementById(
"updating-with-string-attribute",
);
await animationFrame();
const textContent = normalizeText(
updatingComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Attribute",
"Should have initial heading from string attribute",
);
});
it("should update from initial integer number attribute", async function () {
const updatingComponent = document.getElementById(
"updating-with-number-attribute",
);
await animationFrame();
const textContent = normalizeText(
updatingComponent.querySelector("span").textContent,
);
assert.equal(
textContent,
"42",
"Should have initial count from numeric attribute",
);
});
it("should update from initial floating point number attribute", async function () {
const updatingComponent = document.getElementById(
"updating-with-number-attribute",
);
await animationFrame();
const stepAttribute = updatingComponent
.querySelector("input")
.getAttribute("step");
assert.equal(
stepAttribute,
"0.1",
"Should have initial step attribute from floating point number attribute",
);
});
it("should update from initial custom parser attribute", async function () {
const updatingComponent = document.getElementById(
"updating-with-number-attribute",
);
await animationFrame();
const valueAttribute =
updatingComponent.querySelector("input").value;
assert.equal(
valueAttribute,
"3.14",
"Should have initial value attribute from custom parser attribute",
);
});
it("should add class from boolean attribute", async function () {
const updatingComponent = document.getElementById(
"updating-with-boolean-attribute",
);
await animationFrame();
const className =
updatingComponent.querySelector("h1").className;
assert.equal(
className,
"selected",
"Should have initial class from boolean attribute",
);
});
it("should update when string attribute set", async function () {
const updatingComponent = document.getElementById(
"updating-with-string-attribute",
);
updatingComponent.setAttribute(
"heading",
"Hello from Changed Attribute",
);
await animationFrame();
const textContent = normalizeText(
updatingComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from Changed Attribute",
"Should update text content from setting heading attribute",
);
});
it("should update when numeric attribute is set", async function () {
const updatingComponent = document.getElementById(
"updating-with-number-attribute",
);
updatingComponent.setAttribute("count", "0");
await animationFrame();
const textContent = normalizeText(
updatingComponent.querySelector("span").textContent,
);
assert.equal(
textContent,
"0",
"Should update text content from setting count attribute",
);
});
it("should update when numeric state is set", async function () {
const updatingComponent = document.getElementById(
"updating-with-number-attribute",
);
updatingComponent.step = 1;
await animationFrame();
const stepAttribute = updatingComponent
.querySelector("input")
.getAttribute("step");
assert.equal(
stepAttribute,
"1",
"Should update step attribute of input element from setting step state",
);
});
it("should update when numeric attribute is set, parsed as integer", async function () {
const updatingComponent = document.getElementById(
"updating-with-number-attribute",
);
updatingComponent.setAttribute("value", "1.14");
await animationFrame();
const valueAttribute =
updatingComponent.querySelector("input").value;
assert.equal(
valueAttribute,
"1",
"Should update value attribute of input element from setting value attribute and parse it as defined",
);
});
it("should remove class when boolean attribute removed", async function () {
const updatingComponent = document.getElementById(
"updating-with-boolean-attribute",
);
updatingComponent.removeAttribute("selected");
await animationFrame();
const className =
updatingComponent.querySelector("h1").className;
assert.equal(
className,
"",
"Should remove class from removing selected attribute",
);
});
it("should update when state is set", async function () {
const updatingComponent = document.getElementById(
"updating-with-string-attribute",
);
updatingComponent.heading = "Hello from State";
await animationFrame();
const textContent = normalizeText(
updatingComponent.querySelector("h1").textContent,
);
assert.equal(
textContent,
"Hello from State",
"Should update text content from setting heading state",
);
});
});
describe("My counter", function () {
it("should increment and decrement", async function () {
const counter = document.querySelector("my-counter");
const decrement = counter.querySelector(".decrement");
const increment = counter.querySelector(".increment");
const value = counter.querySelector(".value");
assert.equal(
counter.value,
42,
"Should have initial value from attribute",
);
assert.equal(
normalizeText(value.textContent),
"42",
"Should have initial textContent from attribute",
);
decrement.click();
assert.equal(
counter.value,
41,
"Should decrement value",
);
await animationFrame();
assert.equal(
normalizeText(value.textContent),
"41",
"Should have updated textContent from decrement",
);
increment.click();
assert.equal(
counter.value,
42,
"Should increment value",
);
await animationFrame();
assert.equal(
normalizeText(value.textContent),
"42",
"Should have updated textContent from increment",
);
});
it("should update derived values", async function () {
const counter = document.querySelector("my-counter");
const decrement = counter.querySelector(".decrement");
const parity = counter.querySelector(".parity");
const double = counter.querySelector(".double");
assert.equal(
normalizeText(parity.textContent),
"even",
"Should have derived parity textContent from attribute",
);
assert.equal(
normalizeText(double.textContent),
"84",
"Should have derived double textContent from attribute",
);
decrement.click();
await animationFrame();
assert.equal(
normalizeText(parity.textContent),
"odd",
"Should have changed derived parity textContent",
);
assert.equal(
normalizeText(double.textContent),
"82",
"Should have decremented derived double textContent",
);
});
});
describe("Greeting Configurator", function () {
it("should display greeting", async function () {
const configurator = document.querySelector(
"greeting-configurator",
);
const helloWorld =
configurator.querySelector("hello-world");
const greeting = helloWorld.querySelector("p");
assert.equal(
normalizeText(greeting.textContent),
"Hello Jane",
"Should have initial greeting",
);
helloWorld.greeting = "Hi";
await animationFrame();
assert.equal(
normalizeText(greeting.textContent),
"Hi Jane",
"Should have updated greeting from state",
);
});
it("should update name if first name changes", async function () {
const configurator = document.querySelector(
"greeting-configurator",
);
const first = configurator.querySelector(".first");
const input = first.querySelector("input");
const helloWorld =
configurator.querySelector("hello-world");
const greeting = helloWorld.querySelector("p");
input.value = "Esther";
input.dispatchEvent(new Event("change"));
await animationFrame();
assert.equal(
normalizeText(greeting.textContent),
"Hi Esther",
"Should update if first name changes",
);
});
it("should not update name if last name changes", async function () {
const configurator = document.querySelector(
"greeting-configurator",
);
const last = configurator.querySelector(".last");
const input = last.querySelector("input");
const helloWorld =
configurator.querySelector("hello-world");
const greeting = helloWorld.querySelector("p");
input.value = "Brunner";
input.dispatchEvent(new Event("change"));
await animationFrame();
assert.equal(
normalizeText(greeting.textContent),
"Hi Esther",
"Should not update if last name changes",
);
});
it("should update greeting if use fullname is checked or unchecked", async function () {
const configurator = document.querySelector(
"greeting-configurator",
);
const fullname =
configurator.querySelector("input-checkbox");
const input = fullname.querySelector("input");
const helloWorld =
configurator.querySelector("hello-world");
const greeting = helloWorld.querySelector("p");
input.checked = true;
input.dispatchEvent(new Event("change"));
await animationFrame();
assert.equal(
normalizeText(greeting.textContent),
"Hi Esther Brunner",
"Should update if use fullname is checked",
);
input.checked = false;
input.dispatchEvent(new Event("change"));
await animationFrame();
assert.equal(
normalizeText(greeting.textContent),
"Hi Esther",
"Should update if use fullname is unchecked",
);
});
});
describe("Lazy Load", function () {
it("should display loading status initially", function () {
const lazyComponent =
document.getElementById("lazy-success");
assert.equal(
normalizeText(
lazyComponent.querySelector(".loading")
.textContent,
),
"Loading...",
);
});
it("should display lazy loaded content", async function () {
const lazyComponent =
document.getElementById("lazy-success");
await wait(LOADING_DELAY);
const shadow = lazyComponent.shadowRoot;
assert.instanceOf(
shadow,
DocumentFragment,
"Should have a shadow root",
);
assert.equal(
normalizeText(
shadow.querySelector("p").textContent,
),
"Lazy loaded content",
"Should display lazy loaded content",
);
assert.equal(
lazyComponent.querySelector(".error").hidden,
true,
"Should hide error container",
);
assert.equal(
lazyComponent.querySelector(".loading") === null,
true,
"Should remove loading status",
);
});
it("should display error message", async function () {
const lazyComponent =
document.getElementById("lazy-error");
await wait(LOADING_DELAY);
assert.equal(
normalizeText(
lazyComponent.querySelector(".error")
.textContent,
),
"Not Found",
"Should display error message",
);
assert.equal(
lazyComponent.querySelector(".loading") === null,
true,
"Should remove loading status",
);
});
it("should prevent recursive loading", async function () {
const lazyComponent =
document.getElementById("lazy-recursion");
await wait(LOADING_DELAY);
const shadow = lazyComponent.shadowRoot;
assert.instanceOf(
shadow,
DocumentFragment,
"Should have a shadow root",
);
const lazySubComponent =
shadow.querySelector("lazy-load");
const errorElement =
lazySubComponent.querySelector(".error");
assert.isFalse(
errorElement.hidden,
"Error message should be visible",
);
assert.equal(
errorElement.textContent,
"Recursive loading detected",
"Should display recursion error message",
);
});
});
describe("Tab Group", function () {
it("should mark the first button as active", async function () {
const tabGroup = document.querySelector("tab-group");
const buttons = tabGroup.querySelectorAll("button");
await animationFrame();
assert.equal(
buttons[0].ariaSelected,
"true",
"Should have the first button marked as active",
);
});
it("should change the active tab when a button is clicked", async function () {
const tabGroup = document.querySelector("tab-group");
const buttons = tabGroup.querySelectorAll("button");
buttons[1].click();
await animationFrame();
assert.equal(
buttons[0].ariaSelected,
"false",
"Should have the first button marked as inactive",
);
assert.equal(
buttons[1].ariaSelected,
"true",
"Should have the second button marked as active",
);
});
it("should display the content of the active tab", async function () {
const tabGroup = document.querySelector("tab-group");
const buttons = tabGroup.querySelectorAll("button");
await animationFrame();
assert.equal(
tabGroup
.querySelectorAll('[role="tabpanel"]')[1]
.hasAttribute("hidden"),
false,
"Should mark the second tabpanel as visible",
);
buttons[0].click();
await animationFrame();
assert.equal(
tabGroup
.querySelectorAll('[role="tabpanel"]')[1]
.hasAttribute("hidden"),
true,
"Should mark the second tabpanel as hidden",
);
assert.equal(
tabGroup
.querySelectorAll('[role="tabpanel"]')[0]
.hasAttribute("hidden"),
false,
"Should mark the first tabpanel as visible",
);
});
});
describe("InsertRecursion", function () {
it("should not cause infinite loops or memory leaks", async function () {
const parentCount =
document.querySelectorAll(
"recursion-parent",
).length;
const childCount =
document.querySelectorAll("recursion-child").length;
assert.isAtMost(
parentCount,
3,
"Should not create more than 3 recursion-parent components",
);
assert.isAtMost(
childCount,
3,
"Should not create more than 3 recursion-child components",
);
});
});
});
</script>
</body>
</html>