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> @
448 lines
12 KiB
JavaScript
448 lines
12 KiB
JavaScript
import { readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import process from "node:process";
|
||
|
||
const root = join(import.meta.dirname, "..");
|
||
const uiRoot = join(root, "packages", "ui");
|
||
const componentsDir = join(uiRoot, "components");
|
||
const preservedButton = readFileSync(join(componentsDir, "Button.wrn"), "utf8");
|
||
const preservedAdvancedSelect = readFileSync(join(componentsDir, "AdvancedSelect.wrn"), "utf8");
|
||
|
||
const groups = {
|
||
layout: [
|
||
"Container",
|
||
"Columns",
|
||
"Grid",
|
||
"LayoutSplitter",
|
||
"Typography",
|
||
"Image",
|
||
"Link",
|
||
"Divider",
|
||
"Kbd",
|
||
"CustomScrollbar",
|
||
],
|
||
base: [
|
||
"Accordion",
|
||
"Alert",
|
||
"Avatar",
|
||
"AvatarGroup",
|
||
"Badge",
|
||
"Blockquote",
|
||
"Button",
|
||
"ButtonGroup",
|
||
"Card",
|
||
"ChatBubble",
|
||
"Carousel",
|
||
"Collapse",
|
||
"DatePicker",
|
||
"DeviceFrame",
|
||
"List",
|
||
"ListGroup",
|
||
"LegendIndicator",
|
||
"Progress",
|
||
"FileUploadProgress",
|
||
"Rating",
|
||
"Skeleton",
|
||
"Spinner",
|
||
"StyledIcon",
|
||
"Toast",
|
||
"Timeline",
|
||
"TreeView",
|
||
"Marquee",
|
||
],
|
||
navigation: [
|
||
"Navbar",
|
||
"MegaMenu",
|
||
"Nav",
|
||
"Tabs",
|
||
"Sidebar",
|
||
"Scrollspy",
|
||
"Breadcrumb",
|
||
"Pagination",
|
||
"Stepper",
|
||
],
|
||
forms: [
|
||
"Input",
|
||
"InputGroup",
|
||
"Textarea",
|
||
"FileInput",
|
||
"Checkbox",
|
||
"Radio",
|
||
"Switch",
|
||
"Select",
|
||
"RangeSlider",
|
||
"ColorPicker",
|
||
"TimePicker",
|
||
],
|
||
"advanced-forms": [
|
||
"AdvancedSelect",
|
||
"Combobox",
|
||
"SearchBox",
|
||
"InputNumber",
|
||
"StrongPassword",
|
||
"TogglePassword",
|
||
"ToggleCount",
|
||
"CopyMarkup",
|
||
"PinInput",
|
||
],
|
||
overlays: ["Dropdown", "ContextMenu", "Modal", "Drawer", "Popover", "Tooltip"],
|
||
tables: ["DataTable"],
|
||
integrations: [
|
||
"AdvancedRangeSlider",
|
||
"AdvancedDatePicker",
|
||
"Chart",
|
||
"Clipboard",
|
||
"Confetti",
|
||
"DataMap",
|
||
"DragAndDrop",
|
||
"FileUpload",
|
||
"Map",
|
||
"ToastNotifications",
|
||
"WysiwygEditor",
|
||
],
|
||
};
|
||
|
||
const displayName = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||
const kebab = (name) => displayName(name).replace(/\s+/g, "-").toLowerCase();
|
||
|
||
function layoutComponent(name) {
|
||
if (name === "Image")
|
||
return `component Image {
|
||
props {
|
||
src = ""
|
||
alt = ""
|
||
width = ""
|
||
height = ""
|
||
loading = "lazy"
|
||
rounded = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<figure class="wire-next wire-next--image {rounded ? 'wire-next--rounded' : ''} {class}">
|
||
<img src="{src}" alt="{alt}" width="{width}" height="{height}" loading="{loading}" />
|
||
<slot />
|
||
</figure>
|
||
}
|
||
}
|
||
`;
|
||
if (name === "Link")
|
||
return `component Link {
|
||
props {
|
||
label = "Link"
|
||
href = "#"
|
||
target = ""
|
||
rel = ""
|
||
external = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<a {...attrs} href="{href}" target="{target}" rel="{rel}" class="wire-next wire-next--link {class}"><slot>{label}</slot>{#if external}<span aria-hidden="true">↗</span>{/if}</a>
|
||
}
|
||
}
|
||
`;
|
||
if (name === "Divider")
|
||
return `component Divider {
|
||
props {
|
||
label = ""
|
||
orientation = "horizontal"
|
||
class = ""
|
||
}
|
||
view {
|
||
<div class="wire-next wire-next--divider wire-next--divider-{orientation} {class}" role="separator" aria-orientation="{orientation}">{#if label}<span>{label}</span>{/if}</div>
|
||
}
|
||
}
|
||
`;
|
||
if (name === "Kbd")
|
||
return `component Kbd {
|
||
props {
|
||
label = "⌘ K"
|
||
class = ""
|
||
}
|
||
view { <kbd class="wire-next wire-next--kbd {class}"><slot>{label}</slot></kbd> }
|
||
}
|
||
`;
|
||
const tag = name === "Typography" ? "article" : "div";
|
||
return `component ${name} {
|
||
props {
|
||
columns = 2
|
||
gap = "md"
|
||
maxWidth = "xl"
|
||
class = ""
|
||
}
|
||
view {
|
||
<${tag} class="wire-next wire-next--${kebab(name)} wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></${tag}>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function collectionComponent(name) {
|
||
if (name === "Accordion" || name === "Collapse")
|
||
return `component ${name} {
|
||
props {
|
||
items = []
|
||
multiple = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<div class="wire-next wire-next--${kebab(name)} {class}">
|
||
{#each items as item}<details open="{item.open}"><summary>{item.label}</summary><div>{item.content}</div></details>{/each}
|
||
<slot />
|
||
</div>
|
||
}
|
||
}
|
||
`;
|
||
if (name === "Progress" || name === "FileUploadProgress")
|
||
return `component ${name} {
|
||
props {
|
||
label = "Progress"
|
||
value = 50
|
||
max = 100
|
||
showValue = true
|
||
class = ""
|
||
}
|
||
view {
|
||
<div class="wire-next wire-next--${kebab(name)} {class}">
|
||
<div class="wire-next__row"><span>{label}</span>{#if showValue}<strong>{value}%</strong>{/if}</div>
|
||
<progress value="{value}" max="{max}"></progress>
|
||
</div>
|
||
}
|
||
}
|
||
`;
|
||
if (name === "Spinner" || name === "Skeleton")
|
||
return `component ${name} {
|
||
props {
|
||
label = "Loading"
|
||
size = "md"
|
||
lines = 3
|
||
class = ""
|
||
}
|
||
view {
|
||
<div class="wire-next wire-next--${kebab(name)} wire-next--size-{size} {class}" role="status" aria-label="{label}">
|
||
{#if "${name}" === "Skeleton"}{#each Array(lines).fill(0) as line}<span></span>{/each}{:else}<i aria-hidden="true"></i>{/if}
|
||
</div>
|
||
}
|
||
}
|
||
`;
|
||
return `component ${name} {
|
||
props {
|
||
title = "${displayName(name)}"
|
||
description = ""
|
||
items = []
|
||
variant = "default"
|
||
class = ""
|
||
}
|
||
view {
|
||
<section class="wire-next wire-next--${kebab(name)} wire-next--variant-{variant} {class}">
|
||
{#if title}<strong>{title}</strong>{/if}
|
||
{#if description}<p>{description}</p>{/if}
|
||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
||
<slot />
|
||
</section>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function navigationComponent(name) {
|
||
return `component ${name} {
|
||
props {
|
||
label = "${displayName(name)}"
|
||
items = []
|
||
active = ""
|
||
orientation = "horizontal"
|
||
class = ""
|
||
}
|
||
view {
|
||
<nav class="wire-next wire-next--${kebab(name)} wire-next--{orientation} {class}" aria-label="{label}">
|
||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||
<slot />
|
||
</nav>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function formComponent(name) {
|
||
const typeByName = {
|
||
Input: "text",
|
||
SearchBox: "search",
|
||
InputNumber: "number",
|
||
StrongPassword: "password",
|
||
TogglePassword: "password",
|
||
PinInput: "text",
|
||
DatePicker: "date",
|
||
AdvancedDatePicker: "date",
|
||
TimePicker: "time",
|
||
ColorPicker: "color",
|
||
RangeSlider: "range",
|
||
AdvancedRangeSlider: "range",
|
||
FileInput: "file",
|
||
};
|
||
if (name === "Textarea" || name === "WysiwygEditor")
|
||
return `component ${name} {
|
||
props {
|
||
label = "${displayName(name)}"
|
||
name = ""
|
||
value = ""
|
||
placeholder = ""
|
||
rows = 5
|
||
disabled = false
|
||
required = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<label class="wire-next wire-next--field wire-next--${kebab(name)} {class}"><span>{label}</span><textarea {...attrs} name="{name}" placeholder="{placeholder}" rows="{rows}" disabled="{disabled}" required="{required}">{value}</textarea></label>
|
||
}
|
||
}
|
||
`;
|
||
if (name === "Checkbox" || name === "Radio" || name === "Switch")
|
||
return `component ${name} {
|
||
props {
|
||
label = "${displayName(name)}"
|
||
name = ""
|
||
value = "on"
|
||
checked = false
|
||
disabled = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<label class="wire-next wire-next--choice wire-next--${kebab(name)} {class}"><input {...attrs} type="${name === "Radio" ? "radio" : "checkbox"}" name="{name}" value="{value}" checked="{checked}" disabled="{disabled}" /><span>{label}</span></label>
|
||
}
|
||
}
|
||
`;
|
||
if (name.includes("Select") || name === "Combobox")
|
||
return `component ${name} {
|
||
props {
|
||
label = "${displayName(name)}"
|
||
name = ""
|
||
value = ""
|
||
options = []
|
||
placeholder = "Select an option"
|
||
multiple = false
|
||
disabled = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<label class="wire-next wire-next--field wire-next--${kebab(name)} {class}"><span>{label}</span><select {...attrs} name="{name}" multiple="{multiple}" disabled="{disabled}"><option value="">{placeholder}</option>{#each options as option}<option value="{option.value}" selected="{option.value === value}">{option.label}</option>{/each}</select></label>
|
||
}
|
||
}
|
||
`;
|
||
const inputType = typeByName[name] ?? "text";
|
||
return `component ${name} {
|
||
props {
|
||
label = "${displayName(name)}"
|
||
name = ""
|
||
value = ""
|
||
placeholder = ""
|
||
type = "${inputType}"
|
||
min = ""
|
||
max = ""
|
||
step = ""
|
||
disabled = false
|
||
required = false
|
||
class = ""
|
||
}
|
||
view {
|
||
<label class="wire-next wire-next--field wire-next--${kebab(name)} {class}"><span>{label}</span><input {...attrs} type="{type}" name="{name}" value="{value}" placeholder="{placeholder}" min="{min}" max="{max}" step="{step}" disabled="{disabled}" required="{required}" /></label>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function overlayComponent(name) {
|
||
const modal = name === "Modal" || name === "Drawer";
|
||
return `component ${name} {
|
||
props {
|
||
title = "${displayName(name)}"
|
||
description = ""
|
||
open = false
|
||
placement = "bottom"
|
||
closeLabel = "Close"
|
||
class = ""
|
||
}
|
||
view {
|
||
<div data-show="{open}" class="wire-next wire-next--${kebab(name)} wire-next--placement-{placement} {class}" role="${modal ? "dialog" : "region"}" aria-modal="${modal}" aria-label="{title}">
|
||
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header>
|
||
{#if description}<p>{description}</p>{/if}
|
||
<slot />
|
||
</div>
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function dataComponent(name) {
|
||
if (name === "DataTable")
|
||
return `component ${name} {
|
||
props {
|
||
caption = "${displayName(name)}"
|
||
columns = []
|
||
rows = []
|
||
striped = true
|
||
class = ""
|
||
}
|
||
view {
|
||
<div class="wire-next 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>
|
||
}
|
||
}
|
||
`;
|
||
return collectionComponent(name);
|
||
}
|
||
|
||
function sourceFor(name, category) {
|
||
if (name === "Button") return preservedButton;
|
||
if (name === "AdvancedSelect") return preservedAdvancedSelect;
|
||
if (category === "layout") return layoutComponent(name);
|
||
if (category === "navigation") return navigationComponent(name);
|
||
if (category === "forms" || category === "advanced-forms") return formComponent(name);
|
||
if (category === "overlays") return overlayComponent(name);
|
||
if (category === "tables" || category === "integrations") return dataComponent(name);
|
||
if (name === "DatePicker") return formComponent(name);
|
||
return collectionComponent(name);
|
||
}
|
||
|
||
function withUniversalDesignProps(source) {
|
||
let next = source;
|
||
if (!/^\s+color\s*=/m.test(next)) {
|
||
next = next.replace(/(\s+props\s*\{\r?\n)/, '$1 color = "primary"\n');
|
||
}
|
||
if (!/^\s+size\s*=/m.test(next)) {
|
||
next = next.replace(/(\s+props\s*\{\r?\n)/, '$1 size = "default"\n');
|
||
}
|
||
return next.replace(
|
||
/class="wire-next(?![^"]*wire-next--color-)/,
|
||
'class="wire-next wire-next--color-{color} wire-next--size-{size}',
|
||
);
|
||
}
|
||
|
||
const entries = Object.entries(groups).flatMap(([category, names]) =>
|
||
names.map((name) => ({
|
||
name,
|
||
category,
|
||
purpose: `Theme-aware, responsive ${displayName(name).toLowerCase()} component.`,
|
||
})),
|
||
);
|
||
|
||
for (const file of readdirSync(componentsDir)) {
|
||
if (file.endsWith(".wrn")) unlinkSync(join(componentsDir, file));
|
||
}
|
||
for (const entry of entries) {
|
||
writeFileSync(
|
||
join(componentsDir, `${entry.name}.wrn`),
|
||
withUniversalDesignProps(sourceFor(entry.name, entry.category)),
|
||
"utf8",
|
||
);
|
||
}
|
||
|
||
writeFileSync(
|
||
join(uiRoot, "component-catalog.json"),
|
||
`${JSON.stringify({ sourceDocuments: ["Screenshot-directed WRNexus UI catalog reset"], components: entries }, null, 2)}\n`,
|
||
);
|
||
writeFileSync(
|
||
join(uiRoot, "component-migrations.json"),
|
||
`${JSON.stringify({ generatedFrom: "full component catalog reset", removedCount: 0, replacements: {} }, null, 2)}\n`,
|
||
);
|
||
|
||
process.stdout.write(`Reset @wrnexus/ui to ${entries.length} canonical components.\n`);
|