feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs
DataTable replaces the 20-line Table scaffold entirely: columns, sorting, filtering, pagination, selection, bulk actions, comparison layout, sticky first column, custom HTML cells, and a remote source driven by a `request` output rather than a function prop (props travel as HTML attributes, so a function arrives as its own source text). Toaster replaces the hand-rolled status div: tone icons, actions, hover pause/resume and a progress bar. Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing ever moved focus into the panel, so the @keydown handler on their root never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap and a body scroll lock now live in the reactive runtime, shared by both. ContextMenu placed pointer menus by subtracting a guessed 340x420 from the viewport, which pushed every menu that was not that size away from the pointer; it now positions at the pointer and lets the anchored clamp pull it back once it can be measured. The reactive runtime size budget moves 150k -> 175k to cover anchored overlays, dialog behaviour, the toaster and the DataTable client half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -573,3 +573,324 @@ test("cache invalidation refetches matching client Async boundaries", async () =
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user