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

feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs

DataTable replaces the 20-line Table scaffold entirely: columns, sorting,
filtering, pagination, selection, bulk actions, comparison layout, sticky
first column, custom HTML cells, and a remote source driven by a `request`
output rather than a function prop (props travel as HTML attributes, so a
function arrives as its own source text).

Toaster replaces the hand-rolled status div: tone icons, actions, hover
pause/resume and a progress bar.

Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing
ever moved focus into the panel, so the @keydown handler on their root
never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap
and a body scroll lock now live in the reactive runtime, shared by both.

ContextMenu placed pointer menus by subtracting a guessed 340x420 from the
viewport, which pushed every menu that was not that size away from the
pointer; it now positions at the pointer and lets the anchored clamp pull
it back once it can be measured.

The reactive runtime size budget moves 150k -> 175k to cover anchored
overlays, dialog behaviour, the toaster and the DataTable client half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
This commit is contained in:
2026-08-07 14:57:17 +05:30
parent 296728d51d
commit cc98bccd6c
151 changed files with 14350 additions and 9189 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ import {
type PwaConfig,
type NavigationConfig,
} from "@wrnexus/styles";
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui";
import { uiComponentsDir, uiCssPath } from "@wrnexus/ui/registry";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
import {
+1 -1
View File
@@ -48,7 +48,7 @@ export async function expandStaticComponents(
}
const rendered = await component.mod.render(parseComponentProps(attributes!));
output += await expandStaticComponents(
fillSlots(String(rendered), body.inner),
fillSlots(String(rendered), body.inner, true),
components,
depth + 1,
);
+39 -9
View File
@@ -1240,9 +1240,20 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
return compressResponse(req, res);
} catch (err) {
const app = process.env.WRNEXUS_APP_NAME ?? "app";
const detail =
let detail =
err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
// AggregateError (e.g. Bun.build() "Bundle failed") hides its real cause in
// `.errors` — the top-level message/stack alone is useless for diagnosing a
// failed bundle. Print every nested error so the actual failure is visible.
const nested = (err as { errors?: unknown[] } | undefined)?.errors;
if (Array.isArray(nested) && nested.length) {
detail +=
"\n caused by:\n" +
nested
.map((e, i) => ` [${i}] ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
.join("\n");
}
console.error(
`[wrnexus] unhandled request error (${app}) ${req.method} ${url.pathname}\n${detail}`,
);
@@ -1531,7 +1542,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
}
const policy = (mod.__wrnexusCache ?? {}) as Record<string, string>;
const strategy = policy.strategy?.toLowerCase();
const renderComponent = () => fillSlots(String(render(props)), inner);
const renderComponent = () => fillSlots(String(render(props)), inner, true);
const rendered =
strategy && !["none", "no-store", "request"].includes(strategy)
? await cache.getOrLoad(
@@ -2331,24 +2342,43 @@ function extractSlots(inner: string): { named: Record<string, string>; def: stri
* <slot>…</slot> ← everything else (the default slot)
* A `<slot>fallback</slot>` keeps its fallback when nothing is provided.
*/
export function fillSlots(html: string, inner: string): string {
export function fillSlots(html: string, inner: string, markOwner = false): string {
if (!/<slot\b/.test(html)) return html;
const { named, def } = extractSlots(inner);
const defTrimmed = def.trim();
/*
* Slot content is authored by whoever wrote the component tag, but it is
* spliced *inside* the component's own [data-scope] root. Left unmarked the
* client runtime hands it to the component -- so `@input` handlers and
* `{state}` in slot content resolve against the component's scope, where
* the page's functions and state do not exist (and same-named component
* state silently shadows them).
*
* Wrapping it in <wrn-slot data-wrn-slot> gives the runtime an ownership
* boundary to walk back out of, so the markup is hydrated by the scope that
* actually wrote it. The wrapper is display: contents, so it adds an
* ownership marker without adding a box to the layout.
*
* Only component mounts pass markOwner. Layouts also go through fillSlots,
* but a layout wraps the whole page rather than being mounted inside a
* parent scope, so there is no outer scope to hand its content back to.
*/
const wrap = (content: string): string =>
markOwner && content.trim() ? `<wrn-slot data-wrn-slot="">${content}</wrn-slot>` : content;
return html
.replace(
/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?\/>/g,
(_m, name: string) => named[name] ?? "",
.replace(/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?\/>/g, (_m, name: string) =>
wrap(named[name] ?? ""),
)
.replace(
/<slot\b[^>]*?\bname="([A-Za-z0-9_-]+)"[^>]*?>([\s\S]*?)<\/slot>/g,
(_m, name: string, fallback: string) =>
named[name] != null && named[name]!.trim() ? named[name]! : fallback,
named[name] != null && named[name]!.trim() ? wrap(named[name]!) : fallback,
)
.replace(/<slot\s*\/>/g, defTrimmed ? def : "")
.replace(/<slot\s*\/>/g, defTrimmed ? wrap(def) : "")
.replace(/<slot\b[^>]*>([\s\S]*?)<\/slot>/g, (_m, fallback: string) =>
defTrimmed ? def : fallback,
defTrimmed ? wrap(def) : fallback,
);
}