@
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user