@
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:
@@ -0,0 +1,90 @@
|
||||
// API route: GET /api/accounts — a paged, sorted, filtered slice of a dataset.
|
||||
//
|
||||
// This is the server half of the DataTable `load` contract. The table sends
|
||||
// page, pageSize, sortKey, sortDirection and query, and expects `rows` for
|
||||
// that page plus `total` for the whole matching set. The total has to come
|
||||
// from here: the table only ever sees one page and cannot count the rest.
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
interface Account {
|
||||
id: number;
|
||||
name: string;
|
||||
plan: string;
|
||||
owner: string;
|
||||
seats: number;
|
||||
renewsOn: string;
|
||||
}
|
||||
|
||||
const PLANS = ["Scale", "Team", "Enterprise", "Starter"];
|
||||
const OWNERS = ["A. Okafor", "R. Silva", "M. Chen", "J. Dubois", "P. Novak"];
|
||||
const NAMES = [
|
||||
"Northwind",
|
||||
"Acme Industrial",
|
||||
"Globex",
|
||||
"Initech",
|
||||
"Umbrella",
|
||||
"Stark Labs",
|
||||
"Wayne Foods",
|
||||
"Soylent",
|
||||
"Hooli",
|
||||
"Vehement",
|
||||
"Massive Dynamic",
|
||||
"Cyberdyne",
|
||||
"Tyrell",
|
||||
"Aperture",
|
||||
"Black Mesa",
|
||||
"Oceanic",
|
||||
"Weyland",
|
||||
"Gringotts",
|
||||
"Duff Brewing",
|
||||
"Prestige Worldwide",
|
||||
"Bluth Company",
|
||||
"Pied Piper",
|
||||
];
|
||||
|
||||
// Deterministic so paging is stable across requests.
|
||||
const ACCOUNTS: Account[] = NAMES.map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
plan: PLANS[index % PLANS.length]!,
|
||||
owner: OWNERS[index % OWNERS.length]!,
|
||||
seats: ((index * 13) % 240) + 4,
|
||||
renewsOn: new Date(Date.UTC(2026, index % 12, ((index * 5) % 27) + 1)).toISOString().slice(0, 10),
|
||||
}));
|
||||
|
||||
export function GET(ctx: Context): Response {
|
||||
const params = ctx.url.searchParams;
|
||||
const page = Math.max(1, Number(params.get("page")) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number(params.get("pageSize")) || 10));
|
||||
const query = (params.get("query") ?? "").trim().toLowerCase();
|
||||
const sortKey = params.get("sortKey") ?? "";
|
||||
const descending = params.get("sortDirection") === "desc";
|
||||
|
||||
let matched = ACCOUNTS;
|
||||
if (query) {
|
||||
matched = matched.filter((account) =>
|
||||
[account.name, account.plan, account.owner, account.renewsOn].some((field) =>
|
||||
field.toLowerCase().includes(query),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (sortKey) {
|
||||
const direction = descending ? -1 : 1;
|
||||
matched = [...matched].sort((left, right) => {
|
||||
const a = left[sortKey as keyof Account];
|
||||
const b = right[sortKey as keyof Account];
|
||||
if (typeof a === "number" && typeof b === "number") return (a - b) * direction;
|
||||
return String(a).localeCompare(String(b)) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return Response.json({
|
||||
rows: matched.slice(start, start + pageSize),
|
||||
total: matched.length,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// API route: POST /api/invite. Validates the body with the SAME schema the
|
||||
// modal's invite form uses in the browser, so a request that bypasses the
|
||||
// client (curl, a replayed fetch, a stale page) is held to identical rules.
|
||||
//
|
||||
// There is no mail provider wired up in the example, so a successful parse
|
||||
// just echoes the invite back. The point being demonstrated is the shared
|
||||
// schema and the client/server round trip, not delivery.
|
||||
import { verifyCsrf, type Context } from "@wrnexus/core";
|
||||
import { parseBody } from "@wrnexus/validation";
|
||||
import invite from "../schemas/invite.ts";
|
||||
|
||||
export async function POST(ctx: Context): Promise<Response> {
|
||||
if (!verifyCsrf(ctx)) return new Response("Invalid CSRF token", { status: 403 });
|
||||
|
||||
const result = await parseBody(invite, ctx.req);
|
||||
if (!result.ok) return result.response; // 400 { ok:false, errors } — rendered per field
|
||||
|
||||
const { email, message } = result.value as { email: string; message?: string };
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
email,
|
||||
message: message ?? "",
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
component Modal {
|
||||
props {
|
||||
@event onConfirmed = function
|
||||
}
|
||||
|
||||
state isOpen = false
|
||||
|
||||
functions {
|
||||
function confirmed() {
|
||||
isOpen = false;
|
||||
if(onConfirmed) {
|
||||
onConfirmed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<main
|
||||
class="flex min-h-screen items-center justify-center bg-[var(--wire-color-background)] p-6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl bg-[var(--wire-color-primary)] px-5 py-3 font-semibold text-white shadow-lg transition hover:opacity-90"
|
||||
@click="isOpen = true"
|
||||
>
|
||||
Open Transparent Modal
|
||||
</button>
|
||||
|
||||
<div
|
||||
class:hidden="isOpen == false"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg overflow-hidden rounded-3xl border border-white/20 bg-white/10 p-6 shadow-2xl backdrop-blur-2xl dark:border-white/10 dark:bg-black/20"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-20 -top-20 h-48 w-48 rounded-full bg-[var(--wire-color-primary)]/30 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute -bottom-20 -left-20 h-48 w-48 rounded-full bg-cyan-500/20 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative z-10"
|
||||
>
|
||||
<div
|
||||
class="mb-6 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
class="mb-2 text-sm font-semibold uppercase tracking-wider text-[var(--wire-color-primary)]"
|
||||
>
|
||||
Transparent Modal
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-2xl font-bold text-[var(--wire-color-text)]"
|
||||
>
|
||||
Welcome to WRNexusJS
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="mt-2 text-sm leading-6 text-[var(--wire-color-text-muted)]"
|
||||
>
|
||||
This modal uses a transparent glass background with blur,
|
||||
soft borders, and theme-aware colors.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-white/20 bg-white/10 text-xl text-[var(--wire-color-text)] transition hover:bg-white/20 dark:border-white/10 dark:bg-black/20 dark:hover:bg-black/30"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-2xl border border-white/20 bg-white/10 p-4 backdrop-blur-lg dark:border-white/10 dark:bg-black/10"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--wire-color-text-muted)]"
|
||||
>
|
||||
You can place forms, confirmation messages, account details,
|
||||
images, or any other component inside this modal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-white/20 bg-white/10 px-5 py-2.5 font-medium text-[var(--wire-color-text)] transition hover:bg-white/20 dark:border-white/10 dark:bg-black/20 dark:hover:bg-black/30"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl bg-[var(--wire-color-primary)] px-5 py-2.5 font-semibold text-white shadow-lg transition hover:opacity-90"
|
||||
@click="confirmed()"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
|
||||
style {
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,438 @@
|
||||
// Modal demo page. Route: /modal
|
||||
//
|
||||
// Five real-world use cases for the shared @wrnexus/ui Modal component — each
|
||||
// one exercises a different combination of props (color, variant, size,
|
||||
// scrollable, destructive, showFooter, confirmDisabled) instead of just
|
||||
// showing the same modal five times.
|
||||
//
|
||||
// Notifications go through the shared <Toaster /> mounted at the bottom of
|
||||
// this page. Any client expression can raise one with the runtime global —
|
||||
// toast("..."), toast.success(...), toast.error(...) — so pages never
|
||||
// hand-roll a status banner and never reach for a blocking alert().
|
||||
//
|
||||
// No import here on purpose: component tags resolve by directory scan, and
|
||||
// @wrnexus/ui's index.ts intentionally has no runtime exports (registry.ts
|
||||
// holds the server-only filesystem helpers instead) — an explicit
|
||||
// `import { Modal } from "@wrnexus/ui"` fails to bundle since there's no
|
||||
// matching export. The WRN-IMPORT-IMPLICIT warning this triggers is safe to
|
||||
// ignore for component tags.
|
||||
page ModalPage {
|
||||
|
||||
functions {
|
||||
function onConfirmed() {
|
||||
alert("User Confirmed")
|
||||
}
|
||||
state noteText = "Remember to ship the release notes"
|
||||
state deletedNote = ""
|
||||
|
||||
functions {
|
||||
function onSaveConfirmed() {
|
||||
toast.success("Changes saved", { title: "Draft published" })
|
||||
}
|
||||
|
||||
view {
|
||||
<Modal
|
||||
onConfirmed={onConfirmed}
|
||||
/>
|
||||
function onDeleteConfirmed() {
|
||||
toast.error("Account deleted", { title: "Gone for good", duration: 6000 })
|
||||
}
|
||||
|
||||
// Toast actions and component state.
|
||||
//
|
||||
// An action callback runs LONG AFTER the function that created it
|
||||
// returned, and a client function only flushes its state when its body
|
||||
// ends -- so assigning noteText straight from the callback would write to
|
||||
// a dead local and vanish. The callback dispatches an event instead, and
|
||||
// the declarative @window binding on the actions row below handles it as
|
||||
// a fresh invocation with live state. Anything that is not component
|
||||
// state (calling toast(), fetch, navigation) works from the callback
|
||||
// directly.
|
||||
function onDeleteNote() {
|
||||
deletedNote = noteText
|
||||
noteText = ""
|
||||
toast("Note deleted", {
|
||||
title: "Deleted",
|
||||
actionLabel: "Undo",
|
||||
onAction: function () {
|
||||
window.dispatchEvent(new CustomEvent("demo:restore-note"))
|
||||
},
|
||||
duration: 8000
|
||||
})
|
||||
}
|
||||
|
||||
function restoreNote() {
|
||||
if (!deletedNote) {
|
||||
return
|
||||
}
|
||||
noteText = deletedNote
|
||||
deletedNote = ""
|
||||
toast.success("Note restored")
|
||||
}
|
||||
|
||||
// Two actions. The second is styled destructive and keeps the toast open
|
||||
// (dismiss: false) so the choice stays on screen until it is resolved.
|
||||
function onConflict() {
|
||||
toast.warning("This note changed in another tab", {
|
||||
title: "Conflict",
|
||||
actions: [
|
||||
{
|
||||
label: "Keep mine",
|
||||
onClick: function () { toast.success("Kept your version") }
|
||||
},
|
||||
{
|
||||
label: "Discard",
|
||||
tone: "danger",
|
||||
dismiss: false,
|
||||
onClick: function () {
|
||||
window.dispatchEvent(new CustomEvent("demo:discard-note"))
|
||||
}
|
||||
}
|
||||
],
|
||||
duration: 0
|
||||
})
|
||||
}
|
||||
|
||||
function discardNote() {
|
||||
deletedNote = noteText
|
||||
noteText = ""
|
||||
toast.error("Your changes were discarded")
|
||||
}
|
||||
|
||||
// Tones carry their own icon and colour, but any toast can override it.
|
||||
// The class lives in this page source, so the app Tailwind/iconify build
|
||||
// sees it and emits the rule -- a class from inside @wrnexus/ui would not
|
||||
// be scanned, which is why the packaged defaults are inline SVG.
|
||||
function onTrialNotice() {
|
||||
toast.warning("Your trial ends in 3 days", {
|
||||
title: "Heads up",
|
||||
icon: "icon-[lucide--hourglass]",
|
||||
actionLabel: "Upgrade",
|
||||
duration: 6000
|
||||
})
|
||||
}
|
||||
|
||||
function onTermsConfirmed() {
|
||||
toast.success("Thanks, you are all set")
|
||||
}
|
||||
|
||||
// The invite form is a real <form data-schema="invite">, so
|
||||
// @wrnexus/validation owns the rules: it validates on input/blur/submit
|
||||
// against app/schemas/invite.ts, writes each message into the
|
||||
// [data-error] slot the Input renders, and only then POSTs to /api/invite — which parses
|
||||
// the very same schema server-side. These two handlers just react to the
|
||||
// outcome events the validator emits.
|
||||
function onInviteSent(event) {
|
||||
toast.success("Invite sent to " + event.detail.email, { title: "Invitation" })
|
||||
// Close the modal the form lives in. The event bubbles from the form up
|
||||
// to the Modal root, which listens for it -- so the page never needs a
|
||||
// handle on the modal or a way to reach its internal state.
|
||||
event.target.dispatchEvent(
|
||||
new CustomEvent("wrnexus:modal:close", { bubbles: true })
|
||||
)
|
||||
}
|
||||
|
||||
function onInviteFailed(event) {
|
||||
toast.error(event.detail.message || "Could not send the invite", {
|
||||
title: "Invite failed"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
style {
|
||||
.modal-demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-demo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 1.1rem;
|
||||
background: var(--wire-color-surface-raised);
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Direct children only. A modal mounts *inside* its card, and its panel is
|
||||
* position: fixed but still a DOM descendant -- so a plain
|
||||
* `.modal-demo-card p` also styles the copy inside the open modal, which
|
||||
* repainted the destructive modal body muted grey on its red panel. The
|
||||
* card is describing its own blurb here, not everything a component it
|
||||
* hosts happens to render.
|
||||
*/
|
||||
.modal-demo-card > h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.modal-demo-card > p {
|
||||
margin: 0;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-demo-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-demo-form-error {
|
||||
margin: 0;
|
||||
color: var(--wire-color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.modal-demo-form-error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-demo-submit {
|
||||
appearance: none;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.65rem 1rem;
|
||||
color: var(--wire-color-secondary-contrast);
|
||||
background: var(--wire-color-secondary);
|
||||
border: 0;
|
||||
border-radius: 0.78rem;
|
||||
font: inherit;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-demo-toast-demos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.75rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.modal-demo-note {
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
.modal-demo-note em {
|
||||
color: var(--wire-color-text);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.modal-demo-toast-demos button {
|
||||
appearance: none;
|
||||
padding: 0.4rem 0.7rem;
|
||||
color: var(--wire-color-text);
|
||||
background: var(--wire-color-surface-raised);
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 0.6rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-demo-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-demo-terms {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.5rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Modal</h1>
|
||||
<p>
|
||||
Five common use cases for the shared <code>@wrnexus/ui</code>
|
||||
<code>Modal</code> component — themed, accessible, and responsive out
|
||||
of the box.
|
||||
</p>
|
||||
|
||||
<div class="modal-demo-grid">
|
||||
|
||||
<!-- 1. Info-only modal: no footer, dismiss via close button/backdrop/Escape -->
|
||||
<div class="modal-demo-card">
|
||||
<h2>Info modal</h2>
|
||||
<p>No footer, no actions — just content you dismiss.</p>
|
||||
<Modal
|
||||
title="What's new"
|
||||
description="Release notes for this build."
|
||||
triggerLabel="View release notes"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
showFooter={false}
|
||||
>
|
||||
<p>
|
||||
This release adds the shared <code>@wrnexus/ui</code>
|
||||
<code>Modal</code> component to every app, fully themed to your
|
||||
palette and dark/light mode.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- 2. Standard confirm/cancel modal -->
|
||||
<div class="modal-demo-card">
|
||||
<h2>Confirmation</h2>
|
||||
<p>Cancel/confirm with a primary action.</p>
|
||||
<Modal
|
||||
title="Save changes?"
|
||||
description="You have unsaved edits on this page."
|
||||
triggerLabel="Save changes"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="md"
|
||||
cancelLabel="Discard"
|
||||
confirmLabel="Save"
|
||||
closeOnConfirm={true}
|
||||
@confirm="onSaveConfirmed()"
|
||||
>
|
||||
<p>Saving will overwrite the previously published version.</p>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- 3. Destructive confirmation -->
|
||||
<div class="modal-demo-card">
|
||||
<h2>Destructive action</h2>
|
||||
<p>Danger styling for irreversible actions.</p>
|
||||
<Modal
|
||||
title="Delete account"
|
||||
description="This action cannot be undone."
|
||||
triggerLabel="Delete account"
|
||||
color="danger"
|
||||
variant="solid"
|
||||
destructive={true}
|
||||
cancelLabel="Keep account"
|
||||
confirmLabel="Delete account"
|
||||
closeOnConfirm={true}
|
||||
@confirm="onDeleteConfirmed()"
|
||||
>
|
||||
<p>
|
||||
All of your data, projects, and billing history will be
|
||||
permanently removed.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- 4. Form modal. The form owns its own submit button, so the Modal
|
||||
footer is turned off: @wrnexus/validation binds to form[data-schema]
|
||||
and drives the whole flow (validate on input/blur, block submit
|
||||
while invalid, POST to /api/invite, emit wire:success or
|
||||
wire:error). Nothing here re-implements a rule that
|
||||
app/schemas/invite.ts already states. -->
|
||||
<div class="modal-demo-card">
|
||||
<h2>Form modal</h2>
|
||||
<p>Real Input fields, validated by the shared schema on both sides.</p>
|
||||
<Modal
|
||||
title="Invite a teammate"
|
||||
description="They will get an email with a link to join."
|
||||
triggerLabel="Invite teammate"
|
||||
color="secondary"
|
||||
variant="soft"
|
||||
size="md"
|
||||
showFooter={false}
|
||||
>
|
||||
<form
|
||||
class="modal-demo-form"
|
||||
data-schema="invite"
|
||||
method="post"
|
||||
action="/api/invite"
|
||||
@wire:success="onInviteSent(event)"
|
||||
@wire:error="onInviteFailed(event)"
|
||||
>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email address"
|
||||
placeholder="teammate@company.com"
|
||||
autocomplete="email"
|
||||
icon="icon-[lucide--mail]"
|
||||
required={true}
|
||||
helperText="They will get a one-time link to join your workspace."
|
||||
/>
|
||||
<Input
|
||||
name="message"
|
||||
label="Note (optional)"
|
||||
placeholder="Looking forward to working with you"
|
||||
maxlength="140"
|
||||
icon="icon-[lucide--message-square]"
|
||||
cornerHint="Optional"
|
||||
/>
|
||||
<p class="modal-demo-form-error" data-error="_form"></p>
|
||||
<button type="submit" class="modal-demo-submit">Send invite</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- 5. Large scrollable content, backdrop-click disabled -->
|
||||
<div class="modal-demo-card">
|
||||
<h2>Scrollable content</h2>
|
||||
<p>Larger size, scrollable body, no accidental backdrop dismiss.</p>
|
||||
<Modal
|
||||
title="Terms of service"
|
||||
description="Please review before continuing."
|
||||
triggerLabel="Read terms"
|
||||
color="primary"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
scrollable={true}
|
||||
closeOnBackdrop={false}
|
||||
cancelLabel=""
|
||||
confirmLabel="I agree"
|
||||
closeOnConfirm={true}
|
||||
@confirm="onTermsConfirmed()"
|
||||
>
|
||||
<div class="modal-demo-terms">
|
||||
<p>
|
||||
This is placeholder terms-of-service copy used to demonstrate a
|
||||
tall, scrollable modal body. In a real app this slot would hold
|
||||
your actual legal text.
|
||||
</p>
|
||||
<p>
|
||||
1. You agree to use this framework responsibly. 2. Components
|
||||
are provided as-is, themed to your app's palette. 3. Scrollable
|
||||
modals keep the header and footer fixed while the body scrolls
|
||||
independently, so long content never breaks the layout.
|
||||
</p>
|
||||
<p>
|
||||
4. Clicking the backdrop will not close this particular modal —
|
||||
that is controlled by the <code>closeOnBackdrop</code> prop,
|
||||
set to <code>false</code> here on purpose so an explicit choice
|
||||
is required. 5. Pressing Escape still works, since
|
||||
<code>closeOnEscape</code> defaults to <code>true</code>.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="modal-demo-toast-demos">
|
||||
Toast tones:
|
||||
<button type="button" @click='toast.success("Everything went through", { title: "Success" })'>success</button>
|
||||
<button type="button" @click='toast.error("That did not work", { title: "Error" })'>danger</button>
|
||||
<button type="button" @click='onTrialNotice()'>warning + custom icon</button>
|
||||
<button type="button" @click='toast.info("Just so you know")'>info</button>
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="modal-demo-toast-demos"
|
||||
@window:demo:restore-note="restoreNote()"
|
||||
@window:demo:discard-note="discardNote()"
|
||||
>
|
||||
Toast actions:
|
||||
<button type="button" @click="onDeleteNote()">delete note (undo)</button>
|
||||
<button type="button" @click="onConflict()">conflict (two actions)</button>
|
||||
<span class="modal-demo-note">Note: <em data-show="noteText">{noteText}</em><em data-show="!noteText">(deleted)</em></span>
|
||||
</p>
|
||||
|
||||
<!-- One host per page (a real app puts this in the layout). Every
|
||||
toast() call anywhere on the page lands here. -->
|
||||
<Toaster position="bottom-right" duration={4500} max={4} />
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// DataTable demo. Route: /table
|
||||
//
|
||||
// Two tables: one over a local array, one backed by /api/accounts.
|
||||
//
|
||||
// The remote table does not receive a fetch function. Component props travel
|
||||
// as HTML attributes, so a function passed that way arrives as its own source
|
||||
// text rather than something callable. Instead the table emits a `request`
|
||||
// output and we answer it by dispatching the rows back with the same id.
|
||||
page TablePage {
|
||||
|
||||
state localRows = [
|
||||
{ id: 1, name: "Northwind", plan: "Scale", seats: 42, renewsOn: "2026-02-09" },
|
||||
{ id: 2, name: "Acme Industrial", plan: "Team", seats: 8, renewsOn: "2026-01-10" },
|
||||
{ id: 3, name: "Globex", plan: "Enterprise", seats: 210, renewsOn: "2025-12-24" },
|
||||
{ id: 4, name: "Initech", plan: "Starter", seats: 5, renewsOn: "2026-03-02" },
|
||||
{ id: 5, name: "Umbrella", plan: "Scale", seats: 96, renewsOn: "2026-01-31" }
|
||||
]
|
||||
|
||||
state localColumns = [
|
||||
{ key: "name", label: "Account", sortable: true },
|
||||
{ key: "plan", label: "Plan", sortable: true },
|
||||
{ key: "seats", label: "Seats", align: "end", sortable: true, type: "number" },
|
||||
{ key: "renewsOn", label: "Renews", align: "end", sortable: true, type: "date" }
|
||||
]
|
||||
|
||||
state remoteColumns = [
|
||||
{ key: "name", label: "Account", sortable: true },
|
||||
{ key: "owner", label: "Owner", sortable: true },
|
||||
{ key: "plan", label: "Plan", align: "center" },
|
||||
{ key: "seats", label: "Seats", align: "end", sortable: true, type: "number" },
|
||||
{ key: "renewsOn", label: "Renews", align: "end", sortable: true, type: "date" }
|
||||
]
|
||||
|
||||
state tableActions = []
|
||||
state lastEvent = "none yet"
|
||||
|
||||
functions {
|
||||
// The table asks for rows through its `request` output; we answer by
|
||||
// dispatching the result back with the same instanceId. It cannot simply
|
||||
// call a function we hand it: component props travel as HTML attributes,
|
||||
// so a function would arrive as its own source text.
|
||||
function loadAccounts(payload) {
|
||||
var id = payload.instanceId
|
||||
var search = new URLSearchParams({
|
||||
page: String(payload.page),
|
||||
pageSize: String(payload.pageSize),
|
||||
query: payload.query || "",
|
||||
sortKey: payload.sortKey || "",
|
||||
sortDirection: payload.sortDirection || "asc"
|
||||
})
|
||||
|
||||
fetch("/api/accounts?" + search.toString())
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("Request failed with status " + response.status)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then(function (data) {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:datatable:rows", {
|
||||
detail: { instanceId: id, rows: data.rows, total: data.total }
|
||||
}))
|
||||
})
|
||||
.catch(function (error) {
|
||||
window.dispatchEvent(new CustomEvent("wrnexus:datatable:error", {
|
||||
detail: { instanceId: id, message: error.message }
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function onTableChange(payload) {
|
||||
lastEvent =
|
||||
"page " + payload.page + " of " + Math.max(1, Math.ceil(payload.total / payload.pageSize)) +
|
||||
" · " + payload.total + " records" +
|
||||
(payload.sortKey ? " · sorted by " + payload.sortKey + " " + payload.sortDirection : "")
|
||||
}
|
||||
|
||||
function onTableAction(payload) {
|
||||
toast.info(payload.id + " on " + payload.rows.length + " record(s)")
|
||||
}
|
||||
}
|
||||
|
||||
style {
|
||||
.table-demo-section {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.table-demo-note {
|
||||
margin: 0;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.table-demo-note code {
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<h1>Data table</h1>
|
||||
<p>
|
||||
Sorting, filtering, paging and selection over a local array, and the same
|
||||
component driven by an API through its <code>request</code> output.
|
||||
</p>
|
||||
|
||||
<section class="table-demo-section">
|
||||
<h2>Local array</h2>
|
||||
<p class="table-demo-note">
|
||||
Everything is derived in the browser. The Renews column declares
|
||||
<code>type: "date"</code>, so it sorts chronologically rather than
|
||||
alphabetically.
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
columns={localColumns}
|
||||
rows={localRows}
|
||||
actions={tableActions}
|
||||
caption="Accounts"
|
||||
pageSize={5}
|
||||
selectable={true}
|
||||
gridlines="grid"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="table-demo-section">
|
||||
<h2>Loaded from an API</h2>
|
||||
<p class="table-demo-note">
|
||||
With <code>remote</code> the table emits a <code>request</code> for each
|
||||
view change and waits to be handed rows back. Sorting and searching are
|
||||
done by <code>/api/accounts</code> — the table never sees the other pages.
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
columns={remoteColumns}
|
||||
remote={true}
|
||||
@request="loadAccounts(payload)"
|
||||
caption="Accounts (server side)"
|
||||
description="22 records, paged by the API."
|
||||
pageSize={6}
|
||||
pageSizes={[6, 12, 22]}
|
||||
paginationStyle="numbered"
|
||||
searchPlaceholder="Search accounts"
|
||||
stickyFirstColumn={true}
|
||||
selectable={true}
|
||||
@change="onTableChange(payload)"
|
||||
@action="onTableAction(payload)"
|
||||
/>
|
||||
|
||||
<p class="table-demo-note">Last change event: <strong>{lastEvent}</strong></p>
|
||||
</section>
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface Routes {
|
||||
"/platform-showcase": Record<string, never>;
|
||||
"/reactive": Record<string, never>;
|
||||
"/server-actions": Record<string, never>;
|
||||
"/table": Record<string, never>;
|
||||
"/test": Record<string, never>;
|
||||
"/ui": Record<string, never>;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ export interface RouteNames {
|
||||
"platform.showcase": "/platform-showcase";
|
||||
"reactive": "/reactive";
|
||||
"server.actions": "/server-actions";
|
||||
"table": "/table";
|
||||
"test": "/test";
|
||||
"ui": "/ui";
|
||||
}
|
||||
@@ -123,6 +125,7 @@ export function route<N extends RouteName>(
|
||||
"platform.showcase": "/platform-showcase",
|
||||
"reactive": "/reactive",
|
||||
"server.actions": "/server-actions",
|
||||
"table": "/table",
|
||||
"test": "/test",
|
||||
"ui": "/ui"
|
||||
} as Record<RouteName, RoutePath>;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// One schema, two consumers: the browser validates the invite form against
|
||||
// the descriptor emitted from this file, and /api/invite parses the same
|
||||
// schema server-side. Keeping a single definition is the point — a rule added
|
||||
// here tightens both sides at once, and the client can never drift into
|
||||
// accepting something the server rejects.
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export default v.object({
|
||||
email: v.string().trim().email("Enter a valid email address"),
|
||||
message: v.string().trim().max(140, "Keep the note under 140 characters").optional(),
|
||||
});
|
||||
Reference in New Issue
Block a user