@
Quality / quality (ubuntu-latest) (push) Failing after 12m30s
Quality / quality (windows-latest) (push) Canceled after 0s

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:
2026-08-07 14:57:17 +05:30
parent 296728d51d
commit cc98bccd6c
151 changed files with 14350 additions and 9189 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ import {
renderThemeCss,
renderThemeRuntime,
} from "@wrnexus/styles";
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
import { uiComponentsDir, uiCss } from "@wrnexus/ui/registry";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n } from "@wrnexus/i18n";
import { pathToFileURL } from "node:url";
+1 -1
View File
@@ -6,7 +6,7 @@
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { uiComponentNames, uiComponentPath } from "@wrnexus/ui";
import { uiComponentNames, uiComponentPath } from "@wrnexus/ui/registry";
export function runEject(appRoot: string, names: string[]): void {
const root = resolve(appRoot);
+1 -1
View File
@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from "node:path";
import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router";
import { createPluginRunner, discoverPlugins } from "@wrnexus/plugin";
import { loadAppConfig } from "@wrnexus/styles";
import { uiComponentsDir } from "@wrnexus/ui";
import { uiComponentsDir } from "@wrnexus/ui/registry";
import { parse } from "@wrnexus/syntax";
export type InspectTarget =
+28 -26
View File
@@ -80,43 +80,45 @@ export default authzMiddleware({ catalog: getAuthzCatalog(), store: memoryPermis
});
let port: number | undefined;
let stderrText = "";
try {
const reader = proc.stdout.getReader();
const errReader = proc.stderr.getReader();
const decoder = new TextDecoder();
let buffered = "";
const deadline = Date.now() + 20_000;
const TIMED_OUT = Symbol("timed out");
while (port === undefined && Date.now() < deadline) {
const outcome = await Promise.race([
reader.read(),
new Promise<typeof TIMED_OUT>((resolve) => setTimeout(() => resolve(TIMED_OUT), 250)),
]);
if (outcome === TIMED_OUT) continue;
const { value, done } = outcome;
if (done) break;
buffered += decoder.decode(value);
const match = /listening on http:\/\/[^:]+:(\d+)/.exec(buffered);
if (match) port = Number(match[1]);
/*
* One outstanding read at a time, with the deadline enforced by killing
* the process rather than by racing the read.
*
* This loop used to race a FRESH reader.read() against a 250ms timer each
* pass. When the timer won, the abandoned read stayed pending and later
* resolved with the next chunk — which nothing was waiting for any more,
* so that output was dropped on the floor. On a loaded machine the very
* chunk carrying "listening on" could be swallowed, and the loop then
* spun to the full deadline and failed a server that had in fact started
* immediately. Reading without racing cannot lose a chunk: if the server
* really never starts, the kill below ends the stream and read() reports
* done.
*/
const giveUp = setTimeout(() => proc.kill(), 20_000);
try {
while (port === undefined) {
const { value, done } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
const match = /listening on http:\/\/[^:]+:(\d+)/.exec(buffered);
if (match) port = Number(match[1]);
}
} finally {
clearTimeout(giveUp);
reader.releaseLock();
}
reader.releaseLock();
if (port === undefined) {
// Drain stderr for a useful failure message before giving up.
const errOutcome = await Promise.race([
errReader.read(),
new Promise<typeof TIMED_OUT>((resolve) => setTimeout(() => resolve(TIMED_OUT), 500)),
]);
if (errOutcome !== TIMED_OUT && errOutcome.value) {
stderrText += decoder.decode(errOutcome.value);
}
errReader.releaseLock();
const stderrText = await new Response(proc.stderr).text().catch(() => "");
throw new Error(
`production server never printed a "listening on" line within 20s. stderr:\n${stderrText}`,
);
}
errReader.releaseLock();
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
expect(response.status).toBe(200);
+26 -3
View File
@@ -180,13 +180,34 @@ function functionEntry(
availableFunctions: string[],
): string {
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
/*
* Names the function body declares for itself.
*
* Props and state are destructured into the SAME scope as the body, so a
* body that declares `var size` when `size` is also a prop produced
* "Identifier 'size' has already been declared" and the entire module
* failed to parse -- taking every function in the component down with it,
* with nothing to point at the one line responsible. Skipping the alias for
* a shadowed name is also what plain JavaScript does: inside that function
* the local wins.
*/
const declaredLocals = new Set<string>();
for (const match of fn.body.matchAll(
/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)|\bfunction\s+([A-Za-z_$][\w$]*)/g,
)) {
const name = match[1] ?? match[2];
if (name) declaredLocals.add(name);
}
const stateNames = ast.states
.filter(
(state) =>
state.runtime !== "server" &&
safeIdentifier(state.name) &&
!RUNTIME_BINDINGS.has(state.name) &&
!parameterNames.has(state.name),
!parameterNames.has(state.name) &&
!declaredLocals.has(state.name),
)
.map((state) => state.name);
const stateSet = new Set(stateNames);
@@ -196,7 +217,8 @@ function functionEntry(
safeIdentifier(prop.name) &&
!RUNTIME_BINDINGS.has(prop.name) &&
!parameterNames.has(prop.name) &&
!stateSet.has(prop.name),
!stateSet.has(prop.name) &&
!declaredLocals.has(prop.name),
)
.map((prop) => prop.name);
const functionAliases = availableFunctions.filter(
@@ -205,7 +227,8 @@ function functionEntry(
!RUNTIME_BINDINGS.has(name) &&
!parameterNames.has(name) &&
!stateSet.has(name) &&
!propNames.includes(name),
!propNames.includes(name) &&
!declaredLocals.has(name),
);
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
const initialStateSnapshot = stateNames.length
+72 -4
View File
@@ -170,6 +170,35 @@ function eventAttribute(name: string): string {
return `data-on-${name}`;
}
/**
* Event attribute for a handler written on a *component tag*
* (`<Modal @confirm="save()">`).
*
* These need their own attribute name. The mount's attributes are forwarded
* into the component and land on its view root, i.e. inside the component's
* own `data-scope` -- but the statement (`save()`) belongs to the parent that
* wrote the tag. Emitting `data-on-confirm` makes the child's runtime bind it
* against the child's scope, where the parent's functions and state do not
* exist, so the handler silently does nothing. `data-wrn-out-*` is ignored by
* the child and claimed by the mounting scope instead.
*
* `window:`/`document:` (and the browser/mobile bridges) keep the plain
* `data-on-*` form: those bind to a global target rather than to the element,
* and the runtime has no component-output path for them.
*/
function componentEventAttribute(name: string): string {
if (
name.startsWith("window:") ||
name.startsWith("document:") ||
name.startsWith("browser-") ||
name.startsWith("mobile-")
) {
return eventAttribute(name);
}
return `data-wrn-out-${name}`;
}
function reactiveAttrValue(raw: string, reactive: PageReactive): string | null {
let found = false;
const value = raw.replace(/\{([^{}]+)\}/g, (whole, inner: string) => {
@@ -353,7 +382,11 @@ function renderLoopBody(node: ViewNode): string {
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => {
const name = attr.event ? eventAttribute(attr.name) : attr.name;
const name = attr.event
? componentTag
? componentEventAttribute(attr.name)
: eventAttribute(attr.name)
: attr.name;
if (attr.boolean) {
return escLit(` ${name}`);
@@ -699,7 +732,9 @@ function renderNestedComponentInvocation(
if (attr.event) {
return (
escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`)
escLit(` ${componentEventAttribute(attr.name)}="`) +
escLit(attrEscape(attr.value)) +
escLit(`"`)
);
}
@@ -2134,6 +2169,20 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
attrEscape(JSON.stringify([a.name, a.value])),
)}"`
: "";
/*
* A boolean attribute whose expression names a loop variable cannot
* be resolved on the server: __wireBooleanAttr runs at render time,
* where `row` or `item` simply does not exist, and the emitted
* module blew up. Leave the attribute off the server output and let
* the client bind set it -- the runtime toggles boolean attributes
* rather than stringifying them, so `checked={isSelected(row)}`
* behaves correctly once hydrated.
*/
if (referencesLoopVariable) {
return ` data-wrn-bind-${bindIndex++}="${escLit(
attrEscape(JSON.stringify([a.name, a.value])),
)}"`;
}
return `\${__wireBooleanAttr(${JSON.stringify(a.name)}, ${elementContext.resolveExpr(expression)})}${marker}`;
}
@@ -2142,6 +2191,22 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
}
const wholeExpression = wholeAttributeExpression(a.value);
/*
* data-show carries an EXPRESSION, not a value. The client re-evaluates
* whatever string it finds in the attribute on every state change, so
* interpolating `{open || visible}` down to the literal "false" at
* render time froze the directive: the element could never be shown
* again, no matter what the state did. A data-wrn-bind marker did not
* save it either -- the bind rewrites the same attribute the directive
* reads, and the directive had already captured "false" as its
* expression. Emitting the expression verbatim (the form Modal uses,
* data-show="isOpen()") makes both authoring styles behave the same.
*/
if (a.name === "data-show" && wholeExpression) {
return ` data-show="${escLit(attrEscape(wholeExpression))}"`;
}
const compiledValue =
isExplicitComponentMount && wholeExpression
? `\${__wireProp(${elementContext.resolveExpr(wholeExpression)})}`
@@ -2563,7 +2628,10 @@ function __wireSpreadAttrs(value: any): string {
lowerName === "style" ||
lowerName === "slot" ||
lowerName === "data-component" ||
lowerName.startsWith("data-wrn")
// Internal markers must not leak through a spread -- except the
// parent's output handlers, whose whole job is to ride from the mount
// onto the view root so the mounting scope can bind them there.
(lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-"))
) {
continue;
}
@@ -2699,7 +2767,7 @@ function __wireProp(v: any): string {
}
function renderPageComponentAttr(attr: Attr, dynamicExpressions: string[]): string {
if (attr.event) {
return ` ${eventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
return ` ${componentEventAttribute(attr.name)}="${attrEscape(attr.value)}"`;
}
if (attr.boolean) {
@@ -176,7 +176,10 @@ function __wireSpreadAttrs(value: any): string {
lowerName === "style" ||
lowerName === "slot" ||
lowerName === "data-component" ||
lowerName.startsWith("data-wrn")
// Internal markers must not leak through a spread -- except the
// parent's output handlers, whose whole job is to ride from the mount
// onto the view root so the mounting scope can bind them there.
(lowerName.startsWith("data-wrn") && !lowerName.startsWith("data-wrn-out-"))
) {
continue;
}
+72
View File
@@ -1398,3 +1398,75 @@ test("strict-mode reserved prop names compile through safe local references", ()
expect(output).toContain("(__p_private) ?");
expect(output).not.toContain("const private:");
});
// data-show holds an expression the client re-evaluates, so it must survive
// compilation verbatim. Interpolating `{open || visible}` to the literal
// "false" froze every overlay that used the braced form -- Drawer, Dropdown,
// Popover, ContextMenu and Tooltip could never open.
test("data-show keeps its expression instead of being interpolated away", () => {
const source = `component Panel {
props {
open: boolean = false
}
state visible = false
view {
<div class="panel" data-show='{open || visible}'>body</div>
}
}
`;
const code = compileWireFile(source, "Panel.wrn");
expect(code).toContain(`data-show="open || visible"`);
expect(code).not.toContain(`data-show="false"`);
});
// Props and state are destructured into the same scope as the function body,
// so a local that shares one of their names used to emit a redeclaration and
// break the whole generated module with a parse error naming no source line.
test("a local variable may shadow a prop without breaking the module", () => {
const source = `component Sized {
props {
size: number = 10
}
state total = 0
functions {
client function recompute() {
var size = 4
var total = size * 2
return total
}
}
view {
<div class="sized">{total}</div>
}
}
`;
const code = compileWireFile(source, "Sized.wrn");
// The alias must be dropped, not emitted alongside the local declaration.
expect(code).not.toContain("const { size } = context.props;");
expect(code).toContain("var size = 4");
});
// A boolean attribute whose expression names a loop variable cannot be
// resolved on the server -- __wireBooleanAttr runs at render time, where the
// loop variable does not exist, and the generated module failed outright.
test("a boolean attribute bound to a loop variable binds on the client", () => {
const source = `component Picker {
state rows = []
functions {
shared function isOn(row) {
return row.on
}
}
view {
<ul>
<li data-for="row in rows" data-key="row.id">
<input type="checkbox" checked='{isOn(row)}' />
</li>
</ul>
}
}
`;
const code = compileWireFile(source, "Picker.wrn");
expect(code).not.toContain('__wireBooleanAttr("checked"');
expect(code).toContain("data-wrn-bind-");
});
File diff suppressed because it is too large Load Diff
+321
View File
@@ -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&#10;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");
});
+1 -1
View File
@@ -18,7 +18,7 @@ import {
type PwaConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui";
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui/registry";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
import {
+1 -1
View File
@@ -48,7 +48,7 @@ export async function expandStaticComponents(
}
const rendered = await component.mod.render(parseComponentProps(attributes!));
output += await expandStaticComponents(
fillSlots(String(rendered), body.inner),
fillSlots(String(rendered), body.inner, true),
components,
depth + 1,
);
+39 -9
View File
@@ -1240,9 +1240,20 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
return compressResponse(req, res);
} catch (err) {
const app = process.env.WRNEXUS_APP_NAME ?? "app";
const detail =
let detail =
err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
// AggregateError (e.g. Bun.build() "Bundle failed") hides its real cause in
// `.errors` — the top-level message/stack alone is useless for diagnosing a
// failed bundle. Print every nested error so the actual failure is visible.
const nested = (err as { errors?: unknown[] } | undefined)?.errors;
if (Array.isArray(nested) && nested.length) {
detail +=
"\n caused by:\n" +
nested
.map((e, i) => ` [${i}] ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
.join("\n");
}
console.error(
`[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`,
);
@@ -1531,7 +1542,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}
const policy = (mod.__wrnexusCache ?? {}) as Record<string, string>;
const strategy = policy.strategy?.toLowerCase();
const renderComponent = () => fillSlots(String(render(props)), inner);
const renderComponent = () => fillSlots(String(render(props)), inner, true);
const rendered =
strategy && !["none", "no-store", "request"].includes(strategy)
? await cache.getOrLoad(
@@ -2331,24 +2342,43 @@ function extractSlots(inner: string): { named: Record<string, string>; def: stri
* <slot></slot> everything else (the default slot)
* A `<slot>fallback</slot>` keeps its fallback when nothing is provided.
*/
export function fillSlots(html: string, inner: string): string {
export function fillSlots(html: string, inner: string, markOwner = false): string {
if (!/<slot\b/.test(html)) return html;
const { named, def } = extractSlots(inner);
const defTrimmed = def.trim();
/*
* Slot content is authored by whoever wrote the component tag, but it is
* spliced *inside* the component's own [data-scope] root. Left unmarked the
* client runtime hands it to the component -- so `@input` handlers and
* `{state}` in slot content resolve against the component's scope, where
* the page's functions and state do not exist (and same-named component
* state silently shadows them).
*
* Wrapping it in <wrn-slot data-wrn-slot> gives the runtime an ownership
* boundary to walk back out of, so the markup is hydrated by the scope that
* actually wrote it. The wrapper is display: contents, so it adds an
* ownership marker without adding a box to the layout.
*
* Only component mounts pass markOwner. Layouts also go through fillSlots,
* but a layout wraps the whole page rather than being mounted inside a
* parent scope, so there is no outer scope to hand its content back to.
*/
const wrap = (content: string): string =>
markOwner && content.trim() ? `<wrn-slot data-wrn-slot="">${content}</wrn-slot>` : content;
return html
.replace(
/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?\/>/g,
(_m, name: string) => named[name] ?? "",
.replace(/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?\/>/g, (_m, name: string) =>
wrap(named[name] ?? ""),
)
.replace(
/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?>([\s\S]*?)<\/slot>/g,
(_m, name: string, fallback: string) =>
named[name] != null && named[name]!.trim() ? named[name]! : fallback,
named[name] != null && named[name]!.trim() ? wrap(named[name]!) : fallback,
)
.replace(/<slot\s*\/>/g, defTrimmed ? def : "")
.replace(/<slot\s*\/>/g, defTrimmed ? wrap(def) : "")
.replace(/<slot\b[^>]*>([\s\S]*?)<\/slot>/g, (_m, fallback: string) =>
defTrimmed ? def : fallback,
defTrimmed ? wrap(def) : fallback,
);
}
+27 -1
View File
@@ -521,7 +521,33 @@ export function renderThemeCss(theme: ResolvedTheme): string {
return (
blocks.join("\n") +
'\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n'
'\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' +
// <wrn-slot> wraps content spliced into a component's <slot> so the client
// runtime can tell which scope authored it. It is a pure ownership marker
// and must never introduce a box: an unknown element would otherwise
// default to display:inline and break the component's own flex/grid layout.
/*
* Shape and elevation tokens the Wire UI stylesheet depends on.
*
* ui.css references --wire-radius-sm (51 times) and --wire-radius-md (21)
* but nothing ever defined them, so every component using them fell back
* to square corners in any app that did not declare them itself.
* --wire-shadow-1 had the same problem: the showcase declared it
* privately, so the library looked right there and flat everywhere else.
* Defining them with the rest of the theme keeps a component looking the
* same in every app; an app can still override them.
*/
":root{--wire-radius-sm:0.55rem;--wire-radius-md:0.9rem;--wire-radius-lg:1.35rem;" +
"--wire-shadow-1:0 1px 2px color-mix(in srgb, black 22%, transparent)," +
"0 10px 30px color-mix(in srgb, black 14%, transparent);}\n" +
"wrn-slot{display:contents;}\n" +
// A [data-for] element is the loop TEMPLATE, not a rendered row: the server
// ships it with mustaches unresolved ({item.title}) and hydration replaces
// it with a comment marker. Painted as-is it flashes one blank, literal
// row on every page load before the runtime boots. Hiding it costs
// nothing after hydration -- the runtime strips data-for from the clones
// it renders, so this only ever matches the template itself.
"[data-for]{display:none !important;}\n"
);
}
+37
View File
@@ -284,6 +284,18 @@ export class Lexer {
* Read a `{ ... }` block and return its INNER text (no outer braces), with
* brace counting that respects string and template literals so a `}` inside a
* string doesn't end the block early.
*
* Comments are skipped as well. Without that, an apostrophe in ordinary
* prose `/* the panel's color *\/`, `// the Input's slot` opened a
* string that ran to the next apostrophe, swallowing every brace in between
* and failing the whole component with "Unbalanced braces" pointing at the
* block's opening line. Comments are where apostrophes actually occur, so
* that error was almost always a false alarm.
*
* A `//` line comment is only recognised at the start of a line (after
* whitespace), which is where every comment in a `.wrn` file is written.
* Recognising it mid-line would break the far more common case of a bare
* URL in view text, where `https://…` is not inside quotes.
*/
readBalancedBraces(): string {
this.skipTrivia();
@@ -295,6 +307,8 @@ export class Lexer {
let depth = 0;
let i = this.pos;
let str: string | null = null;
/** True while only whitespace has been seen since the last newline. */
let atLineStart = false;
for (; i < src.length; i++) {
const c = src[i]!;
if (str) {
@@ -305,6 +319,29 @@ export class Lexer {
if (c === str) str = null;
continue;
}
if (c === "\n") {
atLineStart = true;
continue;
}
if (c === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
if (close === -1) break; // unterminated: fall through to the error
i = close + 1;
atLineStart = false;
continue;
}
if (atLineStart && c === "/" && src[i + 1] === "/") {
const newline = src.indexOf("\n", i + 2);
if (newline === -1) break;
i = newline - 1; // let the loop's own increment land on the newline
continue;
}
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
if (c === '"' || c === "'" || c === "`") {
str = c;
continue;
+28
View File
@@ -207,3 +207,31 @@ test("maps literal unions to their runtime primitive types", async () => {
expect(runtimeTypeOf("1 | 2 | 3")).toBe("number");
expect(runtimeTypeOf("true | false")).toBe("boolean");
});
// An apostrophe in prose is not a string. The brace scanner used to treat one
// as an opening quote and swallow every brace until the next apostrophe, so a
// comment like "the Input's slot" broke the whole component with a baffling
// "Unbalanced braces" error pointing at the block's first line.
test("comments may contain apostrophes without unbalancing a block", () => {
const source = `component Demo {
style {
/* The panel's own color -- do not inherit it. */
.demo {
color: red;
}
// A trailing note about the card's border.
.demo-b {
color: blue;
}
}
view {
<p>Docs at https://example.com/a//b are not comments.</p>
}
}
`;
const ast = parse(source);
expect(ast.name).toBe("Demo");
expect(ast.styles.join(" ")).toContain(".demo-b");
// The URL in view text must survive: `//` is only a comment at line start.
expect(JSON.stringify(ast.view)).toContain("https://example.com/a//b");
});
+16 -16
View File
@@ -359,6 +359,15 @@ Reusable preference switcher component.
- Slots: None
- Outputs: `theme({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `color({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `language({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
### Toaster
Reusable toaster component.
- Mount: `data-component="Toaster"`
- Props: `color: string = "info"`, `size: string = "default"`, `position: string = "bottom-right"`, `duration: number = 4500`, `max: number = 4`, `pauseOnHover: boolean = true`, `showIcon: boolean = true`, `successIcon: string = ""`, `dangerIcon: string = ""`, `warningIcon: string = ""`, `infoIcon: string = ""`, `closable: boolean = true`, `showProgress: boolean = true`, `closeLabel: string = "Dismiss notification"`, `class: string = ""`
- Slots: None
- Outputs: `show({ id: number; message: string; tone: string })`, `dismiss({ id: number; reason: string })`, `action({ id: number; sourceEvent: Event })`
## Data
### MetricCard
@@ -554,15 +563,6 @@ Theme-aware, responsive data map component.
- Slots: `default`
- Outputs: `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
### DataTable
Theme-aware, responsive data table component.
- Mount: `data-component="DataTable"`
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Data Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
- Slots: `default`
- Outputs: `sort({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `rowClick({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `pageChange({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
### DragAndDrop
Theme-aware, responsive drag and drop component.
@@ -945,7 +945,7 @@ Open an accessible keyboard-aware action menu from pointer or keyboard context i
Present responsive modal side or bottom content with focus management, backdrop behavior, slots, and close events.
- Mount: `data-component="Drawer"`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `placement: string = "right"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `title: string = "Drawer"`, `description: string = ""`, `icon: string = ""`, `label: string = "Drawer"`, `closeLabel: string = "Close drawer"`, `showClose: boolean = true`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `overlay: boolean = true`, `scrollable: boolean = true`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `class: string = ""`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `placement: string = "right"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `title: string = "Drawer"`, `description: string = ""`, `icon: string = ""`, `label: string = "Drawer"`, `closeLabel: string = "Close drawer"`, `showClose: boolean = true`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `duration: number = 260`, `overlay: boolean = true`, `scrollable: boolean = true`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `open({ placement: string; sourceEvent: Event })`, `close({ reason: string; placement: string; sourceEvent: Event })`, `cancel({ placement: string; sourceEvent: Event })`
@@ -963,7 +963,7 @@ Open an accessible anchored menu with keyboard navigation, item selection, actio
Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing.
- Mount: `data-component="Modal"`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `class: string = ""`
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `scrollBehavior: string = "inside"`, `class: string = ""`
- Slots: `trigger`, `header`, `default`, `footer`
- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `cancel({ sourceEvent: Event })`, `confirm({ sourceEvent: Event })`
@@ -987,11 +987,11 @@ Show concise accessible contextual help on hover, focus, click, or controlled op
## Tables
### Table
### DataTable
Theme-aware, responsive table component.
Sortable, filterable, paginated data table with row selection.
- Mount: `data-component="Table"`
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
- Mount: `data-component="DataTable"`
- Props: `color: string = "primary"`, `size: string = "default"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `rowKey: string = "id"`, `remote: boolean = false`, `loadingLabel: string = "Loading"`, `errorLabel: string = "Could not load this data"`, `retryLabel: string = "Try again"`, `caption: string = ""`, `description: string = ""`, `searchable: boolean = true`, `searchPlaceholder: string = "Search"`, `paginated: boolean = true`, `pageSize: number = 10`, `paginationStyle: string = "compact"`, `pageSizes: number[] = [10, 25, 50]`, `selectable: boolean = false`, `actions: unknown[] = []`, `striped: boolean = true`, `bordered: boolean = true`, `gridlines: string = "rows"`, `density: string = "default"`, `emptyLabel: string = "No records to show"`, `noResultsLabel: string = "No records match your search"`, `clearSearchLabel: string = "Clear search"`, `stickyFirstColumn: boolean = false`, `layout: string = "rows"`, `class: string = ""`
- Slots: `default`
- Outputs: `sort({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `rowClick({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
- Outputs: `sort({ key: string; direction: string })`, `search({ query: string })`, `pageChange({ page: number; pageSize: number })`, `select({ selected: Array<string | number>; all: boolean })`, `change({ page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string })`, `rowClick({ row: object; sourceEvent: Event })`, `action({ id: string; selected: Array<string | number>; rows: object[]; sourceEvent: Event })`, `request({ instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string })`
+7 -7
View File
@@ -168,8 +168,8 @@
},
{
"name": "DataTable",
"category": "integrations",
"purpose": "Theme-aware, responsive data table component."
"category": "tables",
"purpose": "Sortable, filterable, paginated data table with row selection."
},
{
"name": "DatePicker",
@@ -471,11 +471,6 @@
"category": "forms",
"purpose": "Theme-aware, responsive switch component."
},
{
"name": "Table",
"category": "tables",
"purpose": "Theme-aware, responsive table component."
},
{
"name": "Tabs",
"category": "navigation",
@@ -511,6 +506,11 @@
"category": "integrations",
"purpose": "Theme-aware, responsive toast notifications component."
},
{
"name": "Toaster",
"category": "core",
"purpose": "Reusable toaster component."
},
{
"name": "ToggleCount",
"category": "advanced-forms",
+343 -98
View File
@@ -4569,16 +4569,9 @@
{
"name": "DataTable",
"mount": "DataTable",
"category": "integrations",
"purpose": "Theme-aware, responsive data table component.",
"category": "tables",
"purpose": "Sortable, filterable, paginated data table with row selection.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "color",
"type": "string",
@@ -4587,10 +4580,10 @@
"options": []
},
{
"name": "caption",
"name": "size",
"type": "string",
"required": false,
"default": "\"Data Table\"",
"default": "\"default\"",
"options": []
},
{
@@ -4607,6 +4600,111 @@
"default": "[]",
"options": []
},
{
"name": "rowKey",
"type": "string",
"required": false,
"default": "\"id\"",
"options": []
},
{
"name": "remote",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "loadingLabel",
"type": "string",
"required": false,
"default": "\"Loading\"",
"options": []
},
{
"name": "errorLabel",
"type": "string",
"required": false,
"default": "\"Could not load this data\"",
"options": []
},
{
"name": "retryLabel",
"type": "string",
"required": false,
"default": "\"Try again\"",
"options": []
},
{
"name": "caption",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "description",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "searchable",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "searchPlaceholder",
"type": "string",
"required": false,
"default": "\"Search\"",
"options": []
},
{
"name": "paginated",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "pageSize",
"type": "number",
"required": false,
"default": "10",
"options": []
},
{
"name": "paginationStyle",
"type": "string",
"required": false,
"default": "\"compact\"",
"options": []
},
{
"name": "pageSizes",
"type": "number[]",
"required": false,
"default": "[10, 25, 50]",
"options": []
},
{
"name": "selectable",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "actions",
"type": "unknown[]",
"required": false,
"default": "[]",
"options": []
},
{
"name": "striped",
"type": "boolean",
@@ -4614,6 +4712,62 @@
"default": "true",
"options": []
},
{
"name": "bordered",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "gridlines",
"type": "string",
"required": false,
"default": "\"rows\"",
"options": []
},
{
"name": "density",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "emptyLabel",
"type": "string",
"required": false,
"default": "\"No records to show\"",
"options": []
},
{
"name": "noResultsLabel",
"type": "string",
"required": false,
"default": "\"No records match your search\"",
"options": []
},
{
"name": "clearSearchLabel",
"type": "string",
"required": false,
"default": "\"Clear search\"",
"options": []
},
{
"name": "stickyFirstColumn",
"type": "boolean",
"required": false,
"default": "false",
"options": []
},
{
"name": "layout",
"type": "string",
"required": false,
"default": "\"rows\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -4626,26 +4780,47 @@
"outputs": [
{
"name": "sort",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
"payloadType": "{ key: string; direction: string }"
},
{
"name": "select",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
},
{
"name": "change",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
},
{
"name": "rowClick",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
"name": "search",
"payloadType": "{ query: string }"
},
{
"name": "pageChange",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
"payloadType": "{ page: number; pageSize: number }"
},
{
"name": "select",
"payloadType": "{ selected: Array<string | number>; all: boolean }"
},
{
"name": "change",
"payloadType": "{ page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string }"
},
{
"name": "rowClick",
"payloadType": "{ row: object; sourceEvent: Event }"
},
{
"name": "action",
"payloadType": "{ id: string; selected: Array<string | number>; rows: object[]; sourceEvent: Event }"
},
{
"name": "request",
"payloadType": "{ instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string }"
}
],
"events": ["sort", "select", "change", "rowClick", "pageChange"],
"events": [
"sort",
"search",
"pageChange",
"select",
"change",
"rowClick",
"action",
"request"
],
"source": "components/DataTable.wrn"
},
{
@@ -5220,6 +5395,13 @@
"default": "true",
"options": []
},
{
"name": "duration",
"type": "number",
"required": false,
"default": "260",
"options": []
},
{
"name": "overlay",
"type": "boolean",
@@ -8736,6 +8918,13 @@
"default": "true",
"options": []
},
{
"name": "scrollBehavior",
"type": "string",
"required": false,
"default": "\"inside\"",
"options": []
},
{
"name": "class",
"type": "string",
@@ -12009,80 +12198,6 @@
"events": ["input", "change", "focus", "blur"],
"source": "components/Switch.wrn"
},
{
"name": "Table",
"mount": "Table",
"category": "tables",
"purpose": "Theme-aware, responsive table component.",
"props": [
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "color",
"type": "string",
"required": false,
"default": "\"primary\"",
"options": []
},
{
"name": "caption",
"type": "string",
"required": false,
"default": "\"Table\"",
"options": []
},
{
"name": "columns",
"type": "unknown[]",
"required": false,
"default": "[]",
"options": []
},
{
"name": "rows",
"type": "unknown[]",
"required": false,
"default": "[]",
"options": []
},
{
"name": "striped",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
}
],
"slots": ["default"],
"outputs": [
{
"name": "sort",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
},
{
"name": "select",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
},
{
"name": "rowClick",
"payloadType": "{ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null"
}
],
"events": ["sort", "select", "rowClick"],
"source": "components/table.wrn"
},
{
"name": "Tabs",
"mount": "Tabs",
@@ -12893,6 +13008,136 @@
"events": ["add", "dismiss", "clear", "action"],
"source": "components/ToastNotifications.wrn"
},
{
"name": "Toaster",
"mount": "Toaster",
"category": "core",
"purpose": "Reusable toaster component.",
"props": [
{
"name": "color",
"type": "string",
"required": false,
"default": "\"info\"",
"options": []
},
{
"name": "size",
"type": "string",
"required": false,
"default": "\"default\"",
"options": []
},
{
"name": "position",
"type": "string",
"required": false,
"default": "\"bottom-right\"",
"options": []
},
{
"name": "duration",
"type": "number",
"required": false,
"default": "4500",
"options": []
},
{
"name": "max",
"type": "number",
"required": false,
"default": "4",
"options": []
},
{
"name": "pauseOnHover",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "showIcon",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "successIcon",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "dangerIcon",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "warningIcon",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "infoIcon",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
},
{
"name": "closable",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "showProgress",
"type": "boolean",
"required": false,
"default": "true",
"options": []
},
{
"name": "closeLabel",
"type": "string",
"required": false,
"default": "\"Dismiss notification\"",
"options": []
},
{
"name": "class",
"type": "string",
"required": false,
"default": "\"\"",
"options": []
}
],
"slots": [],
"outputs": [
{
"name": "show",
"payloadType": "{ id: number; message: string; tone: string }"
},
{
"name": "dismiss",
"payloadType": "{ id: number; reason: string }"
},
{
"name": "action",
"payloadType": "{ id: number; sourceEvent: Event }"
}
],
"events": ["show", "dismiss", "action"],
"source": "components/Toaster.wrn"
},
{
"name": "ToggleCount",
"mount": "ToggleCount",
+7 -2
View File
@@ -45,8 +45,12 @@ component ContextMenu {
sourceEvent.preventDefault()
}
if (placement === "pointer" && sourceEvent) {
positionX = Math.max(12, Math.min(sourceEvent.clientX || 12, window.innerWidth - 340))
positionY = Math.max(12, Math.min(sourceEvent.clientY || 12, window.innerHeight - 420))
// Place the menu at the pointer and let the anchored clamp in the
// runtime pull it back on screen once it has been laid out and can
// actually be measured. Subtracting a guessed 340x420 here instead
// pushed every menu that was not that size away from the pointer.
positionX = Math.max(12, sourceEvent.clientX || 12)
positionY = Math.max(12, sourceEvent.clientY || 12)
}
visible = true
output.open({
@@ -161,6 +165,7 @@ component ContextMenu {
<div
class="wire-context-menu__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
role="menu"
aria-label='{label}'
File diff suppressed because it is too large Load Diff
+83 -1
View File
@@ -20,6 +20,8 @@ open: boolean = false
showClose: boolean = true
closeOnBackdrop: boolean = true
closeOnEscape: boolean = true
// Open/close animation length in ms. 0 disables the animation entirely.
duration: number = 260
overlay: boolean = true
scrollable: boolean = true
triggerLabel: string = ""
@@ -79,7 +81,9 @@ open: boolean = false
data-overlay='{overlay ? "true" : "false"}'
data-scrollable='{scrollable ? "true" : "false"}'
class='wire-drawer {class}'
style='--drawer-duration: {duration}ms'
>
{#if triggerLabel}
<button
type="button"
@@ -101,7 +105,6 @@ open: boolean = false
<div
class="wire-drawer__layer"
data-show='{open || visible}'
role="presentation"
@keydown='handleKeydown(event)'
>
@@ -218,12 +221,32 @@ open: boolean = false
cursor: pointer;
}
/*
* The layer stays in the layout and is revealed by [data-open]; it used to
* be toggled with data-show, which sets display:none, and display cannot
* be transitioned -- the drawer simply snapped in and out. visibility is
* delayed by the duration on the way out so the panel can finish sliding
* before the layer is taken out of the hit-testing tree.
*/
.wire-drawer__layer {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
pointer-events: none;
visibility: hidden;
opacity: 0;
transition:
opacity var(--drawer-duration, 260ms) ease,
visibility 0s linear var(--drawer-duration, 260ms);
}
.wire-drawer[data-open="true"] .wire-drawer__layer {
visibility: visible;
opacity: 1;
transition:
opacity var(--drawer-duration, 260ms) ease,
visibility 0s linear 0s;
}
.wire-drawer__backdrop {
@@ -262,6 +285,38 @@ open: boolean = false
box-shadow: -28px 0 80px color-mix(in srgb, black 28%, transparent);
pointer-events: auto;
overflow: hidden;
/* Slides in from whichever edge the placement puts it on. */
transform: translateX(100%);
transition: transform var(--drawer-duration, 260ms) cubic-bezier(0.32, 0.72, 0, 1);
}
.wire-drawer[data-open="true"] .wire-drawer__panel {
transform: none;
}
.wire-drawer[data-placement="left"] .wire-drawer__panel {
transform: translateX(-100%);
}
.wire-drawer[data-placement="top"] .wire-drawer__panel {
transform: translateY(-100%);
}
.wire-drawer[data-placement="bottom"] .wire-drawer__panel {
transform: translateY(100%);
}
.wire-drawer[data-open="true"][data-placement="left"] .wire-drawer__panel,
.wire-drawer[data-open="true"][data-placement="top"] .wire-drawer__panel,
.wire-drawer[data-open="true"][data-placement="bottom"] .wire-drawer__panel {
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.wire-drawer__layer,
.wire-drawer__panel {
transition: none;
}
}
.wire-drawer[data-size="sm"] .wire-drawer__panel {
@@ -395,12 +450,19 @@ open: boolean = false
color: color-mix(in srgb, currentColor 76%, transparent);
}
/*
* padding is reset explicitly: an app-level `button { padding: ... }` rule
* outranks the browser default and leaves this fixed-size button with a
* content box of a couple of pixels, which squeezes the icon to a sliver
* and reads as "the close button has no icon". Same trap as Modal.
*/
.wire-drawer__close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0;
width: 2.35rem;
height: 2.35rem;
color: var(--wire-color-text-muted);
@@ -417,10 +479,30 @@ open: boolean = false
outline: none;
}
/* Never let the glyph be shrunk by the flex container. */
.wire-drawer__close svg {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
}
/*
* Slot content is authored by the host app, so the app global stylesheet
* styles it too. A bare element selector there (p { color: ... }) beats
* anything the panel merely *inherits*, which is how modal body copy ended
* up muted grey on a saturated background. State the colour explicitly;
* :where() keeps the specificity low enough that any class the app puts on
* its own slot content still wins.
*/
.wire-drawer__body {
flex: 1 1 auto;
min-height: 0;
padding: 1.35rem;
color: var(--wire-color-text);
}
.wire-drawer__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
color: inherit;
}
.wire-drawer[data-scrollable="true"] .wire-drawer__body {
+1
View File
@@ -171,6 +171,7 @@ items: unknown[] = []
<div
class="wire-dropdown__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
role="menu"
aria-label='{menuLabel}'
+6
View File
@@ -454,7 +454,13 @@ component FeatureCard {
font-size: 0.98rem;
}
/*
* Slot content is app-authored, so a bare `p { color: ... }` in the app
* stylesheet beats anything this container merely passes down by
* inheritance. State it, at a specificity the app can still override.
*/
.wire-feature-card__content {
color: var(--wire-color-text);
min-width: 0;
margin-top: 1rem;
}
+197 -18
View File
@@ -35,6 +35,7 @@ component Modal {
triggerLabel: string = ""
triggerIcon: string = ""
scrollable: boolean = true
scrollBehavior: string = "inside"
class: string = ""
}
@@ -50,6 +51,16 @@ component Modal {
output.open({ sourceEvent: sourceEvent })
}
// Slot content can close the modal it sits in by dispatching a bubbling
// wrnexus:modal:close event, e.g. from a form success handler:
//
// event.target.dispatchEvent(
// new CustomEvent("wrnexus:modal:close", { bubbles: true })
// )
//
// The listener is on the modal root, so the event only ever closes the
// modal the dispatching element is actually inside -- no ids to wire up
// and no way to close somebody else's modal by accident.
client function hideModal(reason, sourceEvent) {
visible = false
output.close({
@@ -87,21 +98,23 @@ component Modal {
<div
{...attrs}
data-ui-component="Modal"
data-open='{open || visible ? "true" : "false"}'
data-open='{isOpen() ? "true" : "false"}'
data-size='{size}'
data-placement='{placement}'
data-color='{color}'
data-variant='{variant}'
data-scrollable='{scrollable ? "true" : "false"}'
data-scroll='{scrollBehavior}'
data-destructive='{destructive ? "true" : "false"}'
class='wire-modal {class}'
@wrnexus:modal:close='hideModal("api", event)'
>
{#if triggerLabel}
<button
type="button"
class="wire-modal__trigger"
aria-haspopup="dialog"
aria-expanded='{open || visible ? "true" : "false"}'
aria-expanded='{isOpen() ? "true" : "false"}'
@click='showModal(event)'
>
{#if triggerIcon}
@@ -127,7 +140,7 @@ component Modal {
<div
class="wire-modal__layer"
data-show='{open || visible}'
data-show="isOpen()"
role="presentation"
@keydown='handleKeydown(event)'
>
@@ -183,11 +196,20 @@ component Modal {
aria-label='{closeLabel}'
@click='hideModal("close-button", event)'
>
<span
class="icon-[lucide--x]"
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
</span>
<path d="M18 6 6 18" />
<path d="M6 6 18 18" />
</svg>
</button>
{/if}
</header>
@@ -277,16 +299,26 @@ component Modal {
--modal-contrast: var(--wire-color-secondary-contrast);
}
/*
* Fallbacks below (the second var() argument): the theme token generator
* (packages/styles/src/theme.ts) only emits a real -contrast token for
* primary and secondary. info, success, and danger have no contrast
* token defined at all, so var(--wire-color-info-contrast) with no
* fallback resolves to nothing, and --modal-contrast becomes invalid --
* which made confirm-button and solid-panel text unreadable. White is a
* safe default against these saturated colors until the theme package
* defines real tokens for them.
*/
.wire-modal[data-color="info"] {
--modal-accent: var(--wire-color-info);
--modal-soft: var(--wire-color-info-soft);
--modal-contrast: var(--wire-color-info-contrast);
--modal-contrast: var(--wire-color-info-contrast, white);
}
.wire-modal[data-color="success"] {
--modal-accent: var(--wire-color-success);
--modal-soft: var(--wire-color-success-soft);
--modal-contrast: var(--wire-color-success-contrast);
--modal-contrast: var(--wire-color-success-contrast, white);
}
.wire-modal[data-color="warning"] {
@@ -299,7 +331,7 @@ component Modal {
.wire-modal[data-destructive="true"] {
--modal-accent: var(--wire-color-danger);
--modal-soft: var(--wire-color-danger-soft);
--modal-contrast: var(--wire-color-on-danger);
--modal-contrast: var(--wire-color-on-danger, white);
}
.wire-modal__trigger,
@@ -321,8 +353,35 @@ component Modal {
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
.wire-modal__trigger:hover {
opacity: 0.92;
}
.wire-modal__trigger:active {
transform: scale(0.97);
}
.wire-modal__trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--modal-accent) 40%, transparent);
}
/*
* Hidden by default so the SSR-rendered HTML never paints the layer before
* hydration runs. data-open on the wire-modal root is evaluated and
* serialized to a real true or false string at render time -- a plain
* bind, not the raw-expression data-show directive -- so this selector
* is correct on first paint with zero flash, with no dependency on
* client JS having run yet. The data-show attribute and client directive
* still run after hydration to keep things in sync for state changes,
* but visibility itself is driven by CSS here. visibility (rather than
* display) is used so the open and close transitions below can actually
* animate -- a box that starts at display: none has no prior frame to
* transition from.
*/
.wire-modal__layer {
position: fixed;
inset: 0;
@@ -331,6 +390,15 @@ component Modal {
align-items: center;
justify-content: center;
padding: 1rem;
visibility: hidden;
opacity: 0;
transition: opacity 180ms ease, visibility 0s linear 180ms;
}
.wire-modal[data-open="true"] .wire-modal__layer {
visibility: visible;
opacity: 1;
transition: opacity 180ms ease, visibility 0s linear 0s;
}
.wire-modal[data-placement="top"] .wire-modal__layer {
@@ -338,6 +406,12 @@ component Modal {
padding-top: clamp(1rem, 8vh, 5rem);
}
.wire-modal[data-scroll="page"] .wire-modal__layer {
align-items: flex-start;
overflow-y: auto;
padding: 2.5rem 1rem;
}
.wire-modal__backdrop {
position: absolute;
inset: 0;
@@ -370,6 +444,18 @@ component Modal {
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 40px 110px color-mix(in srgb, black 38%, transparent);
overflow: hidden;
opacity: 0;
transform: scale(0.96) translateY(10px);
transition: opacity 180ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.wire-modal[data-open="true"] .wire-modal__panel {
opacity: 1;
transform: none;
}
.wire-modal[data-size="xs"] .wire-modal__panel {
width: min(19rem, calc(100vw - 2rem));
}
.wire-modal[data-size="sm"] .wire-modal__panel {
@@ -390,6 +476,10 @@ component Modal {
max-height: none;
}
.wire-modal[data-scroll="page"] .wire-modal__panel {
max-height: none;
}
.wire-modal[data-variant="soft"] .wire-modal__panel {
background:
linear-gradient(145deg, var(--modal-soft), transparent 68%),
@@ -447,6 +537,7 @@ component Modal {
}
.wire-modal__heading-copy h2 {
color: inherit;
font-size: 1.08rem;
font-weight: 650;
line-height: 1.3;
@@ -462,32 +553,100 @@ component Modal {
color: color-mix(in srgb, currentColor 76%, transparent);
}
/*
* padding is reset explicitly: an app-level `button { padding: … }` rule
* outranks the browser default, and 1rem of horizontal padding left this
* 2.15rem button with a ~2px content box -- which squeezed the icon to
* 0.4px wide and read as "the close button has no icon".
*/
.wire-modal__close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 2.35rem;
height: 2.35rem;
padding: 0;
width: 2.15rem;
height: 2.15rem;
color: var(--wire-color-text-muted);
background: var(--wire-color-surface-soft);
border: 1px solid var(--wire-color-border);
border-radius: 0.75rem;
background: transparent;
border: 1px solid transparent;
border-radius: 9999px;
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 150ms ease;
}
/*
* On a solid panel the background is the accent color, so the muted-grey
* default is close to invisible -- the dismiss affordance reads as
* missing rather than subtle. Derive it from the panel contrast color
* instead, and give it a faint ring so it is unmistakably a control.
*/
.wire-modal[data-variant="solid"] .wire-modal__close {
color: color-mix(in srgb, currentColor 82%, transparent);
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.wire-modal[data-variant="solid"] .wire-modal__close:hover {
color: currentColor;
background: color-mix(in srgb, black 18%, transparent);
border-color: color-mix(in srgb, currentColor 55%, transparent);
}
.wire-modal__close:hover {
color: var(--wire-color-text);
background: var(--wire-color-surface-soft);
}
.wire-modal__close:hover,
.wire-modal__close:focus-visible {
color: var(--modal-accent);
border-color: color-mix(in srgb, var(--modal-accent) 34%, var(--wire-color-border));
border-color: color-mix(in srgb, var(--modal-accent) 45%, transparent);
outline: none;
}
.wire-modal__close:active {
transform: scale(0.92);
}
/* Never let the glyph be shrunk by the flex container. */
.wire-modal__close svg {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
}
/*
* Slot content is authored by the host app, so the app global stylesheet
* styles it too. A bare element selector there (p { color: ... }) beats
* anything the panel merely *inherits*, which is how solid-variant modals
* ended up with muted grey body copy on a saturated accent background --
* unreadable, and worst exactly where contrast matters most (the
* destructive confirm). Setting the color on the body makes the panel
* choice explicit instead of leaving it to inheritance.
*
* NOTE: apostrophes are avoided in .wrn style comments on purpose -- the
* block scanner treats a quote as a string delimiter while it counts
* braces, so a stray one breaks parsing of the whole component.
*/
.wire-modal__body {
flex: 1 1 auto;
min-height: 0;
padding: 1.4rem;
color: var(--wire-color-text);
}
/*
* :where() keeps this at the specificity of .wire-modal__body alone, so it
* outranks a global element selector but still yields to any class the app
* puts on its own slot content (an error message, a muted caption). A
* plain .wire-modal__body p list would have quietly overridden those.
*/
.wire-modal__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
color: inherit;
}
.wire-modal[data-variant="solid"] .wire-modal__body {
color: var(--modal-contrast);
}
.wire-modal[data-scrollable="true"] .wire-modal__body {
@@ -548,6 +707,18 @@ component Modal {
border: 1px solid transparent;
}
/*
* On a solid-variant panel the panel background is also --modal-accent,
* so a plain primary button (same color) has no visible edge against it.
* Darken the fill slightly and add a light border so the button still
* reads as a distinct, clickable pill instead of blending into the panel.
*/
.wire-modal[data-variant="solid"] .wire-modal__button--primary {
background: color-mix(in srgb, black 18%, var(--modal-accent));
border-color: color-mix(in srgb, white 32%, transparent);
box-shadow: 0 1px 0 color-mix(in srgb, white 12%, transparent) inset;
}
.wire-modal__button:disabled {
opacity: 0.55;
cursor: not-allowed;
@@ -575,6 +746,7 @@ component Modal {
}
.wire-modal__panel,
.wire-modal[data-size="xs"] .wire-modal__panel,
.wire-modal[data-size="sm"] .wire-modal__panel,
.wire-modal[data-size="lg"] .wire-modal__panel,
.wire-modal[data-size="xl"] .wire-modal__panel {
@@ -599,10 +771,17 @@ component Modal {
@media (prefers-reduced-motion: reduce) {
.wire-modal__button,
.wire-modal__spinner {
.wire-modal__spinner,
.wire-modal__layer,
.wire-modal__panel,
.wire-modal__close {
animation: none;
transition: none;
}
.wire-modal__panel {
transform: none;
}
}
}
}
}
+6
View File
@@ -588,7 +588,13 @@ current: true
min-width: 0;
}
/*
* Slot content is app-authored, so a bare `p { color: ... }` in the app
* stylesheet beats anything this container merely passes down by
* inheritance. State it, at a specificity the app can still override.
*/
.wire-page-header__body {
color: var(--wire-color-text);
margin-top: 1.75rem;
}
+8 -1
View File
@@ -123,12 +123,13 @@ component Popover {
<section
class="wire-popover__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
role="dialog"
aria-label='{title || triggerLabel}'
>
{#if showArrow}
<span class="wire-popover__arrow" aria-hidden="true"></span>
<span class="wire-popover__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
{/if}
{#if title || description || icon || showClose}
@@ -471,7 +472,13 @@ component Popover {
color: color-mix(in srgb, currentColor 76%, transparent);
}
/*
* padding is reset explicitly: an app-level `button { padding: ... }` rule
* outranks the browser default and crushes the icon inside this
* fixed-size button. Same trap as Modal and Drawer.
*/
.wire-popover__close {
padding: 0;
appearance: none;
display: inline-flex;
align-items: center;
+962
View File
@@ -0,0 +1,962 @@
//
// Toaster -- the notification host. Mount it once (usually in a layout) and
// raise notifications from anywhere with the runtime global:
//
// toast("Saved")
// toast.success("Invite sent to " + email)
// toast.error("Could not save", { title: "Network error", duration: 8000 })
// toast({ message: "Uploading", tone: "info", duration: 0 }) // 0 = sticky
//
// Each tone ships a built-in icon tinted with that tone colour -- green for
// success, red for danger, amber for warning, blue for info. Swap in your own
// per toast, or per tone on the host:
//
// toast.success("Shipped", { icon: "icon-[lucide--rocket]" })
// toast("Quiet one", { icon: false }) // suppress the icon
// <Toaster successIcon="icon-[lucide--party-popper]" />
// <Toaster showIcon={false} /> // never show icons
//
// Actions run your own code. The handler is passed straight through, so it
// closes over whatever the calling function can see:
//
// toast("Note deleted", {
// actionLabel: "Undo",
// onAction: function () { restoreNote(id) }
// })
//
// toast.warning("Two versions of this file", {
// actions: [
// { label: "Keep mine", onClick: keepMine },
// { label: "Use theirs", onClick: useTheirs, tone: "danger", dismiss: false }
// ]
// })
//
// The toast dismisses itself after an action; pass dismiss: false on the
// action to keep it open (for a step that has to report back). At most two
// actions render -- see normalizeActions. A declarative @action on the tag
// still fires for every click, so a page can log or route centrally.
//
// IMPORTANT -- what a callback may do. It runs long after the function that
// created it returned, and a client function only flushes its state when its
// body ends, so assigning your own component state from inside a callback
// writes to a dead local and is lost. Calling toast(), fetch, navigation and
// anything else that is not a state assignment works normally. To change
// state, dispatch an event and handle it declaratively, which re-enters with
// live state:
//
// onAction: function () {
// window.dispatchEvent(new CustomEvent("app:undo-delete"))
// }
// ...
// <div @window:app:undo-delete="restoreItem()">
//
// An icon prop is a CSS class, never markup, so any icon system works
// (iconify, an icon font, your own sprite). NOTE the class has to appear in
// YOUR source for a scanner like Tailwind to emit it -- that is exactly why
// the defaults are inline SVG rather than classes from this package.
//
// The runtime never imports this component: toast() dispatches a
// `wrnexus:toast` window event and this listens for it, so an app can supply
// its own host by listening for the same event.
//
// NOTE: apostrophes are avoided in the style block on purpose -- the .wrn
// block scanner treats a quote as a string delimiter while counting braces,
// so a stray one breaks parsing of the whole component.
component Toaster {
outputs {
show(payload: { id: number; message: string; tone: string })
dismiss(payload: { id: number; reason: string })
action(payload: { id: number; sourceEvent: Event })
}
props {
// Default tone for toasts raised without one of their own. Every Wire UI
// component takes color and size; here they set the stack defaults.
color: string = "info"
// default | sm | lg -- controls toast width and density.
size: string = "default"
// top-left | top-center | top-right | bottom-left | bottom-center | bottom-right
position: string = "bottom-right"
// Auto-dismiss delay in ms. 0 keeps a toast until it is dismissed.
duration: number = 4500
// Oldest toasts beyond this are retired as new ones arrive.
max: number = 4
pauseOnHover: boolean = true
// Icons: a built-in glyph per tone, tinted with that tone colour.
// Override any of them with an icon class of your own (iconify, an icon
// font, whatever your app already uses) -- the built-in SVG is only a
// fallback so the component needs no icon dependency. Per toast:
// toast.success("Saved", { icon: "icon-[lucide--party-popper]" })
// toast("Quiet", { icon: false }) // no icon on this one
showIcon: boolean = true
successIcon: string = ""
dangerIcon: string = ""
warningIcon: string = ""
infoIcon: string = ""
closable: boolean = true
showProgress: boolean = true
closeLabel: string = "Dismiss notification"
class: string = ""
}
state toasts = []
state sequence = 0
// Timer bookkeeping, deliberately outside `toasts`: the view never reads
// this, so pausing and resuming rebuilds no DOM. See the note in functions.
state timers = {}
functions {
// TIMER DESIGN -- read before changing.
//
// 1. No callback here touches state. A client function gets state as a
// local snapshot and flushes it back when the body returns, so a write
// from a setTimeout callback lands in a dead local and is lost; calling
// a peer function from one re-flushes the stale snapshot over live
// state. Timers therefore only dispatch a window event, and the
// declarative @window handlers re-enter with live state.
//
// 2. Timer bookkeeping lives in `timers`, NOT on the toast entries.
// The view renders `toasts` through data-for, so touching a toast
// object rebuilds every row -- which restarted each progress bar from
// zero and made the bar look permanently full. Hover has to be free of
// that: pausing must leave the DOM completely alone. `timers` is never
// read by the view, so writing it re-renders nothing.
//
// 3. Everything that DOES change `toasts` (add, dismiss, remove) returns
// untouched items by reference for the rows it is not changing, so the
// keyed loop reuses those nodes and their bars keep running.
client function scheduleEvent(name, id, delay) {
return setTimeout(function () {
window.dispatchEvent(new CustomEvent(name, { detail: { id: id } }))
}, delay)
}
// kind is either "dismiss" (the toast lifetime, which hover pauses) or
// "remove" (the exit-animation cleanup, which hover must NOT touch --
// see pauseAll).
client function trackTimer(id, handle, life, kind) {
var next = {}
Object.keys(timers).forEach(function (key) { next[key] = timers[key] })
next[id] = {
handle: handle,
expiresAt: Date.now() + life,
remaining: life,
kind: kind || "dismiss"
}
timers = next
}
client function forgetTimer(id) {
var next = {}
Object.keys(timers).forEach(function (key) {
if (String(key) !== String(id)) {
next[key] = timers[key]
}
})
timers = next
}
// An explicit per-toast icon wins; otherwise the tone default prop; and if
// that is empty the built-in SVG for the tone renders instead. Returns a
// CSS class name, never markup, so an app can hand us any icon system.
client function resolveIcon(tone, requested) {
if (requested) {
return String(requested)
}
if (tone === "success") {
return successIcon
}
if (tone === "danger") {
return dangerIcon
}
if (tone === "warning") {
return warningIcon
}
if (tone === "info") {
return infoIcon
}
return ""
}
// Actions arrive either as a single actionLabel/onAction pair or as an
// actions array. At most two are rendered: the view has two fixed slots
// because a data-for inside a data-for is not expanded by the runtime, so
// an arbitrary list cannot be rendered per row. Two covers the real cases
// (Undo, Retry, View / Dismiss); anything beyond that is dropped loudly
// rather than silently.
client function normalizeActions(detail) {
var list = []
if (Array.isArray(detail.actions)) {
list = detail.actions.filter(function (action) { return action && action.label })
} else if (detail.actionLabel) {
list = [{ label: detail.actionLabel, onClick: detail.onAction, tone: detail.actionTone }]
}
if (list.length > 2) {
console.warn(
"[wrnexus] Toaster renders at most 2 actions per toast; ignoring " +
(list.length - 2) + " extra."
)
}
return list.slice(0, 2)
}
// Is the pointer resting on the stack right now?
//
// mouseenter only fires when the pointer MOVES. A toast raised while the
// cursor is already parked over the stack -- which is exactly what an
// action handler does -- therefore gets no enter event, is never paused,
// and counts down and disappears while the user is still reaching for its
// button. Its progress bar meanwhile IS paused, because CSS :hover does
// apply, so the bar sat still while the toast quietly expired. Asking the
// DOM for the live :hover state closes that gap.
client function stackHovered() {
var list = refs.list
return !!(pauseOnHover && list && list.matches(":hover"))
}
client function receiveToast(sourceEvent) {
var detail = sourceEvent.detail || {}
// One toast per raise, however many hosts are mounted.
//
// toast() dispatches a window event, so EVERY mounted Toaster hears it
// and a page with two hosts showed the message twice (the component
// showcase mounts five and showed five). Claiming the event on the
// detail object lets the first host win and the rest stand down, so an
// accidental second host is harmless instead of multiplying every
// notification.
if (detail.__wrnClaimed) {
return
}
detail.__wrnClaimed = true
sequence = sequence + 1
var id = sequence
var life = detail.duration === 0 ? 0 : (detail.duration || duration)
var tone = detail.tone || color
var entry = {
id: id,
title: detail.title || "",
message: detail.message === undefined ? "" : String(detail.message),
tone: tone,
icon: resolveIcon(tone, detail.icon),
showIcon: showIcon && detail.icon !== false,
actions: normalizeActions(detail),
duration: life,
leaving: false
}
// Retire the oldest live toasts in the same pass that appends the new
// one, so a burst can never leave the stack over max. Untouched rows are
// returned by reference so their nodes (and bars) survive.
var live = toasts.filter(function (item) { return !item.leaving })
var retire = live.length + 1 > max ? live.slice(0, live.length + 1 - max) : []
toasts = toasts
.map(function (item) {
var doomed = retire.some(function (old) { return old.id === item.id })
return doomed ? Object.assign({}, item, { leaving: true }) : item
})
.concat([entry])
retire.forEach(function (item) {
clearTimeout((timers[item.id] || {}).handle)
trackTimer(item.id, scheduleEvent("wrnexus:toast:remove", item.id, 240), 240, "remove")
})
if (life) {
// Born paused when the stack is already hovered: handle 0 parks it,
// and resumeAll starts the clock when the pointer finally leaves.
if (stackHovered()) {
trackTimer(id, 0, life, "dismiss")
} else {
trackTimer(id, scheduleEvent("wrnexus:toast:dismiss", id, life), life, "dismiss")
}
}
output.show({ id: id, message: entry.message, tone: entry.tone })
}
client function dismissToast(id, reason) {
var found = false
toasts = toasts.map(function (item) {
if (item.id !== id || item.leaving) {
return item
}
found = true
return Object.assign({}, item, { leaving: true })
})
if (!found) {
return
}
clearTimeout((timers[id] || {}).handle)
// Give the exit animation time to play, then drop the entry.
trackTimer(id, scheduleEvent("wrnexus:toast:remove", id, 240), 240, "remove")
output.dismiss({ id: id, reason: reason || "auto" })
}
client function removeToast(id) {
clearTimeout((timers[id] || {}).handle)
forgetTimer(id)
toasts = toasts.filter(function (item) { return item.id !== id })
}
client function dismissById(sourceEvent) {
var detail = sourceEvent.detail || {}
dismissToast(detail.id, detail.reason || "timeout")
}
client function removeById(sourceEvent) {
var detail = sourceEvent.detail || {}
removeToast(detail.id)
}
client function clearAll() {
toasts.slice().forEach(function (item) {
if (!item.leaving) {
dismissToast(item.id, "clear")
}
})
}
// Hovering must not run the clock down while a toast is being read, so
// the remaining time is banked and the timers restart on the way out.
// Only `timers` is written, so not a single DOM node is rebuilt -- the
// progress bars simply stop where they are (CSS pauses them on :hover)
// and carry on from there.
client function pauseAll() {
if (!pauseOnHover) {
return
}
var now = Date.now()
var next = {}
Object.keys(timers).forEach(function (key) {
var entry = timers[key]
// A "remove" timer finishes an exit animation -- pausing it strands
// the toast: invisible, still taking up space in the stack, and still
// able to swallow clicks, forever. Only lifetimes pause.
if (!entry || !entry.handle || entry.kind !== "dismiss") {
next[key] = entry
return
}
clearTimeout(entry.handle)
var left = entry.expiresAt - now
next[key] = {
handle: 0,
expiresAt: entry.expiresAt,
remaining: left > 0 ? left : 1,
kind: "dismiss"
}
})
timers = next
}
client function resumeAll() {
if (!pauseOnHover) {
return
}
var now = Date.now()
var next = {}
Object.keys(timers).forEach(function (key) {
var entry = timers[key]
if (!entry || entry.handle || entry.kind !== "dismiss") {
next[key] = entry
return
}
var left = entry.remaining > 0 ? entry.remaining : 1
// Number(key): object keys come back as strings, and the dismiss
// handler matches ids with !==, so a string id silently matches no
// toast and the resumed timer would fire into the void.
next[key] = {
handle: scheduleEvent("wrnexus:toast:dismiss", Number(key), left),
expiresAt: now + left,
remaining: left,
kind: "dismiss"
}
})
timers = next
}
// The click handler for an action. Runs as a fresh invocation from the
// DOM, so state here is live and calling peer functions is safe.
client function runAction(id, index, sourceEvent) {
var toast = null
toasts.forEach(function (item) {
if (item.id === id) {
toast = item
}
})
if (!toast) {
return
}
var action = toast.actions[index]
if (!action) {
return
}
// Declarative listeners on the <Toaster> tag see every action too.
output.action({
id: id,
index: index,
label: action.label,
sourceEvent: sourceEvent
})
// The callback is application code: a throw here must not take the
// toaster down with it, or the toast would be stuck on screen forever.
if (typeof action.onClick === "function") {
try {
action.onClick(sourceEvent)
} catch (error) {
console.error("[wrnexus] toast action handler failed", error)
}
}
// NOTHING may call a peer function past this point.
//
// The callback is application code and is re-entrant: a handler that
// raises its own toast runs receiveToast in a fresh invocation, which
// appends to live state. Calling a peer from here would first flush the
// snapshot this function captured on entry -- taken BEFORE the callback
// ran -- straight over that live state, silently erasing the toast the
// handler just raised. So the dismissal is dispatched inline instead,
// and handled on the next tick with state that is actually current.
// This function never assigns to state itself, so it flushes nothing.
if (action.dismiss !== false) {
setTimeout(function () {
window.dispatchEvent(
new CustomEvent("wrnexus:toast:dismiss", {
detail: { id: id, reason: "action" }
})
)
}, 0)
}
}
}
view {
<div
{...attrs}
data-ui-component="Toaster"
data-toaster="true"
data-position='{position}'
data-size='{size}'
class='wire-toaster {class}'
role="region"
aria-label="Notifications"
@window:wrnexus:toast='receiveToast(event)'
@window:wrnexus:toast:dismiss='dismissById(event)'
@window:wrnexus:toast:clear='clearAll()'
@window:wrnexus:toast:remove='removeById(event)'
>
<!-- Hover is bound to the list, not to each toast, and pauses the whole
stack. A row is a data-for clone that is recreated whenever state
changes, so a per-row mouseleave would be bound to a node the
browser has already discarded: the pointer would never "leave", the
toast would stay paused, and it would hang on screen forever.
The list element is stable for the lifetime of the page. Pausing
the whole stack is also what people expect -- reading one toast
should not let its neighbours expire underneath it. -->
<ol
class="wire-toaster__list"
data-ref="list"
aria-live="polite"
aria-relevant="additions text"
@mouseenter='pauseAll()'
@mouseleave='resumeAll()'
@focusin='pauseAll()'
@focusout='resumeAll()'
>
<li
class="wire-toast"
data-for="item in toasts"
data-key="item.id"
data-tone='{item.tone}'
data-leaving='{item.leaving}'
>
<span
class="wire-toast__indicator"
aria-hidden="true"
>
</span>
<!-- An app-supplied icon class (iconify or otherwise). -->
<span
class='wire-toast__icon {item.icon}'
data-show="item.showIcon && item.icon"
aria-hidden="true"
>
</span>
<!-- Built-in fallback. Inline SVG on purpose: the package cannot
assume the host app has an icon set installed, and a class from
here would not be in the app Tailwind content globs anyway, so
it would generate no CSS and render nothing. Each tone shows its
own glyph via data-show; all of them inherit --toast-accent, so
the icon is green / red / amber / blue with the tone. -->
<svg
class="wire-toast__icon wire-toast__icon--default"
data-show="item.showIcon && !item.icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<g data-show="item.tone === 'success'">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</g>
<g data-show="item.tone === 'danger'">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</g>
<g data-show="item.tone === 'warning'">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</g>
<g data-show="item.tone !== 'success' && item.tone !== 'danger' && item.tone !== 'warning'">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</g>
</svg>
<div
class="wire-toast__body"
>
<p
class="wire-toast__title"
data-show="item.title"
>
{item.title}
</p>
<p
class="wire-toast__message"
>
{item.message}
</p>
</div>
<!-- Two fixed action slots. A data-for inside a data-for is not
expanded by the runtime, so an arbitrary list cannot be looped
per row; normalizeActions caps the array at two and warns. -->
<div
class="wire-toast__actions"
data-show="item.actions.length"
>
<button
type="button"
class="wire-toast__action"
data-show="item.actions.length > 0"
data-tone='{item.actions[0].tone}'
@click='runAction(item.id, 0, event)'
>
{item.actions[0].label}
</button>
<button
type="button"
class="wire-toast__action"
data-show="item.actions.length > 1"
data-tone='{item.actions[1].tone}'
@click='runAction(item.id, 1, event)'
>
{item.actions[1].label}
</button>
</div>
<button
type="button"
class="wire-toast__close"
data-show="closable"
aria-label='{closeLabel}'
@click='dismissToast(item.id, "close-button")'
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="M6 6 18 18" />
</svg>
</button>
<!-- Decoration only: the timer above owns dismissal. Shown when the
toast has a lifetime, and paused in step with it on hover. -->
<span
class="wire-toast__progress"
data-show="showProgress && item.duration"
style="--toast-duration: {item.duration}ms"
aria-hidden="true"
>
</span>
</li>
</ol>
</div>
}
style {
.wire-toaster {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
padding: clamp(0.75rem, 2vw, 1.25rem);
/* The host covers the viewport so it can align the stack in any corner;
it must never swallow clicks meant for the page underneath. */
pointer-events: none;
}
/*
* pointer-events MUST be re-enabled here, not only on .wire-toast.
*
* The host is pointer-events: none so the page underneath stays clickable
* through the empty overlay, and the list inherits that. An element with
* pointer-events: none is never a hit-test target, so it never matches
* :hover AND never receives mouseenter/mouseleave -- and those two do not
* bubble up from the rows either. The result was that hover-to-pause did
* nothing at all for a real user: toasts kept counting down and vanished
* from under the cursor as they reached for the action button. (Synthetic
* dispatchEvent bypasses hit-testing, so it hid this in testing.)
*
* The list box wraps the stack exactly -- its height is the toasts plus
* their gaps -- so making it interactive costs the page nothing.
*/
.wire-toaster__list {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(23rem, 100%);
margin: 0;
padding: 0;
list-style: none;
pointer-events: auto;
}
/* Nothing to hover when the stack is empty. */
.wire-toaster__list:empty {
pointer-events: none;
}
.wire-toaster[data-size="sm"] .wire-toaster__list {
width: min(18rem, 100%);
}
.wire-toaster[data-size="lg"] .wire-toaster__list {
width: min(28rem, 100%);
}
.wire-toaster[data-size="sm"] .wire-toast {
padding: 0.6rem 0.7rem;
font-size: 0.78rem;
}
.wire-toaster[data-size="lg"] .wire-toast {
padding: 1.05rem 1.1rem;
}
.wire-toaster[data-position^="top"] {
align-items: flex-start;
}
.wire-toaster[data-position^="bottom"] {
align-items: flex-end;
}
/* Newest nearest the screen edge: at the bottom that means visually last,
so the column is reversed rather than the state array. */
.wire-toaster[data-position^="bottom"] .wire-toaster__list {
flex-direction: column-reverse;
}
.wire-toaster[data-position$="left"] {
justify-content: flex-start;
}
.wire-toaster[data-position$="center"] {
justify-content: center;
}
.wire-toaster[data-position$="right"] {
justify-content: flex-end;
}
.wire-toast {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.7rem;
overflow: hidden;
padding: 0.85rem 0.9rem;
color: var(--wire-color-text);
background: var(--wire-color-surface-raised);
border: 1px solid var(--wire-color-border);
border-radius: 0.9rem;
box-shadow: 0 18px 40px color-mix(in srgb, black 28%, transparent);
pointer-events: auto;
animation: wire-toast-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.wire-toaster[data-position$="left"] .wire-toast {
animation-name: wire-toast-in-left;
}
/*
* Belt and braces: a toast on its way out is faded to nothing but still
* occupies its box until it is dropped from the list, so without this it
* can swallow clicks aimed at whatever is under it.
*/
.wire-toast[data-leaving="true"] {
animation: wire-toast-out 200ms ease forwards;
pointer-events: none;
}
.wire-toast__indicator {
flex: 0 0 auto;
width: 0.4rem;
align-self: stretch;
border-radius: 999px;
background: var(--toast-accent, var(--wire-color-primary));
}
.wire-toast[data-tone="success"] {
--toast-accent: var(--wire-color-success);
}
.wire-toast[data-tone="danger"] {
--toast-accent: var(--wire-color-danger);
}
.wire-toast[data-tone="warning"] {
--toast-accent: var(--wire-color-warning);
}
.wire-toast[data-tone="info"] {
--toast-accent: var(--wire-color-info);
}
/*
* Centred like the trailing controls. The row is top-aligned so long text
* starts at the top, but on a one-line toast the 1.75rem close button is
* taller than the text, so a top-aligned body left the text sitting a few
* pixels above the button it is supposed to line up with.
*/
/*
* Tinted with the tone accent, so the glyph reads as the status at a
* glance -- green for success, red for danger, amber for warning, and the
* info colour otherwise. The built-in SVG strokes with currentColor, and
* an app icon class that uses currentColor (iconify does) picks up the
* same value for free.
*/
.wire-toast__icon {
flex: 0 0 auto;
align-self: center;
width: 1.15rem;
height: 1.15rem;
color: var(--toast-accent, var(--wire-color-primary));
}
.wire-toast__body {
flex: 1 1 auto;
min-width: 0;
align-self: center;
display: grid;
gap: 0.15rem;
}
.wire-toast__title,
.wire-toast__message {
margin: 0;
overflow-wrap: anywhere;
}
.wire-toast__title {
color: var(--wire-color-text);
font-size: 0.85rem;
font-weight: 650;
line-height: 1.35;
}
.wire-toast__message {
color: var(--wire-color-text-muted);
font-size: 0.82rem;
line-height: 1.5;
}
/*
* The trailing controls share one alignment. The close button used to be
* pinned to the top with a negative margin while the action sat centred,
* so the two sat on different lines and read as misaligned. Both are
* centred against the toast body now, and both are the same height, so
* their centres line up whether the toast is one line or three.
*/
.wire-toast__actions {
display: flex;
align-items: center;
flex: 0 0 auto;
align-self: center;
gap: 0.35rem;
}
/* A destructive action reads in the danger colour regardless of tone. */
.wire-toast__action[data-tone="danger"] {
color: var(--wire-color-danger);
border-color: color-mix(in srgb, var(--wire-color-danger) 45%, transparent);
}
.wire-toast__action {
appearance: none;
flex: 0 0 auto;
min-height: 1.75rem;
display: inline-flex;
align-items: center;
padding: 0 0.6rem;
color: var(--toast-accent, var(--wire-color-primary));
background: transparent;
border: 1px solid color-mix(in srgb, var(--toast-accent, var(--wire-color-primary)) 40%, transparent);
border-radius: 0.55rem;
font: inherit;
font-size: 0.78rem;
font-weight: 650;
cursor: pointer;
}
/*
* padding is reset explicitly: an app-level button padding rule beats the
* browser default and collapses the icon to a sliver inside this
* fixed-size button.
*/
.wire-toast__close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
align-self: center;
padding: 0;
width: 1.75rem;
height: 1.75rem;
margin: 0;
color: var(--wire-color-text-muted);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
transition: color 150ms ease, background 150ms ease;
}
.wire-toast__close:hover {
color: var(--wire-color-text);
background: var(--wire-color-surface-soft);
}
.wire-toast__close svg {
flex: 0 0 auto;
width: 0.875rem;
height: 0.875rem;
}
/*
* Sweeps left to right as the toast lives out its delay, reaching full
* width as it is dismissed, so the remaining time is readable at a glance.
* (Flip the keyframes below to run 1 -> 0 if you would rather it drain.)
*/
.wire-toast__progress {
position: absolute;
left: 0;
bottom: 0;
height: 2px;
width: 100%;
transform-origin: left center;
background: var(--toast-accent, var(--wire-color-primary));
animation: wire-toast-progress var(--toast-duration, 4500ms) linear forwards;
}
/* Hover pauses the clock, so the bar must pause with it or it would lie
about how much time is left. */
/*
* Paused straight from :hover rather than from a state flag. A flag would
* mean writing state on every hover, and that rebuilds the rows -- which
* restarts the bars from zero and is exactly what made them look stuck.
* CSS pauses the animation in place and touches no DOM, and the JS timer
* is banked on the same mouseenter, so the two stay in step.
*/
.wire-toaster__list:hover .wire-toast__progress,
.wire-toaster__list:focus-within .wire-toast__progress {
animation-play-state: paused;
}
@keyframes wire-toast-in {
from {
opacity: 0;
transform: translateX(18px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes wire-toast-in-left {
from {
opacity: 0;
transform: translateX(-18px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes wire-toast-out {
to {
opacity: 0;
transform: translateX(12px) scale(0.97);
}
}
@keyframes wire-toast-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@media (max-width: 639px) {
.wire-toaster {
justify-content: stretch;
}
.wire-toaster__list {
width: 100%;
}
}
/*
* Reduced motion drops the entry/exit movement but KEEPS the progress
* sweep: it is a clock, not decoration, and a frozen bar would both
* misreport the time left and look like the bug it used to be. A linear
* 2px bar carries no vestibular risk.
*/
@media (prefers-reduced-motion: reduce) {
.wire-toast,
.wire-toast[data-leaving="true"] {
animation: none;
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
component Table {
outputs {
sort(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
rowClick(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props {
size: string = "default"
color: string = "primary"
caption: string = "Table"
columns: unknown[] = []
rows: unknown[] = []
striped: boolean = true
class: string = ""
}
view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--table {class}"><table><caption>{caption}</caption><thead><tr>{#each columns as column}<th>{column.label}</th>{/each}</tr></thead><tbody>{#each rows as row}<tr>{#each columns as column}<td>{row[column.key]}</td>{/each}</tr>{/each}</tbody></table><slot /></div>
}
}
+2 -1
View File
@@ -97,11 +97,12 @@ id: string = ""
<span
id='{id}'
class="wire-tooltip__content"
data-wrn-anchored="true"
data-show='{open || visible}'
role="tooltip"
>
{#if showArrow}
<span class="wire-tooltip__arrow" aria-hidden="true"></span>
<span class="wire-tooltip__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
{/if}
{#if title}
+1
View File
@@ -6,6 +6,7 @@
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./registry": "./src/registry.ts",
"./components/*": "./components/*",
"./component-catalog.json": "./component-catalog.json",
"./component-migrations.json": "./component-migrations.json",
+14 -79
View File
@@ -3,90 +3,25 @@
*
* Components are `.wrn` files under `components/`, auto-discovered by the
* framework (the router scans this directory in addition to the app's own
* `app/components`). Mount them in any page with `data-component="<name>"`.
* Their styles live in a single themeable stylesheet, `ui.css`, served once at
* `/__wrnexus/ui.css` every class uses `var(--wire-*)` theme tokens.
* `app/components`). Mount them in any page with `data-component="<name>"`,
* or reference them by tag name (`<Modal>`, `<Card>`, ) no import needed,
* component resolution happens by directory scan, not by this module's
* exports. Their styles live in a single themeable stylesheet, `ui.css`,
* served once at `/__wrnexus/ui.css` every class uses `var(--wire-*)`
* theme tokens.
*
* Override, in increasing order of power:
* 1. theme tokens (change `--wire-color-primary`, etc.)
* 2. redefine a `.wire-*` class in your own CSS (loaded after ui.css)
* 3. pass a `class` prop (appended to the component root)
* 4. `wrnexus eject <name>` to copy the component into `app/components` and own it
*
* This entry point is intentionally free of `node:*` imports it is the
* package's main entry, so it can end up in a browser bundle for any app
* that composes an interactive `@wrnexus/ui` component. The filesystem
* helpers that used to live here (`uiComponentsDir`, `uiCss`,
* `uiComponentPath`, etc.) now live in `@wrnexus/ui/registry`, a
* server-only subpath. Import from there instead if you need them.
*/
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(here, "..");
let cachedCss: string | undefined;
let cachedComponentFiles: Map<string, string> | undefined;
/** Absolute path to the directory of Wire UI component `.wrn` files. */
export function uiComponentsDir(): string {
return join(packageRoot, "components");
}
/** Absolute path to the Wire UI stylesheet. */
export function uiCssPath(): string {
return join(packageRoot, "ui.css");
}
/** The Wire UI stylesheet contents (all `.wire-*` classes, themed via tokens). */
export function uiCss(): string {
return (cachedCss ??= readFileSync(uiCssPath(), "utf8"));
}
function uiComponentFiles(): Map<string, string> {
if (cachedComponentFiles) return cachedComponentFiles;
const files = new Map<string, string>();
const componentFiles = readdirSync(uiComponentsDir()).filter((entry) => entry.endsWith(".wrn"));
for (const file of componentFiles) {
const path = join(uiComponentsDir(), file);
const source = readFileSync(path, "utf8");
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(source);
if (!declaration) {
throw new Error(`UI component source does not declare a component: ${path}`);
}
const name = declaration[1]!;
const previous = files.get(name);
if (previous) {
throw new Error(`Duplicate UI component declaration '${name}' in ${previous} and ${path}`);
}
files.set(name, path);
}
cachedComponentFiles = files;
return files;
}
/** Names declared by the bundled components, independent of filename casing. */
export function uiComponentNames(): string[] {
return [...uiComponentFiles().keys()].sort((left, right) => left.localeCompare(right));
}
/** Absolute path to a bundled component by its declared component name. */
export function uiComponentPath(name: string): string {
const files = uiComponentFiles();
const exact = files.get(name);
if (exact) return exact;
const normalized = name.toLowerCase();
const matches = [...files.entries()].filter(
([componentName]) => componentName.toLowerCase() === normalized,
);
if (matches.length === 1) return matches[0]![1];
throw new Error(`Unknown WRNexus UI component '${name}'.`);
}
export { uiComponentReference, findUiComponent, auditUiComponents } from "./metadata.ts";
export type { UiComponentMetadata, UiComponentReference } from "./metadata.ts";
export {};
+1 -1
View File
@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { uiComponentsDir } from "./index.ts";
import { uiComponentsDir } from "./registry.ts";
export interface UiComponentMetadata {
name: string;
mount: string;
+93
View File
@@ -0,0 +1,93 @@
/**
* @wrnexus/ui registry SERVER-ONLY filesystem helpers for discovering the
* package's `.wrn` component sources and stylesheet on disk.
*
* This is the code that used to live in `index.ts`. It was moved here
* because it depends on `node:fs`/`node:path`/`node:url`, which don't exist
* in a browser build and `index.ts` is the package's main entry, so any
* client-side (browser) bundle that reaches ANY `@wrnexus/ui` component
* transitively tried to bundle this file too, failing with:
* "Browser polyfill for module 'node:url' doesn't have a matching export
* named 'fileURLToPath'"
*
* Import from `@wrnexus/ui/registry` (server-side code only CLI,
* dev-server, build). Never import this from a `.wrn` component or anything
* that can end up in a client runtime bundle.
*/
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(here, "..");
let cachedCss: string | undefined;
let cachedComponentFiles: Map<string, string> | undefined;
/** Absolute path to the directory of Wire UI component `.wrn` files. */
export function uiComponentsDir(): string {
return join(packageRoot, "components");
}
/** Absolute path to the Wire UI stylesheet. */
export function uiCssPath(): string {
return join(packageRoot, "ui.css");
}
/** The Wire UI stylesheet contents (all `.wire-*` classes, themed via tokens). */
export function uiCss(): string {
return (cachedCss ??= readFileSync(uiCssPath(), "utf8"));
}
function uiComponentFiles(): Map<string, string> {
if (cachedComponentFiles) return cachedComponentFiles;
const files = new Map<string, string>();
const componentFiles = readdirSync(uiComponentsDir()).filter((entry) => entry.endsWith(".wrn"));
for (const file of componentFiles) {
const path = join(uiComponentsDir(), file);
const source = readFileSync(path, "utf8");
const declaration = /^\s*component\s+([A-Za-z][A-Za-z0-9_]*)\b/m.exec(source);
if (!declaration) {
throw new Error(`UI component source does not declare a component: ${path}`);
}
const name = declaration[1]!;
const previous = files.get(name);
if (previous) {
throw new Error(`Duplicate UI component declaration '${name}' in ${previous} and ${path}`);
}
files.set(name, path);
}
cachedComponentFiles = files;
return files;
}
/** Names declared by the bundled components, independent of filename casing. */
export function uiComponentNames(): string[] {
return [...uiComponentFiles().keys()].sort((left, right) => left.localeCompare(right));
}
/** Absolute path to a bundled component by its declared component name. */
export function uiComponentPath(name: string): string {
const files = uiComponentFiles();
const exact = files.get(name);
if (exact) return exact;
const normalized = name.toLowerCase();
const matches = [...files.entries()].filter(
([componentName]) => componentName.toLowerCase() === normalized,
);
if (matches.length === 1) return matches[0]![1];
throw new Error(`Unknown WRNexus UI component '${name}'.`);
}
export { uiComponentReference, findUiComponent, auditUiComponents } from "./metadata.ts";
export type { UiComponentMetadata, UiComponentReference } from "./metadata.ts";
+4 -3
View File
@@ -3,7 +3,10 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { compileWireFile, parse } from "../../compiler/src/index.ts";
import { mountHtml, renderComponent } from "../../test/src/index.ts";
import { uiComponentNames, uiComponentsDir, uiComponentPath, uiCss } from "../src/index.ts";
// The filesystem helpers moved to the server-only `registry` subpath; the
// package entry deliberately stays free of node:* imports so it can be
// bundled for the browser.
import { uiComponentNames, uiComponentsDir, uiComponentPath, uiCss } from "../src/registry.ts";
test("bundled UI assets are discoverable and readable", () => {
expect(uiComponentNames()).toContain("Button");
@@ -165,7 +168,6 @@ test("screenshot-directed canonical components are bundled", () => {
"Drawer",
"Popover",
"Tooltip",
"Table",
"DataTable",
"Chart",
"FileUpload",
@@ -429,7 +431,6 @@ test("component-system CSS includes responsive, theme-token, focus, and reduced-
expect(css).toContain(".wire-text-muted");
expect(css).toContain(".wire-visually-hidden");
expect(css).toContain(".wire-next--field");
expect(css).toContain(".wire-next--table");
expect(css).toContain('[data-show="false"]');
expect(css).toContain("display: none !important");
expect(css).toMatch(
-22
View File
@@ -8037,28 +8037,6 @@
cursor: pointer;
}
.wire-next--table {
width: 100%;
overflow: auto;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-md);
}
.wire-next--table table {
width: 100%;
border-collapse: collapse;
}
.wire-next--table caption,
.wire-next--table th,
.wire-next--table td {
padding: 0.75rem;
border-bottom: 1px solid var(--wire-color-border);
text-align: left;
}
.wire-next--table caption,
.wire-next--table th {
font-weight: 800;
}
@keyframes wire-shimmer {
to {
background-position: -200% 0;