@
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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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-");
|
||||
});
|
||||
|
||||
+1226
-267
File diff suppressed because it is too large
Load Diff
@@ -573,3 +573,324 @@ test("cache invalidation refetches matching client Async boundaries", async () =
|
||||
expect(requests).toBe(2);
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
// --- parent-scope ownership across a component boundary --------------------
|
||||
// Slot content and `@output` handlers on a component tag are authored in the
|
||||
// PARENT (page) source, but SSR splices them inside the child component's
|
||||
// [data-scope] root. Without an owner-chain rule the child scope claims them,
|
||||
// so the page's own functions and state are invisible to markup the page
|
||||
// itself wrote.
|
||||
|
||||
test("slot content is hydrated by the parent scope, not the component it lands in", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="pageValue: 'page', pageFlag: true">` +
|
||||
`<div data-scope="pageValue: 'child', pageFlag: false" data-wrn-hydration="Modal:x">` +
|
||||
`<div data-wrn-events="confirm" class="view">` +
|
||||
`<wrn-slot data-wrn-slot=""><b>{pageValue}</b><i data-show="pageFlag">shown</i></wrn-slot>` +
|
||||
`</div></div></div>`,
|
||||
);
|
||||
expect(win.document.querySelector("b")?.textContent).toBe("page");
|
||||
expect(win.document.querySelector("i")?.getAttribute("data-show")).toBe("true");
|
||||
});
|
||||
|
||||
test("slot event handlers write to the parent scope", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="count: 0">` +
|
||||
`<span id="out">{count}</span>` +
|
||||
`<div data-scope="count: 99" data-wrn-hydration="Modal:x">` +
|
||||
`<div data-wrn-events="confirm">` +
|
||||
`<wrn-slot data-wrn-slot=""><button data-on-click="count++">go</button></wrn-slot>` +
|
||||
`</div></div></div>`,
|
||||
);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("1");
|
||||
});
|
||||
|
||||
test("component output handlers run in the parent scope", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="saved: ''">` +
|
||||
`<span id="out">{saved}</span>` +
|
||||
`<div data-scope="saved: 'child'" data-wrn-hydration="Modal:x">` +
|
||||
`<div data-wrn-events="confirm" data-wrn-out-confirm="saved = 'yes'"></div>` +
|
||||
`</div></div>`,
|
||||
);
|
||||
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
|
||||
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
|
||||
};
|
||||
const handlers = target.__wrnexusOutputHandlers?.confirm;
|
||||
expect(handlers && handlers.size).toBe(1);
|
||||
handlers!.forEach((handler) => handler({}));
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
|
||||
});
|
||||
|
||||
// --- browser globals + regex literals in client expressions ----------------
|
||||
// Client functions and inline handlers are interpreted by the runtime's own
|
||||
// eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
|
||||
// the engine cannot resolve silently becomes undefined, so an author calling
|
||||
// an ordinary browser API gets a confusing "not a function" instead of the
|
||||
// behavior they wrote.
|
||||
|
||||
test("client expressions can call browser globals like alert", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="msg: 'hi'"><button data-on-click="alert(msg)">go</button></div>`,
|
||||
) as unknown as Window & {
|
||||
alert?: (message: string) => void;
|
||||
};
|
||||
const seen: string[] = [];
|
||||
win.alert = (message: string) => seen.push(String(message));
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(seen).toEqual(["hi"]);
|
||||
});
|
||||
|
||||
test("client expressions resolve JS builtins", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="raw: ' 42 ', out: 0, keys: ''">` +
|
||||
`<button data-on-click="out = parseInt(raw, 10); keys = Object.keys({ a: 1, b: 2 }).join('-')">go</button>` +
|
||||
`<span id="n">{out}</span><span id="k">{keys}</span>` +
|
||||
`</div>`,
|
||||
);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#n")?.textContent).toBe("42");
|
||||
expect(win.document.querySelector("#k")?.textContent).toBe("a-b");
|
||||
});
|
||||
|
||||
test("client expressions support regex literals", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="email: 'dev@wrnexus.io', ok: ''">` +
|
||||
// String.raw so the backslashes reach the runtime: in a plain template
|
||||
// literal `\s` collapses to `s` and the test would pass against a
|
||||
// pattern the author never wrote.
|
||||
String.raw`<button data-on-click="ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? 'yes' : 'no'">go</button>` +
|
||||
`<span id="out">{ok}</span></div>`,
|
||||
);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
|
||||
});
|
||||
|
||||
test("a regex literal with a division sign nearby still parses as division", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="a: 10, b: 4, out: 0"><button data-on-click="out = a / b">go</button><span id="out">{out}</span></div>`,
|
||||
);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("2.5");
|
||||
});
|
||||
|
||||
test("a string literal that spells an operator stays a value", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="parts: 'a.b.c', joined: '', first: ''">` +
|
||||
`<button data-on-click="joined = ['x','y'].join('-'); first = parts.split('.')[0]">go</button>` +
|
||||
`<span id="j">{joined}</span><span id="f">{first}</span></div>`,
|
||||
);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#j")?.textContent).toBe("x-y");
|
||||
expect(win.document.querySelector("#f")?.textContent).toBe("a");
|
||||
});
|
||||
|
||||
// --- toast() ---------------------------------------------------------------
|
||||
// The runtime raises notifications through a window event and never imports
|
||||
// the Toaster component, so an app can host toasts however it likes.
|
||||
|
||||
test("toast() dispatches a wrnexus:toast event when a toaster is mounted", () => {
|
||||
const win = mount(
|
||||
`<div data-toaster="true"></div>` +
|
||||
`<div data-scope="who: 'Ada'"><button data-on-click="toast.success('hi ' + who, { title: 'Done' })">go</button></div>`,
|
||||
) as unknown as Window & { addEventListener: Window["addEventListener"] };
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
win.addEventListener("wrnexus:toast", ((event: { detail: Record<string, unknown> }) => {
|
||||
seen.push(event.detail);
|
||||
}) as unknown as Parameters<Window["addEventListener"]>[1]);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(seen).toEqual([{ message: "hi Ada", title: "Done", tone: "success" }]);
|
||||
});
|
||||
|
||||
test("toast() falls back to the console when no toaster is mounted", () => {
|
||||
const win = mount(
|
||||
`<div data-scope=""><button data-on-click="toast('orphan')">go</button></div>`,
|
||||
) as unknown as Window & {
|
||||
addEventListener: Window["addEventListener"];
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
win.addEventListener("wrnexus:toast", ((event: unknown) =>
|
||||
dispatched.push(event)) as unknown as Parameters<Window["addEventListener"]>[1]);
|
||||
const original = console.info;
|
||||
const logged: string[] = [];
|
||||
console.info = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
|
||||
try {
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
} finally {
|
||||
console.info = original;
|
||||
}
|
||||
expect(dispatched).toEqual([]);
|
||||
expect(logged.join("\n")).toContain("orphan");
|
||||
});
|
||||
|
||||
test("toast is reachable as a real global for compiled client modules", () => {
|
||||
const win = mount(`<div data-scope=""></div>`) as unknown as Window & {
|
||||
toast?: unknown;
|
||||
wireToast?: unknown;
|
||||
};
|
||||
expect(typeof win.wireToast).toBe("function");
|
||||
expect(typeof win.toast).toBe("function");
|
||||
});
|
||||
|
||||
test("data-show is evaluated inside a data-for row", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{ id: 1, label: 'a', extra: '' }, { id: 2, label: 'b', extra: 'yes' }]">` +
|
||||
`<ul><li data-for="row in rows" data-key="row.id">` +
|
||||
`<span class="label">{row.label}</span>` +
|
||||
`<span class="extra" data-show="row.extra">{row.extra}</span>` +
|
||||
`</li></ul></div>`,
|
||||
);
|
||||
const extras = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
|
||||
expect(extras.length).toBe(2);
|
||||
// First row has no extra: hidden. Second row does: visible.
|
||||
expect(extras[0]!.getAttribute("data-show")).toBe("false");
|
||||
expect(extras[0]!.style.display).toBe("none");
|
||||
expect(extras[1]!.getAttribute("data-show")).toBe("true");
|
||||
expect(extras[1]!.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
test("data-show in a data-for row can read component scope, not just the item", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="showAll: false, rows: [{ id: 1 }]">` +
|
||||
`<ul><li data-for="row in rows" data-key="row.id">` +
|
||||
`<b data-show="showAll">always?</b>` +
|
||||
`</li></ul></div>`,
|
||||
);
|
||||
const node = win.document.querySelector("b") as unknown as HTMLElement;
|
||||
expect(node.getAttribute("data-show")).toBe("false");
|
||||
expect(node.style.display).toBe("none");
|
||||
});
|
||||
|
||||
// --- nested data-for -------------------------------------------------------
|
||||
// A table is rows containing cells, so a loop inside a loop has to work. The
|
||||
// inner template used to be left unexpanded (and its mustaches baked against
|
||||
// the outer item, where the inner name is undefined), so nested rows rendered
|
||||
// blank.
|
||||
|
||||
test("a data-for nested inside a data-for renders its rows", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{ id: 1, cells: [{ v: 'a1' }, { v: 'b1' }] }, { id: 2, cells: [{ v: 'a2' }, { v: 'b2' }] }]">` +
|
||||
`<table><tbody>` +
|
||||
`<tr data-for="row in rows" data-key="row.id">` +
|
||||
`<td data-for="cell in row.cells" class="cell">{cell.v}</td>` +
|
||||
`</tr>` +
|
||||
`</tbody></table></div>`,
|
||||
);
|
||||
const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.textContent);
|
||||
expect(cells).toEqual(["a1", "b1", "a2", "b2"]);
|
||||
});
|
||||
|
||||
test("a nested loop can read the enclosing item and component scope", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="prefix: 'x', groups: [{ name: 'g1', items: ['p', 'q'] }]">` +
|
||||
`<ul><li data-for="group in groups">` +
|
||||
`<span data-for="item in group.items" class="leaf">{prefix}-{group.name}-{item}</span>` +
|
||||
`</li></ul></div>`,
|
||||
);
|
||||
const leaves = [...win.document.querySelectorAll(".leaf")].map((n) => n.textContent);
|
||||
expect(leaves).toEqual(["x-g1-p", "x-g1-q"]);
|
||||
});
|
||||
|
||||
// A binding inside a loop row must react to state the row's list expression
|
||||
// never touched. Effects registered during the initial render sweep used to be
|
||||
// skipped entirely (forEach fixes its range up front), so they never ran, never
|
||||
// subscribed, and stayed frozen at their first value for the life of the page.
|
||||
test("a data-show inside a loop reacts to state the list expression never reads", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{ id: 1 }, { id: 2 }], showExtra: false">` +
|
||||
`<div data-for="row in rows" data-key="row.id">` +
|
||||
`<b class="extra" data-show="showExtra">detail</b>` +
|
||||
`</div>` +
|
||||
`<button data-on-click="showExtra = true">go</button>` +
|
||||
`</div>`,
|
||||
);
|
||||
const hidden = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
|
||||
expect(hidden.length).toBe(2);
|
||||
expect(hidden.every((n) => n.style.display === "none")).toBe(true);
|
||||
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
|
||||
const shown = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
|
||||
expect(shown.every((n) => n.style.display !== "none")).toBe(true);
|
||||
});
|
||||
|
||||
test("a checked binding inside a loop toggles with state", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{ id: 1 }, { id: 2 }], chosen: 2">` +
|
||||
`<ul><li data-for="row in rows" data-key="row.id">` +
|
||||
`<input class="box" type="checkbox" data-wrn-bind-0='["checked","{row.id === chosen}"]' />` +
|
||||
`</li></ul>` +
|
||||
`<button data-on-click="chosen = 1">pick first</button></div>`,
|
||||
);
|
||||
const boxes = () => [...win.document.querySelectorAll(".box")] as unknown as HTMLInputElement[];
|
||||
expect(boxes().map((b) => b.checked)).toEqual([false, true]);
|
||||
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(boxes().map((b) => b.checked)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
// Rows rebuilt AFTER hydration register their effects outside the initial
|
||||
// sweep, so nothing ran them: data-show kept its raw expression and the
|
||||
// element defaulted to visible. Re-sorting a table therefore drew a second
|
||||
// copy of every header label.
|
||||
test("data-show still resolves in rows rebuilt after the first render", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{ id: 1, on: true }, { id: 2, on: false }]">` +
|
||||
`<div data-for="row in rows" data-key="row.id">` +
|
||||
`<b class="yes" data-show="row.on">yes</b>` +
|
||||
`</div>` +
|
||||
`<button data-on-click="rows = [{ id: 1, on: false }, { id: 2, on: true }]">swap</button>` +
|
||||
`</div>`,
|
||||
);
|
||||
const shown = () =>
|
||||
([...win.document.querySelectorAll(".yes")] as unknown as HTMLElement[]).map(
|
||||
(node) => node.style.display !== "none",
|
||||
);
|
||||
expect(shown()).toEqual([true, false]);
|
||||
|
||||
// New item objects mean brand new clones, hydrated outside the first sweep.
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(shown()).toEqual([false, true]);
|
||||
});
|
||||
|
||||
// data-html is the one directive that does not escape, so it is a separate
|
||||
// opt-in rather than a flag on data-text.
|
||||
test("data-html renders markup while data-text escapes it", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="markup: '<b>bold</b>'">` +
|
||||
`<span id="raw" data-html="markup"></span>` +
|
||||
`<span id="safe" data-text="markup"></span>` +
|
||||
`</div>`,
|
||||
);
|
||||
expect(win.document.querySelector("#raw")?.innerHTML).toBe("<b>bold</b>");
|
||||
expect(win.document.querySelector("#raw b")?.textContent).toBe("bold");
|
||||
// data-text must still show the markup as literal characters.
|
||||
expect(win.document.querySelector("#safe b")).toBeNull();
|
||||
expect(win.document.querySelector("#safe")?.textContent).toBe("<b>bold</b>");
|
||||
});
|
||||
|
||||
test("data-html applies inside a data-for row", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="rows: [{ id: 1, cell: '<b>one</b>' }, { id: 2, cell: '<i>two</i>' }]">` +
|
||||
`<ul><li data-for="row in rows" data-key="row.id">` +
|
||||
`<span class="cell" data-html="row.cell"></span>` +
|
||||
`</li></ul></div>`,
|
||||
);
|
||||
const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.innerHTML);
|
||||
expect(cells).toEqual(["<b>one</b>", "<i>two</i>"]);
|
||||
});
|
||||
|
||||
// Statement bodies given to the expression engine -- lifecycle hooks, inline
|
||||
// handlers -- are ordinary authored code and contain comments. The leading
|
||||
// slash used to fall through to the regex-literal branch and kill the body
|
||||
// with "Unclosed regular expression".
|
||||
test("comments are ignored inside interpreted statements", () => {
|
||||
const win = mount(
|
||||
`<div data-scope="total: 0">` +
|
||||
`<button data-on-click="// bump the counter total = total + 2 /* twice */">go</button>` +
|
||||
`<span id="out">{total}</span></div>`,
|
||||
);
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 })`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -171,6 +171,7 @@ items: unknown[] = []
|
||||
|
||||
<div
|
||||
class="wire-dropdown__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="menu"
|
||||
aria-label='{menuLabel}'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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,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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user