feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs
Quality / quality (ubuntu-latest) (push) Failing after 13m40s
Quality / quality (windows-latest) (push) Canceled after 0s

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:59:58 +05:30
co-authored by Claude Opus 5
parent 296728d51d
commit 949cf78636
151 changed files with 14350 additions and 9189 deletions
@@ -197,6 +197,19 @@ const shellPreview = `
<footer>Footer slot</footer>
</div>`;
// Markup rendered beside the mount instead of inside its slot -- for host
// components that have no slot but need controls to demonstrate them.
const withBefore =
(before) =>
(title, description, props = {}, slots = {}) => ({
eyebrow: "Recommended",
title,
description,
props,
slots,
before,
});
const standard = (title, description, props = {}, slots = {}) => ({
eyebrow: "Recommended",
title,
@@ -221,7 +234,190 @@ const advanced = (title, description, props = {}, slots = {}) => ({
slots,
});
const TOASTER_DEMO_BUTTONS =
'<div class="showcase-toast-demo"><button type="button" @click=\'toast.success("Your changes are live", { title: "Published" })\'>success</button><button type="button" @click=\'toast.error("We could not reach the server", { title: "Network error" })\'>danger</button><button type="button" @click=\'toast.warning("Your trial ends in 3 days", { title: "Heads up", icon: "icon-[lucide--hourglass]" })\'>warning + custom icon</button><button type="button" @click=\'toast.info("Export queued")\'>info</button><button type="button" @click=\'toast("Note deleted", { title: "Deleted", actionLabel: "Undo" })\'>one action</button><button type="button" @click=\'toast.warning("This file changed elsewhere", { title: "Conflict", duration: 0, actions: [{ label: "Keep mine" }, { label: "Discard", tone: "danger" }] })\'>two actions (sticky)</button><button type="button" @click=\'toast("No icon on this one", { icon: false })\'>icon suppressed</button><button type="button" @click=\'toast.success("Gone in 1.5s", { duration: 1500 })\'>short duration</button><button type="button" @click=\'toast.clear()\'>clear all</button></div>';
const DATATABLE_COLUMNS_PLAIN =
'[{"key": "name", "label": "Account", "sortable": true}, {"key": "plan", "label": "Plan", "sortable": true}, {"key": "owner", "label": "Owner"}, {"key": "seats", "label": "Seats", "align": "end", "sortable": true}, {"key": "status", "label": "Status", "align": "center"}]';
const DATATABLE_COLUMNS_NARROW =
'[{"key": "name", "label": "Account", "sortable": true, "width": "40%"}, {"key": "seats", "label": "Seats", "align": "end", "sortable": true, "width": "20%"}, {"key": "plan", "label": "Plan", "align": "center", "width": "20%"}, {"key": "status", "label": "Status", "align": "end", "width": "20%"}]';
const DATATABLE_COLUMNS_INBOX =
'[{"key": "owner", "label": "From", "sortable": true}, {"key": "name", "label": "Subject", "sortable": true}, {"key": "plan", "label": "Folder", "align": "center"}, {"key": "statusHtml", "label": "State", "align": "center", "html": true}]';
const DATATABLE_COLUMNS_COMPARE =
'[{"key": "name", "label": "Plan"}, {"key": "plan", "label": "Tier"}, {"key": "owner", "label": "Owner"}, {"key": "seats", "label": "Seats", "align": "end", "type": "number"}, {"key": "status", "label": "Status", "align": "center"}]';
const DATATABLE_ACTIONS =
'[{"id": "refresh", "label": "Refresh", "icon": "icon-[lucide--refresh-cw]", "when": "always"}, {"id": "read", "label": "Mark as read", "icon": "icon-[lucide--mail-open]", "when": "selection"}, {"id": "archive", "label": "Archive", "icon": "icon-[lucide--archive]", "when": "selection"}, {"id": "delete", "label": "Delete", "icon": "icon-[lucide--trash-2]", "when": "selection", "tone": "danger"}]';
const DATATABLE_COLUMNS =
'[{"key": "name", "label": "Account", "sortable": true}, {"key": "plan", "label": "Plan", "sortable": true}, {"key": "owner", "label": "Owner"}, {"key": "seats", "label": "Seats", "align": "end", "sortable": true}, {"key": "statusHtml", "label": "Status", "align": "center", "html": true}]';
const DATATABLE_ROWS =
'[{"id": 1, "name": "Northwind", "plan": "Scale", "owner": "A. Okafor", "seats": 6, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 2, "name": "Acme Industrial", "plan": "Team", "owner": "R. Silva", "seats": 13, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 3, "name": "Globex", "plan": "Enterprise", "owner": "M. Chen", "seats": 20, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 4, "name": "Initech", "plan": "Starter", "owner": "J. Dubois", "seats": 27, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 5, "name": "Umbrella", "plan": "Scale", "owner": "P. Novak", "seats": 34, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 6, "name": "Stark Labs", "plan": "Team", "owner": "A. Okafor", "seats": 41, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 7, "name": "Wayne Foods", "plan": "Enterprise", "owner": "R. Silva", "seats": 48, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 8, "name": "Soylent", "plan": "Starter", "owner": "M. Chen", "seats": 55, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 9, "name": "Hooli", "plan": "Scale", "owner": "J. Dubois", "seats": 62, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 10, "name": "Vehement", "plan": "Team", "owner": "P. Novak", "seats": 69, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 11, "name": "Massive Dynamic", "plan": "Enterprise", "owner": "A. Okafor", "seats": 76, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 12, "name": "Cyberdyne", "plan": "Starter", "owner": "R. Silva", "seats": 83, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}, {"id": 13, "name": "Tyrell", "plan": "Scale", "owner": "M. Chen", "seats": 90, "status": "Active", "statusHtml": "<span class=\\"showcase-pill showcase-pill--success\\">Active</span>"}, {"id": 14, "name": "Aperture", "plan": "Team", "owner": "J. Dubois", "seats": 97, "status": "Trial", "statusHtml": "<span class=\\"showcase-pill showcase-pill--info\\">Trial</span>"}, {"id": 15, "name": "Black Mesa", "plan": "Enterprise", "owner": "P. Novak", "seats": 104, "status": "Past due", "statusHtml": "<span class=\\"showcase-pill showcase-pill--danger\\">Past due</span>"}]';
export const componentProfiles = {
DataTable: {
demos: [
standard(
"Sortable, searchable and paged",
"Click a header to sort, type to filter, and page through the results. Every derivation is a shared function, so the first page is server rendered and stays live after hydration.",
{
columns: DATATABLE_COLUMNS_PLAIN,
rows: DATATABLE_ROWS,
caption: "Accounts",
description: "15 records across four plans.",
pageSize: 5,
pageSizes: [5, 10, 25],
selectable: true,
actions: [],
},
),
advanced(
"Bulk actions on selection",
"An inbox pattern: Refresh is always available, while Mark as read, Archive and Delete appear only once something is ticked. Each handler receives the selected records, and an action output fires for every click.",
{
columns: DATATABLE_COLUMNS_INBOX,
rows: DATATABLE_ROWS,
actions: DATATABLE_ACTIONS,
selectable: true,
caption: "Inbox",
description: "Tick a row to reveal the selection-only actions.",
pageSize: 6,
pageSizes: [6, 12, 25],
searchPlaceholder: "Search mail",
},
),
standard(
"Numbered pagination",
"Page numbers instead of arrows, windowed around the current page so a large table never renders hundreds of buttons.",
{
columns: DATATABLE_COLUMNS_PLAIN,
rows: DATATABLE_ROWS,
actions: [],
paginationStyle: "numbered",
pageSize: 4,
pageSizes: [4, 8, 15],
caption: "Accounts",
description: "15 records, four per page.",
},
),
advanced(
"Full gridlines and custom cell markup",
'gridlines="grid" adds column rules as well as row rules. The Status column is rendered from markup rather than text, so a cell can hold a badge, a link or any other component output.',
{
columns: DATATABLE_COLUMNS,
rows: DATATABLE_ROWS,
actions: [],
gridlines: "grid",
striped: false,
pageSize: 5,
pageSizes: [5, 10, 25],
caption: "Accounts",
description: "Status is supplied as markup through a html column.",
},
),
compact(
"Fixed widths and alignment",
"Columns can set their own width and alignment: text left, counts right, short labels centred. Widths are plain CSS, so percentages, rem and ch all work.",
{
columns: DATATABLE_COLUMNS_NARROW,
rows: DATATABLE_ROWS,
actions: [],
searchable: false,
pageSize: 5,
pageSizes: [5, 10, 25],
density: "comfortable",
caption: "Column sizing",
},
),
advanced(
"Comparison layout",
'layout="comparison" transposes the table: fields run down the left and each record gets its own column. The label column stays put while the rest scrolls sideways.',
{
columns: DATATABLE_COLUMNS_COMPARE,
rows: DATATABLE_ROWS,
actions: [],
layout: "comparison",
searchable: false,
pageSize: 3,
pageSizes: [3, 5, 8],
caption: "Feature",
},
),
standard(
"Sticky first column",
"With many columns the table scrolls sideways; stickyFirstColumn keeps the record name in view so a row never loses its label.",
{
columns: DATATABLE_COLUMNS_PLAIN,
rows: DATATABLE_ROWS,
actions: [],
stickyFirstColumn: true,
pageSize: 5,
pageSizes: [5, 10, 25],
caption: "Accounts",
description: "Scroll the table horizontally on a narrow screen.",
},
),
compact("Compact density, no pagination", "Every row at once, tighter rows, no footer.", {
columns: DATATABLE_COLUMNS_PLAIN,
rows: DATATABLE_ROWS,
pageSizes: [5, 10, 25],
actions: [],
paginated: false,
density: "compact",
searchable: false,
striped: false,
caption: "All accounts",
}),
advanced("Empty state", "What a table shows before any records arrive.", {
columns: DATATABLE_COLUMNS_PLAIN,
rows: "[]",
pageSizes: [5, 10, 25],
actions: [],
caption: "Accounts",
emptyLabel: "No accounts match this filter yet",
}),
],
playground: {
columns: DATATABLE_COLUMNS,
rows: DATATABLE_ROWS,
caption: "Accounts",
pageSize: 5,
pageSizes: [5, 10, 25],
actions: DATATABLE_ACTIONS,
selectable: true,
},
options: {
density: ["compact", "default", "comfortable"],
paginationStyle: ["compact", "numbered"],
gridlines: ["rows", "grid"],
color: ["primary", "secondary", "success", "danger", "info"],
},
},
// A Toaster is a HOST, not a widget. The generic profile rendered five empty
// boxes -- and because every mounted host answers the same window event, one
// toast() call produced five toasts. One host plus buttons that actually
// raise notifications is the only demo that shows anything.
Toaster: {
demos: [
withBefore(TOASTER_DEMO_BUTTONS)(
"Raise notifications from anywhere",
"Mount one host, then call toast() from any client function. Each tone brings its own icon and colour, and hovering the stack holds a toast open while you read it. Actions here show label-only for brevity; attach onClick/onAction handlers from a functions{} block.",
{ position: "bottom-right", duration: 4500, max: 4 },
),
],
playground: { position: "bottom-right", duration: 4500, max: 4 },
options: {
position: [
"top-left",
"top-center",
"top-right",
"bottom-left",
"bottom-center",
"bottom-right",
],
size: ["sm", "default", "lg"],
},
},
PublicPageShell: {
demos: [
standard(