The scroll lock was mine, and it broke every page carrying a Drawer or Modal. Making dialog visibility testable, I replaced a size check with a data-show check -- but a Drawer animates open, so its panel cannot be hidden with data-show at all: display:none is not transitionable. Every closed Drawer therefore looked open, took the body scroll lock and never released it, and the page could not be scrolled. Both components publish data-open, which is the signal that actually means open, and that is what is read now. Stepper gains the wizard surface: showPanel renders each step body and shows only the active one, the same contract Tabs uses, and controls adds Back, Skip and Next, which becomes Finish on the last step. nextDisabled lets a form hold the step; the component never validates anything itself, since the page owns the form. Stepper also gets a single root. The panels and controls were siblings of the list, so the component had several roots and anything scoped to data-ui-component missed most of it. Tabs panels now slide in the direction of travel rather than fading upward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1113 lines
44 KiB
TypeScript
1113 lines
44 KiB
TypeScript
import { test, expect, beforeEach } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
|
import { mountHtml } from "@wrnexus/test";
|
|
|
|
// Fresh DOM per test, with the runtime's globals bound.
|
|
function mount(html: string): Window {
|
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
|
win.document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
(globalThis as Record<string, unknown>).window = win;
|
|
(globalThis as Record<string, unknown>).document = win.document;
|
|
(globalThis as Record<string, unknown>).location = win.location;
|
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
|
win as unknown as { NodeFilter: unknown }
|
|
).NodeFilter;
|
|
(globalThis as Record<string, unknown>).MutationObserver = (
|
|
win as unknown as { MutationObserver: unknown }
|
|
).MutationObserver;
|
|
(0, eval)(REACTIVE_RUNTIME);
|
|
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
|
|
// test window may not fire). setupScope is idempotent, so this is safe.
|
|
const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
|
|
w.__wrnexusHydrateScopes?.(win.document);
|
|
return win as unknown as Window;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
delete (globalThis as Record<string, unknown>).window;
|
|
delete (globalThis as Record<string, unknown>).document;
|
|
delete (globalThis as Record<string, unknown>).location;
|
|
delete (globalThis as Record<string, unknown>).fetch;
|
|
delete (globalThis as Record<string, unknown>).MutationObserver;
|
|
});
|
|
|
|
test("hydrates {expr} mustaches from data-scope", () => {
|
|
const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
|
|
expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
|
|
});
|
|
|
|
test("mounts client-only templates before hydrating their scopes", () => {
|
|
const win = mount(
|
|
`<div data-wrn-client-root="client-page" aria-busy="true"></div>` +
|
|
`<template data-wrn-client-template="client-page"><main data-scope="count: 2"><b>{count}</b></main></template>`,
|
|
);
|
|
const runtime = win as unknown as {
|
|
__wrnexusMountClientRoots?: (root: unknown) => void;
|
|
__wrnexusHydrateScopes?: (root: unknown) => void;
|
|
};
|
|
runtime.__wrnexusMountClientRoots?.(win.document);
|
|
runtime.__wrnexusHydrateScopes?.(win.document);
|
|
expect(win.document.querySelector("template")).toBeNull();
|
|
expect(win.document.querySelector("main b")?.textContent).toBe("2");
|
|
});
|
|
|
|
test("orchestrates named client loads and renders the success template", async () => {
|
|
(globalThis as Record<string, unknown>).fetch = () =>
|
|
Promise.resolve(Response.json({ data: { name: "Ada" } }));
|
|
const win = mount(
|
|
`<section data-wrn-async="users" data-wrn-async-retries="0" aria-busy="true">` +
|
|
`<div data-wrn-async-content>Loading</div>` +
|
|
`<template data-wrn-async-loading>Loading</template>` +
|
|
`<template data-wrn-async-success><strong>{users.name}</strong></template>` +
|
|
`<template data-wrn-async-error><b>{error.message}</b></template></section>`,
|
|
);
|
|
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
|
|
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(win.document.querySelector("section")?.getAttribute("data-wrn-async-state")).toBe(
|
|
"success",
|
|
);
|
|
expect(win.document.querySelector("strong")?.textContent).toBe("Ada");
|
|
});
|
|
|
|
test("@event (data-on-click) mutates a signal and re-renders", () => {
|
|
const win = mount(
|
|
`<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
|
|
);
|
|
const btn = win.document.querySelector("button")!;
|
|
expect(btn.textContent).toBe("0");
|
|
btn.click();
|
|
btn.click();
|
|
expect(btn.textContent).toBe("2");
|
|
});
|
|
|
|
test("component functions support formatted multiline assignments and ternaries", () => {
|
|
const behavior = Buffer.from(
|
|
JSON.stringify({
|
|
functions: `
|
|
function increment(event, nextValue) {
|
|
nextValue =
|
|
Number(count) +
|
|
(event.shiftKey ? 10 : 1)
|
|
count = nextValue
|
|
}
|
|
|
|
function normalize(useMinimum) {
|
|
count = useMinimum
|
|
? 0
|
|
: Number(count)
|
|
}
|
|
`,
|
|
watches: [],
|
|
lifecycle: {},
|
|
}),
|
|
).toString("base64");
|
|
const win = mount(
|
|
`<div data-scope="count: 0" data-wrn-behavior="${behavior}">
|
|
<button id="increment" data-on-click="increment(event)">{count}</button>
|
|
<button id="normalize" data-on-click="normalize(true)">normalize</button>
|
|
</div>`,
|
|
);
|
|
|
|
const increment = win.document.getElementById("increment") as unknown as HTMLElement;
|
|
increment.click();
|
|
expect(increment.textContent).toBe("1");
|
|
|
|
const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
|
|
increment.dispatchEvent(
|
|
new MouseEventConstructor("click", { shiftKey: true }) as unknown as Event,
|
|
);
|
|
expect(increment.textContent).toBe("11");
|
|
|
|
(win.document.getElementById("normalize") as unknown as HTMLElement).click();
|
|
expect(increment.textContent).toBe("0");
|
|
});
|
|
|
|
test("component functions evaluate template literals without unsafe eval", () => {
|
|
const behavior = Buffer.from(
|
|
JSON.stringify({
|
|
functions: `function increment() {
|
|
count++
|
|
message = \`Count is now \${count}.\`
|
|
}`,
|
|
watches: [],
|
|
lifecycle: {},
|
|
}),
|
|
).toString("base64");
|
|
const win = mount(
|
|
`<div data-scope="count: 0, message: 'ready'" data-wrn-behavior="${behavior}">` +
|
|
`<button data-on-click="increment()">{message}</button></div>`,
|
|
);
|
|
const button = win.document.querySelector("button")!;
|
|
button.click();
|
|
expect(button.textContent).toBe("Count is now 1.");
|
|
});
|
|
|
|
test("declared component events emit through the generic $emit function", () => {
|
|
const win = mount(
|
|
`<div data-scope="" data-wrn-events="complete">
|
|
<button data-on-click="$emit('complete', { value: '4829' })">Complete</button>
|
|
</div>`,
|
|
);
|
|
const root = win.document.querySelector("[data-wrn-events]")!;
|
|
let detail: Record<string, unknown> | undefined;
|
|
root.addEventListener("complete", (event) => {
|
|
detail = (event as unknown as CustomEvent).detail;
|
|
});
|
|
|
|
win.document.querySelector("button")!.click();
|
|
|
|
expect(detail).toEqual({ value: "4829" });
|
|
});
|
|
|
|
test("nested scopes don't clobber each other (regression)", () => {
|
|
// An empty outer scope must not touch inner scopes' values.
|
|
const win = mount(
|
|
`<div data-scope="">
|
|
<div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
|
|
<div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
|
|
</div>`,
|
|
);
|
|
const [a, b] = Array.from(win.document.querySelectorAll("button"));
|
|
expect(a!.textContent).toBe("A0");
|
|
expect(b!.textContent).toBe("B10");
|
|
a!.click();
|
|
expect(a!.textContent).toBe("A1");
|
|
expect(b!.textContent).toBe("B10"); // unchanged
|
|
});
|
|
|
|
test("data-text binds an element's textContent to an expression", () => {
|
|
const win = mount(
|
|
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
|
|
);
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("9");
|
|
win.document.querySelector("button")!.click();
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("15");
|
|
});
|
|
|
|
test("string scope values bind via data-text", () => {
|
|
const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("hi");
|
|
});
|
|
|
|
test("data-for renders a list of objects and reacts to array changes", () => {
|
|
const win = mount(
|
|
`<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
|
|
<ul><li data-for="t in todos" data-text="t.text"></li></ul>
|
|
<button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
|
|
<button id="clear" data-on-click="todos = []">clear</button>
|
|
</div>`,
|
|
);
|
|
const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
|
|
expect(items()).toEqual(["a", "b"]);
|
|
(win.document.getElementById("add") as unknown as HTMLElement).click();
|
|
expect(items()).toEqual(["a", "b", "c"]);
|
|
(win.document.getElementById("clear") as unknown as HTMLElement).click();
|
|
expect(items()).toEqual([]);
|
|
});
|
|
|
|
test("data-for exposes item + index, mustaches and member access", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
|
|
<ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
|
|
</div>`,
|
|
);
|
|
expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
|
|
"0:x",
|
|
"1:y",
|
|
]);
|
|
});
|
|
|
|
test("keyed data-for preserves DOM identity when items reorder", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
|
|
<ul><li data-for="row in rows key row.id">{row.name}</li></ul>
|
|
<button data-on-click="rows = rows.slice().reverse()">reverse</button>
|
|
</div>`,
|
|
);
|
|
|
|
const before = Array.from(win.document.querySelectorAll("li"));
|
|
expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);
|
|
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
|
|
const after = Array.from(win.document.querySelectorAll("li"));
|
|
expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
|
|
expect(after[0]).toBe(before[1]);
|
|
expect(after[1]).toBe(before[0]);
|
|
});
|
|
|
|
test("expression evaluator: member access, ternary, comparison, calls", () => {
|
|
const win = mount(
|
|
`<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
|
|
<span id="a" data-text="user.name"></span>
|
|
<span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
|
|
<span id="c" data-text="items.length"></span>
|
|
</div>`,
|
|
);
|
|
expect(win.document.getElementById("a")!.textContent).toBe("Ada");
|
|
expect(win.document.getElementById("b")!.textContent).toBe("senior");
|
|
expect(win.document.getElementById("c")!.textContent).toBe("3");
|
|
});
|
|
|
|
test("expression evaluator exposes safe primitive conversion globals", () => {
|
|
const win = mount(
|
|
`<div data-scope="count: 12">
|
|
<span data-text="String(count)"></span>
|
|
</div>`,
|
|
);
|
|
|
|
expect(win.document.querySelector("span")!.textContent).toBe("12");
|
|
});
|
|
|
|
test("logical expressions consume their right-hand side when the result short-circuits", () => {
|
|
const win = mount(
|
|
`<div data-scope="leftFalse: false, leftTrue: true">
|
|
<span id="and" data-text="leftFalse && 123"></span>
|
|
<span id="or" data-text="leftTrue || 456"></span>
|
|
</div>`,
|
|
);
|
|
expect(win.document.getElementById("and")!.textContent).toBe("false");
|
|
expect(win.document.getElementById("or")!.textContent).toBe("true");
|
|
});
|
|
|
|
test("expression evaluator supports flatMap callbacks", () => {
|
|
const win = mount(
|
|
`<div data-scope="groups: [{items: [1, 2]}, {items: [3]}]">
|
|
<span data-text="groups.flatMap((group) => group.items)"></span>
|
|
</div>`,
|
|
);
|
|
expect(win.document.querySelector("span")!.textContent).toBe("1,2,3");
|
|
});
|
|
|
|
test("data-show toggles visibility without destroying interactive state", () => {
|
|
const win = mount(
|
|
`<div data-scope="tab: 0">
|
|
<button data-on-click="tab = 1">go</button>
|
|
<section id="a" data-show="tab === 0">A</section>
|
|
<section id="b" data-show="tab === 1">B</section>
|
|
</div>`,
|
|
);
|
|
const a = win.document.getElementById("a") as unknown as HTMLElement;
|
|
const b = win.document.getElementById("b") as unknown as HTMLElement;
|
|
expect(a.style.display).toBe("");
|
|
expect(b.style.display).toBe("none");
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(a.style.display).toBe("none");
|
|
expect(b.style.display).toBe("");
|
|
});
|
|
|
|
test("dynamic components remove inactive cases from the live DOM", async () => {
|
|
const win = mount(
|
|
`<div data-scope="active: 'Admin'">
|
|
<button data-on-click="active = active === 'Admin' ? 'Guest' : 'Admin'">switch</button>
|
|
<div data-wrn-dynamic-component="Admin" data-wrn-bind-0='["data-wrn-dynamic-component","{active}"]'>
|
|
<section data-component-case="Admin">Administrator secret</section>
|
|
<section data-component-case="Guest">Guest dashboard</section>
|
|
</div>
|
|
</div>`,
|
|
);
|
|
win.document.dispatchEvent(new win.Event("DOMContentLoaded"));
|
|
expect(win.document.querySelector('[data-component-case="Admin"]')?.textContent).toContain(
|
|
"Administrator",
|
|
);
|
|
expect(win.document.querySelector('[data-component-case="Guest"]')).toBeNull();
|
|
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(win.document.querySelector('[data-component-case="Admin"]')).toBeNull();
|
|
expect(win.document.querySelector('[data-component-case="Guest"]')?.textContent).toContain(
|
|
"Guest",
|
|
);
|
|
});
|
|
|
|
test("reactive data attributes preserve explicit boolean strings", () => {
|
|
const win = mount(
|
|
`<div data-scope="ready: true">
|
|
<section
|
|
id="carousel-layout"
|
|
data-show="true"
|
|
data-wrn-bind-0='["data-show","{ready}"]'
|
|
>Slides</section>
|
|
</div>`,
|
|
);
|
|
|
|
expect(win.document.getElementById("carousel-layout")!.getAttribute("data-show")).toBe("true");
|
|
});
|
|
|
|
test("reactive attribute bindings update input and accessibility attributes", () => {
|
|
const win = mount(
|
|
`<div data-scope="show: false">
|
|
<input id="password" type="password" data-wrn-bind-0='["type","{show ? 'text' : 'password'}"]'>
|
|
<button data-on-click="show = !show" aria-label="Show password"
|
|
data-wrn-bind-0='["aria-label","{show ? 'Hide password' : 'Show password'}"]'>Toggle</button>
|
|
</div>`,
|
|
);
|
|
const input = win.document.getElementById("password") as unknown as HTMLInputElement;
|
|
const button = win.document.querySelector("button") as unknown as HTMLElement;
|
|
expect(input.type).toBe("password");
|
|
expect(button.getAttribute("aria-label")).toBe("Show password");
|
|
button.click();
|
|
expect(input.type).toBe("text");
|
|
expect(button.getAttribute("aria-label")).toBe("Hide password");
|
|
});
|
|
|
|
test("independent signals in one scope update correctly (dependency tracking)", () => {
|
|
const win = mount(
|
|
`<div data-scope="a: 0, b: 100">
|
|
<span id="ta" data-text="a"></span>
|
|
<span id="tb" data-text="b"></span>
|
|
<button id="ba" data-on-click="a++">A</button>
|
|
<button id="bb" data-on-click="b++">B</button>
|
|
</div>`,
|
|
);
|
|
const ta = () => win.document.getElementById("ta")!.textContent;
|
|
const tb = () => win.document.getElementById("tb")!.textContent;
|
|
const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
|
|
expect([ta(), tb()]).toEqual(["0", "100"]);
|
|
click("ba");
|
|
click("ba");
|
|
expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
|
|
click("bb");
|
|
expect([ta(), tb()]).toEqual(["2", "101"]);
|
|
});
|
|
test("data-for preserves arrays of objects from encoded component scope", () => {
|
|
const items = [
|
|
{
|
|
question: "One",
|
|
answer: "First",
|
|
},
|
|
{
|
|
question: "Two",
|
|
answer: "Second",
|
|
},
|
|
{
|
|
question: "Three",
|
|
answer: "Third",
|
|
},
|
|
{
|
|
question: "Four",
|
|
answer: "Fourth",
|
|
},
|
|
];
|
|
|
|
const encoded = Buffer.from(
|
|
JSON.stringify({
|
|
items,
|
|
}),
|
|
"utf8",
|
|
).toString("base64");
|
|
|
|
const dom = mountHtml(`
|
|
<div data-wrn-scope="${encoded}">
|
|
<article data-for="item, index in items">
|
|
<span>{index + 1}</span>
|
|
<strong>{item.question}</strong>
|
|
</article>
|
|
</div>
|
|
`);
|
|
|
|
expect(dom.querySelectorAll("article")).toHaveLength(4);
|
|
|
|
expect(dom.querySelectorAll("strong").map((element) => element.textContent)).toEqual([
|
|
"One",
|
|
"Two",
|
|
"Three",
|
|
"Four",
|
|
]);
|
|
});
|
|
|
|
test("$emit inside component functions does not depend on a global browser event", () => {
|
|
const behavior = Buffer.from(
|
|
JSON.stringify({
|
|
functions: `
|
|
function showOverlay(sourceEvent) {
|
|
visible = true
|
|
$emit("open", { source: sourceEvent.type })
|
|
}
|
|
`,
|
|
watches: [],
|
|
lifecycle: {},
|
|
}),
|
|
).toString("base64");
|
|
|
|
delete (globalThis as Record<string, unknown>).event;
|
|
|
|
const win = mount(
|
|
`<div data-scope="visible: false" data-wrn-events="open" data-wrn-behavior="${behavior}">
|
|
<button data-on-click="showOverlay(event)">Open overlay</button>
|
|
<span>{visible}</span>
|
|
</div>`,
|
|
);
|
|
|
|
const root = win.document.querySelector("[data-wrn-events]")!;
|
|
let detail: Record<string, unknown> | undefined;
|
|
root.addEventListener("open", (event) => {
|
|
detail = (event as unknown as CustomEvent).detail;
|
|
});
|
|
|
|
win.document.querySelector("button")!.click();
|
|
|
|
expect(win.document.querySelector("span")!.textContent).toBe("true");
|
|
expect(detail).toEqual({ source: "click" });
|
|
});
|
|
|
|
test("template literals work inside browser API call arguments", () => {
|
|
const behavior = Buffer.from(
|
|
JSON.stringify({
|
|
functions: `function save() {
|
|
window.localStorage.setItem(\`prefs:\${count}\`, JSON.stringify(count))
|
|
message = \`Saved \${count}\`
|
|
}`,
|
|
watches: [],
|
|
lifecycle: {},
|
|
}),
|
|
).toString("base64");
|
|
const win = mount(
|
|
`<div data-scope="count: 3, message: 'ready'" data-wrn-behavior="${behavior}">` +
|
|
`<button data-on-click="save()">{message}</button></div>`,
|
|
);
|
|
win.document.querySelector("button")!.click();
|
|
expect(win.localStorage.getItem("prefs:3")).toBe("3");
|
|
expect(win.document.querySelector("button")!.textContent).toBe("Saved 3");
|
|
});
|
|
|
|
test("compiled client functions are not overwritten by fallback behavior parsing", () => {
|
|
const behavior = Buffer.from(
|
|
JSON.stringify({
|
|
functions: `function save() { message = \`fallback\` }`,
|
|
watches: [],
|
|
lifecycle: {},
|
|
}),
|
|
).toString("base64");
|
|
const win = new Window() as unknown as Window & Record<string, unknown>;
|
|
win.document.body.innerHTML =
|
|
`<div id="scope" data-scope="message: 'ready'" data-wrn-behavior="${behavior}">` +
|
|
`<button data-on-click="save()">{message}</button></div>`;
|
|
const scope = win.document.getElementById("scope") as unknown as HTMLElement & {
|
|
__wrnexusClientModule?: unknown;
|
|
};
|
|
scope.__wrnexusClientModule = {
|
|
bindClientScope(context: { state: Record<string, unknown> }) {
|
|
return {
|
|
save() {
|
|
context.state.message = "native";
|
|
},
|
|
};
|
|
},
|
|
};
|
|
(globalThis as Record<string, unknown>).window = win;
|
|
(globalThis as Record<string, unknown>).document = win.document;
|
|
(globalThis as Record<string, unknown>).location = win.location;
|
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
|
win as unknown as { NodeFilter: unknown }
|
|
).NodeFilter;
|
|
(globalThis as Record<string, unknown>).MutationObserver = (
|
|
win as unknown as { MutationObserver: unknown }
|
|
).MutationObserver;
|
|
(0, eval)(REACTIVE_RUNTIME);
|
|
(win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
|
|
win.document,
|
|
);
|
|
win.document.querySelector("button")!.click();
|
|
expect(win.document.querySelector("button")!.textContent).toBe("native");
|
|
});
|
|
|
|
test("$route updates after client navigation", () => {
|
|
const win = new Window({ url: "http://localhost/" }) as unknown as Window &
|
|
Record<string, unknown>;
|
|
win.document.body.innerHTML =
|
|
`<span hidden data-wrn-route-params='{"id":"42"}'></span>` +
|
|
`<div data-scope=""><strong>{$route.pathname}</strong><em>{$route.params.id}</em></div>`;
|
|
(globalThis as Record<string, unknown>).window = win;
|
|
(globalThis as Record<string, unknown>).document = win.document;
|
|
(globalThis as Record<string, unknown>).location = win.location;
|
|
(globalThis as Record<string, unknown>).NodeFilter = (
|
|
win as unknown as { NodeFilter: unknown }
|
|
).NodeFilter;
|
|
(globalThis as Record<string, unknown>).MutationObserver = (
|
|
win as unknown as { MutationObserver: unknown }
|
|
).MutationObserver;
|
|
(0, eval)(REACTIVE_RUNTIME);
|
|
(win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
|
|
win.document,
|
|
);
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("/");
|
|
expect(win.document.querySelector("em")!.textContent).toBe("42");
|
|
expect(win.document.querySelector("[data-wrn-route-params]")).toBeNull();
|
|
win.history.pushState({}, "", "/settings?tab=chat");
|
|
win.dispatchEvent(
|
|
new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
|
|
"wrnexus:navigated",
|
|
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
|
);
|
|
expect(win.document.querySelector("strong")!.textContent).toBe("/settings");
|
|
});
|
|
|
|
test("cache invalidation refetches matching client Async boundaries", async () => {
|
|
let requests = 0;
|
|
(globalThis as Record<string, unknown>).fetch = () => {
|
|
requests++;
|
|
return Promise.resolve(Response.json({ data: { value: requests } }));
|
|
};
|
|
const win = mount(
|
|
`<section data-wrn-async="uploads" data-wrn-async-tags="uploads" data-wrn-async-retries="0">` +
|
|
`<div data-wrn-async-content></div>` +
|
|
`<template data-wrn-async-loading>Loading</template>` +
|
|
`<template data-wrn-async-success data-wrn-async-alias="uploads"><b>{uploads.value}</b></template>` +
|
|
`<template data-wrn-async-error data-wrn-async-alias="error">{error.message}</template>` +
|
|
`</section>`,
|
|
);
|
|
const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
|
|
runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(win.document.querySelector("b")?.textContent).toBe("1");
|
|
win.dispatchEvent(
|
|
new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
|
|
"wrnexus:cache:invalidate",
|
|
{ detail: { tags: ["uploads"] } },
|
|
) as unknown as Parameters<Window["dispatchEvent"]>[0],
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(requests).toBe(2);
|
|
expect(win.document.querySelector("b")?.textContent).toBe("2");
|
|
});
|
|
|
|
// --- parent-scope ownership across a component boundary --------------------
|
|
// Slot content and `@output` handlers on a component tag are authored in the
|
|
// PARENT (page) source, but SSR splices them inside the child component's
|
|
// [data-scope] root. Without an owner-chain rule the child scope claims them,
|
|
// so the page's own functions and state are invisible to markup the page
|
|
// itself wrote.
|
|
|
|
test("slot content is hydrated by the parent scope, not the component it lands in", () => {
|
|
const win = mount(
|
|
`<div data-scope="pageValue: 'page', pageFlag: true">` +
|
|
`<div data-scope="pageValue: 'child', pageFlag: false" data-wrn-hydration="Modal:x">` +
|
|
`<div data-wrn-events="confirm" class="view">` +
|
|
`<wrn-slot data-wrn-slot=""><b>{pageValue}</b><i data-show="pageFlag">shown</i></wrn-slot>` +
|
|
`</div></div></div>`,
|
|
);
|
|
expect(win.document.querySelector("b")?.textContent).toBe("page");
|
|
expect(win.document.querySelector("i")?.getAttribute("data-show")).toBe("true");
|
|
});
|
|
|
|
test("slot event handlers write to the parent scope", () => {
|
|
const win = mount(
|
|
`<div data-scope="count: 0">` +
|
|
`<span id="out">{count}</span>` +
|
|
`<div data-scope="count: 99" data-wrn-hydration="Modal:x">` +
|
|
`<div data-wrn-events="confirm">` +
|
|
`<wrn-slot data-wrn-slot=""><button data-on-click="count++">go</button></wrn-slot>` +
|
|
`</div></div></div>`,
|
|
);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(win.document.querySelector("#out")?.textContent).toBe("1");
|
|
});
|
|
|
|
test("component output handlers run in the parent scope", () => {
|
|
const win = mount(
|
|
`<div data-scope="saved: ''">` +
|
|
`<span id="out">{saved}</span>` +
|
|
`<div data-scope="saved: 'child'" data-wrn-hydration="Modal:x">` +
|
|
`<div data-wrn-events="confirm" data-wrn-out-confirm="saved = 'yes'"></div>` +
|
|
`</div></div>`,
|
|
);
|
|
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
|
|
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
|
|
};
|
|
const handlers = target.__wrnexusOutputHandlers?.confirm;
|
|
expect(handlers && handlers.size).toBe(1);
|
|
handlers!.forEach((handler) => handler({}));
|
|
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
|
|
});
|
|
|
|
// --- browser globals + regex literals in client expressions ----------------
|
|
// Client functions and inline handlers are interpreted by the runtime's own
|
|
// eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
|
|
// the engine cannot resolve silently becomes undefined, so an author calling
|
|
// an ordinary browser API gets a confusing "not a function" instead of the
|
|
// behavior they wrote.
|
|
|
|
test("client expressions can call browser globals like alert", () => {
|
|
const win = mount(
|
|
`<div data-scope="msg: 'hi'"><button data-on-click="alert(msg)">go</button></div>`,
|
|
) as unknown as Window & {
|
|
alert?: (message: string) => void;
|
|
};
|
|
const seen: string[] = [];
|
|
win.alert = (message: string) => seen.push(String(message));
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(seen).toEqual(["hi"]);
|
|
});
|
|
|
|
test("client expressions resolve JS builtins", () => {
|
|
const win = mount(
|
|
`<div data-scope="raw: ' 42 ', out: 0, keys: ''">` +
|
|
`<button data-on-click="out = parseInt(raw, 10); keys = Object.keys({ a: 1, b: 2 }).join('-')">go</button>` +
|
|
`<span id="n">{out}</span><span id="k">{keys}</span>` +
|
|
`</div>`,
|
|
);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(win.document.querySelector("#n")?.textContent).toBe("42");
|
|
expect(win.document.querySelector("#k")?.textContent).toBe("a-b");
|
|
});
|
|
|
|
test("client expressions support regex literals", () => {
|
|
const win = mount(
|
|
`<div data-scope="email: 'dev@wrnexus.io', ok: ''">` +
|
|
// String.raw so the backslashes reach the runtime: in a plain template
|
|
// literal `\s` collapses to `s` and the test would pass against a
|
|
// pattern the author never wrote.
|
|
String.raw`<button data-on-click="ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? 'yes' : 'no'">go</button>` +
|
|
`<span id="out">{ok}</span></div>`,
|
|
);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
|
|
});
|
|
|
|
test("a regex literal with a division sign nearby still parses as division", () => {
|
|
const win = mount(
|
|
`<div data-scope="a: 10, b: 4, out: 0"><button data-on-click="out = a / b">go</button><span id="out">{out}</span></div>`,
|
|
);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(win.document.querySelector("#out")?.textContent).toBe("2.5");
|
|
});
|
|
|
|
test("a string literal that spells an operator stays a value", () => {
|
|
const win = mount(
|
|
`<div data-scope="parts: 'a.b.c', joined: '', first: ''">` +
|
|
`<button data-on-click="joined = ['x','y'].join('-'); first = parts.split('.')[0]">go</button>` +
|
|
`<span id="j">{joined}</span><span id="f">{first}</span></div>`,
|
|
);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(win.document.querySelector("#j")?.textContent).toBe("x-y");
|
|
expect(win.document.querySelector("#f")?.textContent).toBe("a");
|
|
});
|
|
|
|
// --- toast() ---------------------------------------------------------------
|
|
// The runtime raises notifications through a window event and never imports
|
|
// the Toaster component, so an app can host toasts however it likes.
|
|
|
|
test("toast() dispatches a wrnexus:toast event when a toaster is mounted", () => {
|
|
const win = mount(
|
|
`<div data-toaster="true"></div>` +
|
|
`<div data-scope="who: 'Ada'"><button data-on-click="toast.success('hi ' + who, { title: 'Done' })">go</button></div>`,
|
|
) as unknown as Window & { addEventListener: Window["addEventListener"] };
|
|
const seen: Record<string, unknown>[] = [];
|
|
win.addEventListener("wrnexus:toast", ((event: { detail: Record<string, unknown> }) => {
|
|
seen.push(event.detail);
|
|
}) as unknown as Parameters<Window["addEventListener"]>[1]);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(seen).toEqual([{ message: "hi Ada", title: "Done", tone: "success" }]);
|
|
});
|
|
|
|
test("toast() falls back to the console when no toaster is mounted", () => {
|
|
const win = mount(
|
|
`<div data-scope=""><button data-on-click="toast('orphan')">go</button></div>`,
|
|
) as unknown as Window & {
|
|
addEventListener: Window["addEventListener"];
|
|
};
|
|
const dispatched: unknown[] = [];
|
|
win.addEventListener("wrnexus:toast", ((event: unknown) =>
|
|
dispatched.push(event)) as unknown as Parameters<Window["addEventListener"]>[1]);
|
|
const original = console.info;
|
|
const logged: string[] = [];
|
|
console.info = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
|
|
try {
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
} finally {
|
|
console.info = original;
|
|
}
|
|
expect(dispatched).toEqual([]);
|
|
expect(logged.join("\n")).toContain("orphan");
|
|
});
|
|
|
|
test("toast is reachable as a real global for compiled client modules", () => {
|
|
const win = mount(`<div data-scope=""></div>`) as unknown as Window & {
|
|
toast?: unknown;
|
|
wireToast?: unknown;
|
|
};
|
|
expect(typeof win.wireToast).toBe("function");
|
|
expect(typeof win.toast).toBe("function");
|
|
});
|
|
|
|
test("data-show is evaluated inside a data-for row", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{ id: 1, label: 'a', extra: '' }, { id: 2, label: 'b', extra: 'yes' }]">` +
|
|
`<ul><li data-for="row in rows" data-key="row.id">` +
|
|
`<span class="label">{row.label}</span>` +
|
|
`<span class="extra" data-show="row.extra">{row.extra}</span>` +
|
|
`</li></ul></div>`,
|
|
);
|
|
const extras = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
|
|
expect(extras.length).toBe(2);
|
|
// First row has no extra: hidden. Second row does: visible.
|
|
expect(extras[0]!.getAttribute("data-show")).toBe("false");
|
|
expect(extras[0]!.style.display).toBe("none");
|
|
expect(extras[1]!.getAttribute("data-show")).toBe("true");
|
|
expect(extras[1]!.style.display).not.toBe("none");
|
|
});
|
|
|
|
test("data-show in a data-for row can read component scope, not just the item", () => {
|
|
const win = mount(
|
|
`<div data-scope="showAll: false, rows: [{ id: 1 }]">` +
|
|
`<ul><li data-for="row in rows" data-key="row.id">` +
|
|
`<b data-show="showAll">always?</b>` +
|
|
`</li></ul></div>`,
|
|
);
|
|
const node = win.document.querySelector("b") as unknown as HTMLElement;
|
|
expect(node.getAttribute("data-show")).toBe("false");
|
|
expect(node.style.display).toBe("none");
|
|
});
|
|
|
|
// --- nested data-for -------------------------------------------------------
|
|
// A table is rows containing cells, so a loop inside a loop has to work. The
|
|
// inner template used to be left unexpanded (and its mustaches baked against
|
|
// the outer item, where the inner name is undefined), so nested rows rendered
|
|
// blank.
|
|
|
|
test("a data-for nested inside a data-for renders its rows", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{ id: 1, cells: [{ v: 'a1' }, { v: 'b1' }] }, { id: 2, cells: [{ v: 'a2' }, { v: 'b2' }] }]">` +
|
|
`<table><tbody>` +
|
|
`<tr data-for="row in rows" data-key="row.id">` +
|
|
`<td data-for="cell in row.cells" class="cell">{cell.v}</td>` +
|
|
`</tr>` +
|
|
`</tbody></table></div>`,
|
|
);
|
|
const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.textContent);
|
|
expect(cells).toEqual(["a1", "b1", "a2", "b2"]);
|
|
});
|
|
|
|
test("a nested loop can read the enclosing item and component scope", () => {
|
|
const win = mount(
|
|
`<div data-scope="prefix: 'x', groups: [{ name: 'g1', items: ['p', 'q'] }]">` +
|
|
`<ul><li data-for="group in groups">` +
|
|
`<span data-for="item in group.items" class="leaf">{prefix}-{group.name}-{item}</span>` +
|
|
`</li></ul></div>`,
|
|
);
|
|
const leaves = [...win.document.querySelectorAll(".leaf")].map((n) => n.textContent);
|
|
expect(leaves).toEqual(["x-g1-p", "x-g1-q"]);
|
|
});
|
|
|
|
// A binding inside a loop row must react to state the row's list expression
|
|
// never touched. Effects registered during the initial render sweep used to be
|
|
// skipped entirely (forEach fixes its range up front), so they never ran, never
|
|
// subscribed, and stayed frozen at their first value for the life of the page.
|
|
test("a data-show inside a loop reacts to state the list expression never reads", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{ id: 1 }, { id: 2 }], showExtra: false">` +
|
|
`<div data-for="row in rows" data-key="row.id">` +
|
|
`<b class="extra" data-show="showExtra">detail</b>` +
|
|
`</div>` +
|
|
`<button data-on-click="showExtra = true">go</button>` +
|
|
`</div>`,
|
|
);
|
|
const hidden = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
|
|
expect(hidden.length).toBe(2);
|
|
expect(hidden.every((n) => n.style.display === "none")).toBe(true);
|
|
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
|
|
const shown = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
|
|
expect(shown.every((n) => n.style.display !== "none")).toBe(true);
|
|
});
|
|
|
|
test("a checked binding inside a loop toggles with state", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{ id: 1 }, { id: 2 }], chosen: 2">` +
|
|
`<ul><li data-for="row in rows" data-key="row.id">` +
|
|
`<input class="box" type="checkbox" data-wrn-bind-0='["checked","{row.id === chosen}"]' />` +
|
|
`</li></ul>` +
|
|
`<button data-on-click="chosen = 1">pick first</button></div>`,
|
|
);
|
|
const boxes = () => [...win.document.querySelectorAll(".box")] as unknown as HTMLInputElement[];
|
|
expect(boxes().map((b) => b.checked)).toEqual([false, true]);
|
|
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(boxes().map((b) => b.checked)).toEqual([true, false]);
|
|
});
|
|
|
|
// Rows rebuilt AFTER hydration register their effects outside the initial
|
|
// sweep, so nothing ran them: data-show kept its raw expression and the
|
|
// element defaulted to visible. Re-sorting a table therefore drew a second
|
|
// copy of every header label.
|
|
test("data-show still resolves in rows rebuilt after the first render", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{ id: 1, on: true }, { id: 2, on: false }]">` +
|
|
`<div data-for="row in rows" data-key="row.id">` +
|
|
`<b class="yes" data-show="row.on">yes</b>` +
|
|
`</div>` +
|
|
`<button data-on-click="rows = [{ id: 1, on: false }, { id: 2, on: true }]">swap</button>` +
|
|
`</div>`,
|
|
);
|
|
const shown = () =>
|
|
([...win.document.querySelectorAll(".yes")] as unknown as HTMLElement[]).map(
|
|
(node) => node.style.display !== "none",
|
|
);
|
|
expect(shown()).toEqual([true, false]);
|
|
|
|
// New item objects mean brand new clones, hydrated outside the first sweep.
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(shown()).toEqual([false, true]);
|
|
});
|
|
|
|
// data-html is the one directive that does not escape, so it is a separate
|
|
// opt-in rather than a flag on data-text.
|
|
test("data-html renders markup while data-text escapes it", () => {
|
|
const win = mount(
|
|
`<div data-scope="markup: '<b>bold</b>'">` +
|
|
`<span id="raw" data-html="markup"></span>` +
|
|
`<span id="safe" data-text="markup"></span>` +
|
|
`</div>`,
|
|
);
|
|
expect(win.document.querySelector("#raw")?.innerHTML).toBe("<b>bold</b>");
|
|
expect(win.document.querySelector("#raw b")?.textContent).toBe("bold");
|
|
// data-text must still show the markup as literal characters.
|
|
expect(win.document.querySelector("#safe b")).toBeNull();
|
|
expect(win.document.querySelector("#safe")?.textContent).toBe("<b>bold</b>");
|
|
});
|
|
|
|
test("data-html applies inside a data-for row", () => {
|
|
const win = mount(
|
|
`<div data-scope="rows: [{ id: 1, cell: '<b>one</b>' }, { id: 2, cell: '<i>two</i>' }]">` +
|
|
`<ul><li data-for="row in rows" data-key="row.id">` +
|
|
`<span class="cell" data-html="row.cell"></span>` +
|
|
`</li></ul></div>`,
|
|
);
|
|
const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.innerHTML);
|
|
expect(cells).toEqual(["<b>one</b>", "<i>two</i>"]);
|
|
});
|
|
|
|
// Statement bodies given to the expression engine -- lifecycle hooks, inline
|
|
// handlers -- are ordinary authored code and contain comments. The leading
|
|
// slash used to fall through to the regex-literal branch and kill the body
|
|
// with "Unclosed regular expression".
|
|
test("comments are ignored inside interpreted statements", () => {
|
|
const win = mount(
|
|
`<div data-scope="total: 0">` +
|
|
`<button data-on-click="// bump the counter total = total + 2 /* twice */">go</button>` +
|
|
`<span id="out">{total}</span></div>`,
|
|
);
|
|
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
|
expect(win.document.querySelector("#out")?.textContent).toBe("2");
|
|
});
|
|
|
|
/*
|
|
* happy-dom builds its own KeyboardEvent, which is structurally distinct from
|
|
* the DOM lib Event that dispatchEvent is typed against. Same cast the window
|
|
* dispatches above use, kept in one place.
|
|
*/
|
|
function keydown(win: Window, key: string): Event {
|
|
const ctor = (win as unknown as { KeyboardEvent: new (type: string, init: unknown) => unknown })
|
|
.KeyboardEvent;
|
|
return new ctor("keydown", { key, bubbles: true }) as unknown as Event;
|
|
}
|
|
|
|
test("roving focus moves with arrow keys and wraps", () => {
|
|
const win = mount(
|
|
`<div data-wrn-roving="horizontal">
|
|
<button data-wrn-roving-item id="a">A</button>
|
|
<button data-wrn-roving-item id="b">B</button>
|
|
<button data-wrn-roving-item id="c">C</button>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
const a = doc.querySelector("#a") as unknown as HTMLElement;
|
|
const c = doc.querySelector("#c") as unknown as HTMLElement;
|
|
|
|
expect(a.getAttribute("tabindex")).toBe("0");
|
|
expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1");
|
|
|
|
a.focus();
|
|
a.dispatchEvent(keydown(win, "ArrowRight"));
|
|
expect(doc.activeElement!.id).toBe("b");
|
|
expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
|
|
expect(a.getAttribute("tabindex")).toBe("-1");
|
|
|
|
(doc.querySelector("#b") as unknown as HTMLElement).dispatchEvent(keydown(win, "ArrowLeft"));
|
|
expect(doc.activeElement!.id).toBe("a");
|
|
|
|
a.dispatchEvent(keydown(win, "ArrowLeft"));
|
|
expect(doc.activeElement!.id).toBe("c");
|
|
|
|
c.dispatchEvent(keydown(win, "ArrowRight"));
|
|
expect(doc.activeElement!.id).toBe("a");
|
|
});
|
|
|
|
test("roving focus honours Home and End and skips disabled items", () => {
|
|
const win = mount(
|
|
`<div data-wrn-roving="vertical">
|
|
<button data-wrn-roving-item id="a">A</button>
|
|
<button data-wrn-roving-item id="b" disabled>B</button>
|
|
<button data-wrn-roving-item id="c">C</button>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
const a = doc.querySelector("#a") as unknown as HTMLElement;
|
|
a.focus();
|
|
|
|
a.dispatchEvent(keydown(win, "ArrowDown"));
|
|
expect(doc.activeElement!.id).toBe("c");
|
|
|
|
(doc.querySelector("#c") as unknown as HTMLElement).dispatchEvent(keydown(win, "Home"));
|
|
expect(doc.activeElement!.id).toBe("a");
|
|
|
|
a.dispatchEvent(keydown(win, "End"));
|
|
expect(doc.activeElement!.id).toBe("c");
|
|
});
|
|
|
|
test("horizontal roving ignores vertical arrows so the page still scrolls", () => {
|
|
const win = mount(
|
|
`<div data-wrn-roving="horizontal">
|
|
<button data-wrn-roving-item id="a">A</button>
|
|
<button data-wrn-roving-item id="b">B</button>
|
|
</div>`,
|
|
);
|
|
const a = win.document.querySelector("#a") as unknown as HTMLElement;
|
|
a.focus();
|
|
a.dispatchEvent(keydown(win, "ArrowDown"));
|
|
expect(win.document.activeElement!.id).toBe("a");
|
|
});
|
|
|
|
test("roving tabindex starts on the selected item, not the first", () => {
|
|
const win = mount(
|
|
`<div data-wrn-roving="horizontal">
|
|
<button data-wrn-roving-item id="a">A</button>
|
|
<button data-wrn-roving-item id="b" aria-selected="true">B</button>
|
|
</div>`,
|
|
);
|
|
expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
|
|
expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1");
|
|
});
|
|
|
|
test("nested roving groups do not capture the outer group items", () => {
|
|
const win = mount(
|
|
`<div data-wrn-roving="horizontal" id="outer">
|
|
<button data-wrn-roving-item id="a">A</button>
|
|
<div data-wrn-roving="vertical" id="inner">
|
|
<button data-wrn-roving-item id="x">X</button>
|
|
<button data-wrn-roving-item id="y">Y</button>
|
|
</div>
|
|
<button data-wrn-roving-item id="b">B</button>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
const a = doc.querySelector("#a") as unknown as HTMLElement;
|
|
a.focus();
|
|
a.dispatchEvent(keydown(win, "ArrowRight"));
|
|
expect(doc.activeElement!.id).toBe("b");
|
|
});
|
|
|
|
test("opening a modal dialog traps Tab inside it", () => {
|
|
const win = mount(
|
|
`<div>
|
|
<button id="outside">Outside</button>
|
|
<div data-show="true">
|
|
<section role="dialog" aria-modal="true" tabindex="-1">
|
|
<button id="first">First</button>
|
|
<button id="last">Last</button>
|
|
</section>
|
|
</div>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
const last = doc.querySelector("#last") as unknown as HTMLElement;
|
|
last.focus();
|
|
last.dispatchEvent(keydown(win, "Tab"));
|
|
expect(doc.activeElement!.id).toBe("first");
|
|
});
|
|
|
|
test("a hidden dialog does not trap Tab", () => {
|
|
const win = mount(
|
|
`<div>
|
|
<button id="outside">Outside</button>
|
|
<div data-show="false">
|
|
<section role="dialog" aria-modal="true" tabindex="-1">
|
|
<button id="first">First</button>
|
|
</section>
|
|
</div>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
const outside = doc.querySelector("#outside") as unknown as HTMLElement;
|
|
outside.focus();
|
|
outside.dispatchEvent(keydown(win, "Tab"));
|
|
expect(doc.activeElement!.id).toBe("outside");
|
|
});
|
|
|
|
test("an empty or false roving attribute opts the group out entirely", () => {
|
|
const win = mount(
|
|
`<div data-wrn-roving="">
|
|
<button data-wrn-roving-item="false" id="a">A</button>
|
|
<button data-wrn-roving-item="false" id="b">B</button>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
const a = doc.querySelector("#a") as unknown as HTMLElement;
|
|
expect(a.getAttribute("tabindex")).toBeNull();
|
|
a.focus();
|
|
a.dispatchEvent(keydown(win, "ArrowRight"));
|
|
expect(doc.activeElement!.id).toBe("a");
|
|
});
|
|
|
|
test("json inside a textarea is left alone, not read as mustaches", () => {
|
|
const win = mount(
|
|
`<div data-scope="count: 7">
|
|
<textarea id="editor">{"id":"a-1","count":3}</textarea>
|
|
<span id="out">{count}</span>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
// The surrounding scope still interpolates normally...
|
|
expect(doc.querySelector("#out")!.textContent).toBe("7");
|
|
// ...but the textarea holds data, and an expression engine would have
|
|
// evaluated {"id":"a-1","count":3} away and left it empty.
|
|
expect(doc.querySelector("#editor")!.textContent).toBe('{"id":"a-1","count":3}');
|
|
});
|
|
|
|
test("a closed drawer does not hold the body scroll lock", () => {
|
|
const win = mount(
|
|
`<div data-ui-component="Drawer" data-open="false">
|
|
<div class="layer">
|
|
<section role="dialog" aria-modal="true" tabindex="-1">
|
|
<button id="inside">Inside</button>
|
|
</section>
|
|
</div>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
/*
|
|
* A drawer animates open, so its panel cannot be hidden with data-show --
|
|
* display:none is not transitionable. It publishes data-open instead. Reading
|
|
* only data-show made every closed drawer look open, which locked the body
|
|
* and left the page unscrollable.
|
|
*/
|
|
expect(doc.body.style.overflow).not.toBe("hidden");
|
|
|
|
const outside = doc.querySelector("#inside") as unknown as HTMLElement;
|
|
outside.focus();
|
|
outside.dispatchEvent(keydown(win, "Tab"));
|
|
// No trap either: focus is free to leave a closed dialog.
|
|
expect(doc.activeElement!.id).toBe("inside");
|
|
});
|
|
|
|
test("an open drawer locks the body scroll and traps Tab", () => {
|
|
const win = mount(
|
|
`<div data-ui-component="Drawer" data-open="true">
|
|
<section role="dialog" aria-modal="true" tabindex="-1">
|
|
<button id="first">First</button>
|
|
<button id="last">Last</button>
|
|
</section>
|
|
</div>`,
|
|
);
|
|
const doc = win.document;
|
|
expect(doc.body.style.overflow).toBe("hidden");
|
|
const last = doc.querySelector("#last") as unknown as HTMLElement;
|
|
last.focus();
|
|
last.dispatchEvent(keydown(win, "Tab"));
|
|
expect(doc.activeElement!.id).toBe("first");
|
|
});
|