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
+7 -2
View File
@@ -45,8 +45,12 @@ component ContextMenu {
sourceEvent.preventDefault()
}
if (placement === "pointer" && sourceEvent) {
positionX = Math.max(12, Math.min(sourceEvent.clientX || 12, window.innerWidth - 340))
positionY = Math.max(12, Math.min(sourceEvent.clientY || 12, window.innerHeight - 420))
// Place the menu at the pointer and let the anchored clamp in the
// runtime pull it back on screen once it has been laid out and can
// actually be measured. Subtracting a guessed 340x420 here instead
// pushed every menu that was not that size away from the pointer.
positionX = Math.max(12, sourceEvent.clientX || 12)
positionY = Math.max(12, sourceEvent.clientY || 12)
}
visible = true
output.open({
@@ -161,6 +165,7 @@ component ContextMenu {
<div
class="wire-context-menu__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
role="menu"
aria-label='{label}'
File diff suppressed because it is too large Load Diff
+83 -1
View File
@@ -20,6 +20,8 @@ open: boolean = false
showClose: boolean = true
closeOnBackdrop: boolean = true
closeOnEscape: boolean = true
// Open/close animation length in ms. 0 disables the animation entirely.
duration: number = 260
overlay: boolean = true
scrollable: boolean = true
triggerLabel: string = ""
@@ -79,7 +81,9 @@ open: boolean = false
data-overlay='{overlay ? "true" : "false"}'
data-scrollable='{scrollable ? "true" : "false"}'
class='wire-drawer {class}'
style='--drawer-duration: {duration}ms'
>
{#if triggerLabel}
<button
type="button"
@@ -101,7 +105,6 @@ open: boolean = false
<div
class="wire-drawer__layer"
data-show='{open || visible}'
role="presentation"
@keydown='handleKeydown(event)'
>
@@ -218,12 +221,32 @@ open: boolean = false
cursor: pointer;
}
/*
* The layer stays in the layout and is revealed by [data-open]; it used to
* be toggled with data-show, which sets display:none, and display cannot
* be transitioned -- the drawer simply snapped in and out. visibility is
* delayed by the duration on the way out so the panel can finish sliding
* before the layer is taken out of the hit-testing tree.
*/
.wire-drawer__layer {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
pointer-events: none;
visibility: hidden;
opacity: 0;
transition:
opacity var(--drawer-duration, 260ms) ease,
visibility 0s linear var(--drawer-duration, 260ms);
}
.wire-drawer[data-open="true"] .wire-drawer__layer {
visibility: visible;
opacity: 1;
transition:
opacity var(--drawer-duration, 260ms) ease,
visibility 0s linear 0s;
}
.wire-drawer__backdrop {
@@ -262,6 +285,38 @@ open: boolean = false
box-shadow: -28px 0 80px color-mix(in srgb, black 28%, transparent);
pointer-events: auto;
overflow: hidden;
/* Slides in from whichever edge the placement puts it on. */
transform: translateX(100%);
transition: transform var(--drawer-duration, 260ms) cubic-bezier(0.32, 0.72, 0, 1);
}
.wire-drawer[data-open="true"] .wire-drawer__panel {
transform: none;
}
.wire-drawer[data-placement="left"] .wire-drawer__panel {
transform: translateX(-100%);
}
.wire-drawer[data-placement="top"] .wire-drawer__panel {
transform: translateY(-100%);
}
.wire-drawer[data-placement="bottom"] .wire-drawer__panel {
transform: translateY(100%);
}
.wire-drawer[data-open="true"][data-placement="left"] .wire-drawer__panel,
.wire-drawer[data-open="true"][data-placement="top"] .wire-drawer__panel,
.wire-drawer[data-open="true"][data-placement="bottom"] .wire-drawer__panel {
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.wire-drawer__layer,
.wire-drawer__panel {
transition: none;
}
}
.wire-drawer[data-size="sm"] .wire-drawer__panel {
@@ -395,12 +450,19 @@ open: boolean = false
color: color-mix(in srgb, currentColor 76%, transparent);
}
/*
* padding is reset explicitly: an app-level `button { padding: ... }` rule
* outranks the browser default and leaves this fixed-size button with a
* content box of a couple of pixels, which squeezes the icon to a sliver
* and reads as "the close button has no icon". Same trap as Modal.
*/
.wire-drawer__close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0;
width: 2.35rem;
height: 2.35rem;
color: var(--wire-color-text-muted);
@@ -417,10 +479,30 @@ open: boolean = false
outline: none;
}
/* Never let the glyph be shrunk by the flex container. */
.wire-drawer__close svg {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
}
/*
* Slot content is authored by the host app, so the app global stylesheet
* styles it too. A bare element selector there (p { color: ... }) beats
* anything the panel merely *inherits*, which is how modal body copy ended
* up muted grey on a saturated background. State the colour explicitly;
* :where() keeps the specificity low enough that any class the app puts on
* its own slot content still wins.
*/
.wire-drawer__body {
flex: 1 1 auto;
min-height: 0;
padding: 1.35rem;
color: var(--wire-color-text);
}
.wire-drawer__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
color: inherit;
}
.wire-drawer[data-scrollable="true"] .wire-drawer__body {
+1
View File
@@ -171,6 +171,7 @@ items: unknown[] = []
<div
class="wire-dropdown__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
role="menu"
aria-label='{menuLabel}'
+6
View File
@@ -454,7 +454,13 @@ component FeatureCard {
font-size: 0.98rem;
}
/*
* Slot content is app-authored, so a bare `p { color: ... }` in the app
* stylesheet beats anything this container merely passes down by
* inheritance. State it, at a specificity the app can still override.
*/
.wire-feature-card__content {
color: var(--wire-color-text);
min-width: 0;
margin-top: 1rem;
}
+197 -18
View File
@@ -35,6 +35,7 @@ component Modal {
triggerLabel: string = ""
triggerIcon: string = ""
scrollable: boolean = true
scrollBehavior: string = "inside"
class: string = ""
}
@@ -50,6 +51,16 @@ component Modal {
output.open({ sourceEvent: sourceEvent })
}
// Slot content can close the modal it sits in by dispatching a bubbling
// wrnexus:modal:close event, e.g. from a form success handler:
//
// event.target.dispatchEvent(
// new CustomEvent("wrnexus:modal:close", { bubbles: true })
// )
//
// The listener is on the modal root, so the event only ever closes the
// modal the dispatching element is actually inside -- no ids to wire up
// and no way to close somebody else's modal by accident.
client function hideModal(reason, sourceEvent) {
visible = false
output.close({
@@ -87,21 +98,23 @@ component Modal {
<div
{...attrs}
data-ui-component="Modal"
data-open='{open || visible ? "true" : "false"}'
data-open='{isOpen() ? "true" : "false"}'
data-size='{size}'
data-placement='{placement}'
data-color='{color}'
data-variant='{variant}'
data-scrollable='{scrollable ? "true" : "false"}'
data-scroll='{scrollBehavior}'
data-destructive='{destructive ? "true" : "false"}'
class='wire-modal {class}'
@wrnexus:modal:close='hideModal("api", event)'
>
{#if triggerLabel}
<button
type="button"
class="wire-modal__trigger"
aria-haspopup="dialog"
aria-expanded='{open || visible ? "true" : "false"}'
aria-expanded='{isOpen() ? "true" : "false"}'
@click='showModal(event)'
>
{#if triggerIcon}
@@ -127,7 +140,7 @@ component Modal {
<div
class="wire-modal__layer"
data-show='{open || visible}'
data-show="isOpen()"
role="presentation"
@keydown='handleKeydown(event)'
>
@@ -183,11 +196,20 @@ component Modal {
aria-label='{closeLabel}'
@click='hideModal("close-button", event)'
>
<span
class="icon-[lucide--x]"
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
</span>
<path d="M18 6 6 18" />
<path d="M6 6 18 18" />
</svg>
</button>
{/if}
</header>
@@ -277,16 +299,26 @@ component Modal {
--modal-contrast: var(--wire-color-secondary-contrast);
}
/*
* Fallbacks below (the second var() argument): the theme token generator
* (packages/styles/src/theme.ts) only emits a real -contrast token for
* primary and secondary. info, success, and danger have no contrast
* token defined at all, so var(--wire-color-info-contrast) with no
* fallback resolves to nothing, and --modal-contrast becomes invalid --
* which made confirm-button and solid-panel text unreadable. White is a
* safe default against these saturated colors until the theme package
* defines real tokens for them.
*/
.wire-modal[data-color="info"] {
--modal-accent: var(--wire-color-info);
--modal-soft: var(--wire-color-info-soft);
--modal-contrast: var(--wire-color-info-contrast);
--modal-contrast: var(--wire-color-info-contrast, white);
}
.wire-modal[data-color="success"] {
--modal-accent: var(--wire-color-success);
--modal-soft: var(--wire-color-success-soft);
--modal-contrast: var(--wire-color-success-contrast);
--modal-contrast: var(--wire-color-success-contrast, white);
}
.wire-modal[data-color="warning"] {
@@ -299,7 +331,7 @@ component Modal {
.wire-modal[data-destructive="true"] {
--modal-accent: var(--wire-color-danger);
--modal-soft: var(--wire-color-danger-soft);
--modal-contrast: var(--wire-color-on-danger);
--modal-contrast: var(--wire-color-on-danger, white);
}
.wire-modal__trigger,
@@ -321,8 +353,35 @@ component Modal {
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease;
}
.wire-modal__trigger:hover {
opacity: 0.92;
}
.wire-modal__trigger:active {
transform: scale(0.97);
}
.wire-modal__trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--modal-accent) 40%, transparent);
}
/*
* Hidden by default so the SSR-rendered HTML never paints the layer before
* hydration runs. data-open on the wire-modal root is evaluated and
* serialized to a real true or false string at render time -- a plain
* bind, not the raw-expression data-show directive -- so this selector
* is correct on first paint with zero flash, with no dependency on
* client JS having run yet. The data-show attribute and client directive
* still run after hydration to keep things in sync for state changes,
* but visibility itself is driven by CSS here. visibility (rather than
* display) is used so the open and close transitions below can actually
* animate -- a box that starts at display: none has no prior frame to
* transition from.
*/
.wire-modal__layer {
position: fixed;
inset: 0;
@@ -331,6 +390,15 @@ component Modal {
align-items: center;
justify-content: center;
padding: 1rem;
visibility: hidden;
opacity: 0;
transition: opacity 180ms ease, visibility 0s linear 180ms;
}
.wire-modal[data-open="true"] .wire-modal__layer {
visibility: visible;
opacity: 1;
transition: opacity 180ms ease, visibility 0s linear 0s;
}
.wire-modal[data-placement="top"] .wire-modal__layer {
@@ -338,6 +406,12 @@ component Modal {
padding-top: clamp(1rem, 8vh, 5rem);
}
.wire-modal[data-scroll="page"] .wire-modal__layer {
align-items: flex-start;
overflow-y: auto;
padding: 2.5rem 1rem;
}
.wire-modal__backdrop {
position: absolute;
inset: 0;
@@ -370,6 +444,18 @@ component Modal {
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 40px 110px color-mix(in srgb, black 38%, transparent);
overflow: hidden;
opacity: 0;
transform: scale(0.96) translateY(10px);
transition: opacity 180ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.wire-modal[data-open="true"] .wire-modal__panel {
opacity: 1;
transform: none;
}
.wire-modal[data-size="xs"] .wire-modal__panel {
width: min(19rem, calc(100vw - 2rem));
}
.wire-modal[data-size="sm"] .wire-modal__panel {
@@ -390,6 +476,10 @@ component Modal {
max-height: none;
}
.wire-modal[data-scroll="page"] .wire-modal__panel {
max-height: none;
}
.wire-modal[data-variant="soft"] .wire-modal__panel {
background:
linear-gradient(145deg, var(--modal-soft), transparent 68%),
@@ -447,6 +537,7 @@ component Modal {
}
.wire-modal__heading-copy h2 {
color: inherit;
font-size: 1.08rem;
font-weight: 650;
line-height: 1.3;
@@ -462,32 +553,100 @@ component Modal {
color: color-mix(in srgb, currentColor 76%, transparent);
}
/*
* padding is reset explicitly: an app-level `button { padding: … }` rule
* outranks the browser default, and 1rem of horizontal padding left this
* 2.15rem button with a ~2px content box -- which squeezed the icon to
* 0.4px wide and read as "the close button has no icon".
*/
.wire-modal__close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 2.35rem;
height: 2.35rem;
padding: 0;
width: 2.15rem;
height: 2.15rem;
color: var(--wire-color-text-muted);
background: var(--wire-color-surface-soft);
border: 1px solid var(--wire-color-border);
border-radius: 0.75rem;
background: transparent;
border: 1px solid transparent;
border-radius: 9999px;
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 150ms ease;
}
/*
* On a solid panel the background is the accent color, so the muted-grey
* default is close to invisible -- the dismiss affordance reads as
* missing rather than subtle. Derive it from the panel contrast color
* instead, and give it a faint ring so it is unmistakably a control.
*/
.wire-modal[data-variant="solid"] .wire-modal__close {
color: color-mix(in srgb, currentColor 82%, transparent);
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.wire-modal[data-variant="solid"] .wire-modal__close:hover {
color: currentColor;
background: color-mix(in srgb, black 18%, transparent);
border-color: color-mix(in srgb, currentColor 55%, transparent);
}
.wire-modal__close:hover {
color: var(--wire-color-text);
background: var(--wire-color-surface-soft);
}
.wire-modal__close:hover,
.wire-modal__close:focus-visible {
color: var(--modal-accent);
border-color: color-mix(in srgb, var(--modal-accent) 34%, var(--wire-color-border));
border-color: color-mix(in srgb, var(--modal-accent) 45%, transparent);
outline: none;
}
.wire-modal__close:active {
transform: scale(0.92);
}
/* Never let the glyph be shrunk by the flex container. */
.wire-modal__close svg {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
}
/*
* Slot content is authored by the host app, so the app global stylesheet
* styles it too. A bare element selector there (p { color: ... }) beats
* anything the panel merely *inherits*, which is how solid-variant modals
* ended up with muted grey body copy on a saturated accent background --
* unreadable, and worst exactly where contrast matters most (the
* destructive confirm). Setting the color on the body makes the panel
* choice explicit instead of leaving it to inheritance.
*
* NOTE: apostrophes are avoided in .wrn style comments on purpose -- the
* block scanner treats a quote as a string delimiter while it counts
* braces, so a stray one breaks parsing of the whole component.
*/
.wire-modal__body {
flex: 1 1 auto;
min-height: 0;
padding: 1.4rem;
color: var(--wire-color-text);
}
/*
* :where() keeps this at the specificity of .wire-modal__body alone, so it
* outranks a global element selector but still yields to any class the app
* puts on its own slot content (an error message, a muted caption). A
* plain .wire-modal__body p list would have quietly overridden those.
*/
.wire-modal__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
color: inherit;
}
.wire-modal[data-variant="solid"] .wire-modal__body {
color: var(--modal-contrast);
}
.wire-modal[data-scrollable="true"] .wire-modal__body {
@@ -548,6 +707,18 @@ component Modal {
border: 1px solid transparent;
}
/*
* On a solid-variant panel the panel background is also --modal-accent,
* so a plain primary button (same color) has no visible edge against it.
* Darken the fill slightly and add a light border so the button still
* reads as a distinct, clickable pill instead of blending into the panel.
*/
.wire-modal[data-variant="solid"] .wire-modal__button--primary {
background: color-mix(in srgb, black 18%, var(--modal-accent));
border-color: color-mix(in srgb, white 32%, transparent);
box-shadow: 0 1px 0 color-mix(in srgb, white 12%, transparent) inset;
}
.wire-modal__button:disabled {
opacity: 0.55;
cursor: not-allowed;
@@ -575,6 +746,7 @@ component Modal {
}
.wire-modal__panel,
.wire-modal[data-size="xs"] .wire-modal__panel,
.wire-modal[data-size="sm"] .wire-modal__panel,
.wire-modal[data-size="lg"] .wire-modal__panel,
.wire-modal[data-size="xl"] .wire-modal__panel {
@@ -599,10 +771,17 @@ component Modal {
@media (prefers-reduced-motion: reduce) {
.wire-modal__button,
.wire-modal__spinner {
.wire-modal__spinner,
.wire-modal__layer,
.wire-modal__panel,
.wire-modal__close {
animation: none;
transition: none;
}
.wire-modal__panel {
transform: none;
}
}
}
}
}
+6
View File
@@ -588,7 +588,13 @@ current: true
min-width: 0;
}
/*
* Slot content is app-authored, so a bare `p { color: ... }` in the app
* stylesheet beats anything this container merely passes down by
* inheritance. State it, at a specificity the app can still override.
*/
.wire-page-header__body {
color: var(--wire-color-text);
margin-top: 1.75rem;
}
+8 -1
View File
@@ -123,12 +123,13 @@ component Popover {
<section
class="wire-popover__panel"
data-wrn-anchored="true"
data-show='{open || visible}'
role="dialog"
aria-label='{title || triggerLabel}'
>
{#if showArrow}
<span class="wire-popover__arrow" aria-hidden="true"></span>
<span class="wire-popover__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
{/if}
{#if title || description || icon || showClose}
@@ -471,7 +472,13 @@ component Popover {
color: color-mix(in srgb, currentColor 76%, transparent);
}
/*
* padding is reset explicitly: an app-level `button { padding: ... }` rule
* outranks the browser default and crushes the icon inside this
* fixed-size button. Same trap as Modal and Drawer.
*/
.wire-popover__close {
padding: 0;
appearance: none;
display: inline-flex;
align-items: center;
+962
View File
@@ -0,0 +1,962 @@
//
// Toaster -- the notification host. Mount it once (usually in a layout) and
// raise notifications from anywhere with the runtime global:
//
// toast("Saved")
// toast.success("Invite sent to " + email)
// toast.error("Could not save", { title: "Network error", duration: 8000 })
// toast({ message: "Uploading", tone: "info", duration: 0 }) // 0 = sticky
//
// Each tone ships a built-in icon tinted with that tone colour -- green for
// success, red for danger, amber for warning, blue for info. Swap in your own
// per toast, or per tone on the host:
//
// toast.success("Shipped", { icon: "icon-[lucide--rocket]" })
// toast("Quiet one", { icon: false }) // suppress the icon
// <Toaster successIcon="icon-[lucide--party-popper]" />
// <Toaster showIcon={false} /> // never show icons
//
// Actions run your own code. The handler is passed straight through, so it
// closes over whatever the calling function can see:
//
// toast("Note deleted", {
// actionLabel: "Undo",
// onAction: function () { restoreNote(id) }
// })
//
// toast.warning("Two versions of this file", {
// actions: [
// { label: "Keep mine", onClick: keepMine },
// { label: "Use theirs", onClick: useTheirs, tone: "danger", dismiss: false }
// ]
// })
//
// The toast dismisses itself after an action; pass dismiss: false on the
// action to keep it open (for a step that has to report back). At most two
// actions render -- see normalizeActions. A declarative @action on the tag
// still fires for every click, so a page can log or route centrally.
//
// IMPORTANT -- what a callback may do. It runs long after the function that
// created it returned, and a client function only flushes its state when its
// body ends, so assigning your own component state from inside a callback
// writes to a dead local and is lost. Calling toast(), fetch, navigation and
// anything else that is not a state assignment works normally. To change
// state, dispatch an event and handle it declaratively, which re-enters with
// live state:
//
// onAction: function () {
// window.dispatchEvent(new CustomEvent("app:undo-delete"))
// }
// ...
// <div @window:app:undo-delete="restoreItem()">
//
// An icon prop is a CSS class, never markup, so any icon system works
// (iconify, an icon font, your own sprite). NOTE the class has to appear in
// YOUR source for a scanner like Tailwind to emit it -- that is exactly why
// the defaults are inline SVG rather than classes from this package.
//
// The runtime never imports this component: toast() dispatches a
// `wrnexus:toast` window event and this listens for it, so an app can supply
// its own host by listening for the same event.
//
// NOTE: apostrophes are avoided in the style block on purpose -- the .wrn
// block scanner treats a quote as a string delimiter while counting braces,
// so a stray one breaks parsing of the whole component.
component Toaster {
outputs {
show(payload: { id: number; message: string; tone: string })
dismiss(payload: { id: number; reason: string })
action(payload: { id: number; sourceEvent: Event })
}
props {
// Default tone for toasts raised without one of their own. Every Wire UI
// component takes color and size; here they set the stack defaults.
color: string = "info"
// default | sm | lg -- controls toast width and density.
size: string = "default"
// top-left | top-center | top-right | bottom-left | bottom-center | bottom-right
position: string = "bottom-right"
// Auto-dismiss delay in ms. 0 keeps a toast until it is dismissed.
duration: number = 4500
// Oldest toasts beyond this are retired as new ones arrive.
max: number = 4
pauseOnHover: boolean = true
// Icons: a built-in glyph per tone, tinted with that tone colour.
// Override any of them with an icon class of your own (iconify, an icon
// font, whatever your app already uses) -- the built-in SVG is only a
// fallback so the component needs no icon dependency. Per toast:
// toast.success("Saved", { icon: "icon-[lucide--party-popper]" })
// toast("Quiet", { icon: false }) // no icon on this one
showIcon: boolean = true
successIcon: string = ""
dangerIcon: string = ""
warningIcon: string = ""
infoIcon: string = ""
closable: boolean = true
showProgress: boolean = true
closeLabel: string = "Dismiss notification"
class: string = ""
}
state toasts = []
state sequence = 0
// Timer bookkeeping, deliberately outside `toasts`: the view never reads
// this, so pausing and resuming rebuilds no DOM. See the note in functions.
state timers = {}
functions {
// TIMER DESIGN -- read before changing.
//
// 1. No callback here touches state. A client function gets state as a
// local snapshot and flushes it back when the body returns, so a write
// from a setTimeout callback lands in a dead local and is lost; calling
// a peer function from one re-flushes the stale snapshot over live
// state. Timers therefore only dispatch a window event, and the
// declarative @window handlers re-enter with live state.
//
// 2. Timer bookkeeping lives in `timers`, NOT on the toast entries.
// The view renders `toasts` through data-for, so touching a toast
// object rebuilds every row -- which restarted each progress bar from
// zero and made the bar look permanently full. Hover has to be free of
// that: pausing must leave the DOM completely alone. `timers` is never
// read by the view, so writing it re-renders nothing.
//
// 3. Everything that DOES change `toasts` (add, dismiss, remove) returns
// untouched items by reference for the rows it is not changing, so the
// keyed loop reuses those nodes and their bars keep running.
client function scheduleEvent(name, id, delay) {
return setTimeout(function () {
window.dispatchEvent(new CustomEvent(name, { detail: { id: id } }))
}, delay)
}
// kind is either "dismiss" (the toast lifetime, which hover pauses) or
// "remove" (the exit-animation cleanup, which hover must NOT touch --
// see pauseAll).
client function trackTimer(id, handle, life, kind) {
var next = {}
Object.keys(timers).forEach(function (key) { next[key] = timers[key] })
next[id] = {
handle: handle,
expiresAt: Date.now() + life,
remaining: life,
kind: kind || "dismiss"
}
timers = next
}
client function forgetTimer(id) {
var next = {}
Object.keys(timers).forEach(function (key) {
if (String(key) !== String(id)) {
next[key] = timers[key]
}
})
timers = next
}
// An explicit per-toast icon wins; otherwise the tone default prop; and if
// that is empty the built-in SVG for the tone renders instead. Returns a
// CSS class name, never markup, so an app can hand us any icon system.
client function resolveIcon(tone, requested) {
if (requested) {
return String(requested)
}
if (tone === "success") {
return successIcon
}
if (tone === "danger") {
return dangerIcon
}
if (tone === "warning") {
return warningIcon
}
if (tone === "info") {
return infoIcon
}
return ""
}
// Actions arrive either as a single actionLabel/onAction pair or as an
// actions array. At most two are rendered: the view has two fixed slots
// because a data-for inside a data-for is not expanded by the runtime, so
// an arbitrary list cannot be rendered per row. Two covers the real cases
// (Undo, Retry, View / Dismiss); anything beyond that is dropped loudly
// rather than silently.
client function normalizeActions(detail) {
var list = []
if (Array.isArray(detail.actions)) {
list = detail.actions.filter(function (action) { return action && action.label })
} else if (detail.actionLabel) {
list = [{ label: detail.actionLabel, onClick: detail.onAction, tone: detail.actionTone }]
}
if (list.length > 2) {
console.warn(
"[wrnexus] Toaster renders at most 2 actions per toast; ignoring " +
(list.length - 2) + " extra."
)
}
return list.slice(0, 2)
}
// Is the pointer resting on the stack right now?
//
// mouseenter only fires when the pointer MOVES. A toast raised while the
// cursor is already parked over the stack -- which is exactly what an
// action handler does -- therefore gets no enter event, is never paused,
// and counts down and disappears while the user is still reaching for its
// button. Its progress bar meanwhile IS paused, because CSS :hover does
// apply, so the bar sat still while the toast quietly expired. Asking the
// DOM for the live :hover state closes that gap.
client function stackHovered() {
var list = refs.list
return !!(pauseOnHover && list && list.matches(":hover"))
}
client function receiveToast(sourceEvent) {
var detail = sourceEvent.detail || {}
// One toast per raise, however many hosts are mounted.
//
// toast() dispatches a window event, so EVERY mounted Toaster hears it
// and a page with two hosts showed the message twice (the component
// showcase mounts five and showed five). Claiming the event on the
// detail object lets the first host win and the rest stand down, so an
// accidental second host is harmless instead of multiplying every
// notification.
if (detail.__wrnClaimed) {
return
}
detail.__wrnClaimed = true
sequence = sequence + 1
var id = sequence
var life = detail.duration === 0 ? 0 : (detail.duration || duration)
var tone = detail.tone || color
var entry = {
id: id,
title: detail.title || "",
message: detail.message === undefined ? "" : String(detail.message),
tone: tone,
icon: resolveIcon(tone, detail.icon),
showIcon: showIcon && detail.icon !== false,
actions: normalizeActions(detail),
duration: life,
leaving: false
}
// Retire the oldest live toasts in the same pass that appends the new
// one, so a burst can never leave the stack over max. Untouched rows are
// returned by reference so their nodes (and bars) survive.
var live = toasts.filter(function (item) { return !item.leaving })
var retire = live.length + 1 > max ? live.slice(0, live.length + 1 - max) : []
toasts = toasts
.map(function (item) {
var doomed = retire.some(function (old) { return old.id === item.id })
return doomed ? Object.assign({}, item, { leaving: true }) : item
})
.concat([entry])
retire.forEach(function (item) {
clearTimeout((timers[item.id] || {}).handle)
trackTimer(item.id, scheduleEvent("wrnexus:toast:remove", item.id, 240), 240, "remove")
})
if (life) {
// Born paused when the stack is already hovered: handle 0 parks it,
// and resumeAll starts the clock when the pointer finally leaves.
if (stackHovered()) {
trackTimer(id, 0, life, "dismiss")
} else {
trackTimer(id, scheduleEvent("wrnexus:toast:dismiss", id, life), life, "dismiss")
}
}
output.show({ id: id, message: entry.message, tone: entry.tone })
}
client function dismissToast(id, reason) {
var found = false
toasts = toasts.map(function (item) {
if (item.id !== id || item.leaving) {
return item
}
found = true
return Object.assign({}, item, { leaving: true })
})
if (!found) {
return
}
clearTimeout((timers[id] || {}).handle)
// Give the exit animation time to play, then drop the entry.
trackTimer(id, scheduleEvent("wrnexus:toast:remove", id, 240), 240, "remove")
output.dismiss({ id: id, reason: reason || "auto" })
}
client function removeToast(id) {
clearTimeout((timers[id] || {}).handle)
forgetTimer(id)
toasts = toasts.filter(function (item) { return item.id !== id })
}
client function dismissById(sourceEvent) {
var detail = sourceEvent.detail || {}
dismissToast(detail.id, detail.reason || "timeout")
}
client function removeById(sourceEvent) {
var detail = sourceEvent.detail || {}
removeToast(detail.id)
}
client function clearAll() {
toasts.slice().forEach(function (item) {
if (!item.leaving) {
dismissToast(item.id, "clear")
}
})
}
// Hovering must not run the clock down while a toast is being read, so
// the remaining time is banked and the timers restart on the way out.
// Only `timers` is written, so not a single DOM node is rebuilt -- the
// progress bars simply stop where they are (CSS pauses them on :hover)
// and carry on from there.
client function pauseAll() {
if (!pauseOnHover) {
return
}
var now = Date.now()
var next = {}
Object.keys(timers).forEach(function (key) {
var entry = timers[key]
// A "remove" timer finishes an exit animation -- pausing it strands
// the toast: invisible, still taking up space in the stack, and still
// able to swallow clicks, forever. Only lifetimes pause.
if (!entry || !entry.handle || entry.kind !== "dismiss") {
next[key] = entry
return
}
clearTimeout(entry.handle)
var left = entry.expiresAt - now
next[key] = {
handle: 0,
expiresAt: entry.expiresAt,
remaining: left > 0 ? left : 1,
kind: "dismiss"
}
})
timers = next
}
client function resumeAll() {
if (!pauseOnHover) {
return
}
var now = Date.now()
var next = {}
Object.keys(timers).forEach(function (key) {
var entry = timers[key]
if (!entry || entry.handle || entry.kind !== "dismiss") {
next[key] = entry
return
}
var left = entry.remaining > 0 ? entry.remaining : 1
// Number(key): object keys come back as strings, and the dismiss
// handler matches ids with !==, so a string id silently matches no
// toast and the resumed timer would fire into the void.
next[key] = {
handle: scheduleEvent("wrnexus:toast:dismiss", Number(key), left),
expiresAt: now + left,
remaining: left,
kind: "dismiss"
}
})
timers = next
}
// The click handler for an action. Runs as a fresh invocation from the
// DOM, so state here is live and calling peer functions is safe.
client function runAction(id, index, sourceEvent) {
var toast = null
toasts.forEach(function (item) {
if (item.id === id) {
toast = item
}
})
if (!toast) {
return
}
var action = toast.actions[index]
if (!action) {
return
}
// Declarative listeners on the <Toaster> tag see every action too.
output.action({
id: id,
index: index,
label: action.label,
sourceEvent: sourceEvent
})
// The callback is application code: a throw here must not take the
// toaster down with it, or the toast would be stuck on screen forever.
if (typeof action.onClick === "function") {
try {
action.onClick(sourceEvent)
} catch (error) {
console.error("[wrnexus] toast action handler failed", error)
}
}
// NOTHING may call a peer function past this point.
//
// The callback is application code and is re-entrant: a handler that
// raises its own toast runs receiveToast in a fresh invocation, which
// appends to live state. Calling a peer from here would first flush the
// snapshot this function captured on entry -- taken BEFORE the callback
// ran -- straight over that live state, silently erasing the toast the
// handler just raised. So the dismissal is dispatched inline instead,
// and handled on the next tick with state that is actually current.
// This function never assigns to state itself, so it flushes nothing.
if (action.dismiss !== false) {
setTimeout(function () {
window.dispatchEvent(
new CustomEvent("wrnexus:toast:dismiss", {
detail: { id: id, reason: "action" }
})
)
}, 0)
}
}
}
view {
<div
{...attrs}
data-ui-component="Toaster"
data-toaster="true"
data-position='{position}'
data-size='{size}'
class='wire-toaster {class}'
role="region"
aria-label="Notifications"
@window:wrnexus:toast='receiveToast(event)'
@window:wrnexus:toast:dismiss='dismissById(event)'
@window:wrnexus:toast:clear='clearAll()'
@window:wrnexus:toast:remove='removeById(event)'
>
<!-- Hover is bound to the list, not to each toast, and pauses the whole
stack. A row is a data-for clone that is recreated whenever state
changes, so a per-row mouseleave would be bound to a node the
browser has already discarded: the pointer would never "leave", the
toast would stay paused, and it would hang on screen forever.
The list element is stable for the lifetime of the page. Pausing
the whole stack is also what people expect -- reading one toast
should not let its neighbours expire underneath it. -->
<ol
class="wire-toaster__list"
data-ref="list"
aria-live="polite"
aria-relevant="additions text"
@mouseenter='pauseAll()'
@mouseleave='resumeAll()'
@focusin='pauseAll()'
@focusout='resumeAll()'
>
<li
class="wire-toast"
data-for="item in toasts"
data-key="item.id"
data-tone='{item.tone}'
data-leaving='{item.leaving}'
>
<span
class="wire-toast__indicator"
aria-hidden="true"
>
</span>
<!-- An app-supplied icon class (iconify or otherwise). -->
<span
class='wire-toast__icon {item.icon}'
data-show="item.showIcon && item.icon"
aria-hidden="true"
>
</span>
<!-- Built-in fallback. Inline SVG on purpose: the package cannot
assume the host app has an icon set installed, and a class from
here would not be in the app Tailwind content globs anyway, so
it would generate no CSS and render nothing. Each tone shows its
own glyph via data-show; all of them inherit --toast-accent, so
the icon is green / red / amber / blue with the tone. -->
<svg
class="wire-toast__icon wire-toast__icon--default"
data-show="item.showIcon && !item.icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<g data-show="item.tone === 'success'">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</g>
<g data-show="item.tone === 'danger'">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</g>
<g data-show="item.tone === 'warning'">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</g>
<g data-show="item.tone !== 'success' && item.tone !== 'danger' && item.tone !== 'warning'">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</g>
</svg>
<div
class="wire-toast__body"
>
<p
class="wire-toast__title"
data-show="item.title"
>
{item.title}
</p>
<p
class="wire-toast__message"
>
{item.message}
</p>
</div>
<!-- Two fixed action slots. A data-for inside a data-for is not
expanded by the runtime, so an arbitrary list cannot be looped
per row; normalizeActions caps the array at two and warns. -->
<div
class="wire-toast__actions"
data-show="item.actions.length"
>
<button
type="button"
class="wire-toast__action"
data-show="item.actions.length > 0"
data-tone='{item.actions[0].tone}'
@click='runAction(item.id, 0, event)'
>
{item.actions[0].label}
</button>
<button
type="button"
class="wire-toast__action"
data-show="item.actions.length > 1"
data-tone='{item.actions[1].tone}'
@click='runAction(item.id, 1, event)'
>
{item.actions[1].label}
</button>
</div>
<button
type="button"
class="wire-toast__close"
data-show="closable"
aria-label='{closeLabel}'
@click='dismissToast(item.id, "close-button")'
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="M6 6 18 18" />
</svg>
</button>
<!-- Decoration only: the timer above owns dismissal. Shown when the
toast has a lifetime, and paused in step with it on hover. -->
<span
class="wire-toast__progress"
data-show="showProgress && item.duration"
style="--toast-duration: {item.duration}ms"
aria-hidden="true"
>
</span>
</li>
</ol>
</div>
}
style {
.wire-toaster {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
padding: clamp(0.75rem, 2vw, 1.25rem);
/* The host covers the viewport so it can align the stack in any corner;
it must never swallow clicks meant for the page underneath. */
pointer-events: none;
}
/*
* pointer-events MUST be re-enabled here, not only on .wire-toast.
*
* The host is pointer-events: none so the page underneath stays clickable
* through the empty overlay, and the list inherits that. An element with
* pointer-events: none is never a hit-test target, so it never matches
* :hover AND never receives mouseenter/mouseleave -- and those two do not
* bubble up from the rows either. The result was that hover-to-pause did
* nothing at all for a real user: toasts kept counting down and vanished
* from under the cursor as they reached for the action button. (Synthetic
* dispatchEvent bypasses hit-testing, so it hid this in testing.)
*
* The list box wraps the stack exactly -- its height is the toasts plus
* their gaps -- so making it interactive costs the page nothing.
*/
.wire-toaster__list {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(23rem, 100%);
margin: 0;
padding: 0;
list-style: none;
pointer-events: auto;
}
/* Nothing to hover when the stack is empty. */
.wire-toaster__list:empty {
pointer-events: none;
}
.wire-toaster[data-size="sm"] .wire-toaster__list {
width: min(18rem, 100%);
}
.wire-toaster[data-size="lg"] .wire-toaster__list {
width: min(28rem, 100%);
}
.wire-toaster[data-size="sm"] .wire-toast {
padding: 0.6rem 0.7rem;
font-size: 0.78rem;
}
.wire-toaster[data-size="lg"] .wire-toast {
padding: 1.05rem 1.1rem;
}
.wire-toaster[data-position^="top"] {
align-items: flex-start;
}
.wire-toaster[data-position^="bottom"] {
align-items: flex-end;
}
/* Newest nearest the screen edge: at the bottom that means visually last,
so the column is reversed rather than the state array. */
.wire-toaster[data-position^="bottom"] .wire-toaster__list {
flex-direction: column-reverse;
}
.wire-toaster[data-position$="left"] {
justify-content: flex-start;
}
.wire-toaster[data-position$="center"] {
justify-content: center;
}
.wire-toaster[data-position$="right"] {
justify-content: flex-end;
}
.wire-toast {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.7rem;
overflow: hidden;
padding: 0.85rem 0.9rem;
color: var(--wire-color-text);
background: var(--wire-color-surface-raised);
border: 1px solid var(--wire-color-border);
border-radius: 0.9rem;
box-shadow: 0 18px 40px color-mix(in srgb, black 28%, transparent);
pointer-events: auto;
animation: wire-toast-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.wire-toaster[data-position$="left"] .wire-toast {
animation-name: wire-toast-in-left;
}
/*
* Belt and braces: a toast on its way out is faded to nothing but still
* occupies its box until it is dropped from the list, so without this it
* can swallow clicks aimed at whatever is under it.
*/
.wire-toast[data-leaving="true"] {
animation: wire-toast-out 200ms ease forwards;
pointer-events: none;
}
.wire-toast__indicator {
flex: 0 0 auto;
width: 0.4rem;
align-self: stretch;
border-radius: 999px;
background: var(--toast-accent, var(--wire-color-primary));
}
.wire-toast[data-tone="success"] {
--toast-accent: var(--wire-color-success);
}
.wire-toast[data-tone="danger"] {
--toast-accent: var(--wire-color-danger);
}
.wire-toast[data-tone="warning"] {
--toast-accent: var(--wire-color-warning);
}
.wire-toast[data-tone="info"] {
--toast-accent: var(--wire-color-info);
}
/*
* Centred like the trailing controls. The row is top-aligned so long text
* starts at the top, but on a one-line toast the 1.75rem close button is
* taller than the text, so a top-aligned body left the text sitting a few
* pixels above the button it is supposed to line up with.
*/
/*
* Tinted with the tone accent, so the glyph reads as the status at a
* glance -- green for success, red for danger, amber for warning, and the
* info colour otherwise. The built-in SVG strokes with currentColor, and
* an app icon class that uses currentColor (iconify does) picks up the
* same value for free.
*/
.wire-toast__icon {
flex: 0 0 auto;
align-self: center;
width: 1.15rem;
height: 1.15rem;
color: var(--toast-accent, var(--wire-color-primary));
}
.wire-toast__body {
flex: 1 1 auto;
min-width: 0;
align-self: center;
display: grid;
gap: 0.15rem;
}
.wire-toast__title,
.wire-toast__message {
margin: 0;
overflow-wrap: anywhere;
}
.wire-toast__title {
color: var(--wire-color-text);
font-size: 0.85rem;
font-weight: 650;
line-height: 1.35;
}
.wire-toast__message {
color: var(--wire-color-text-muted);
font-size: 0.82rem;
line-height: 1.5;
}
/*
* The trailing controls share one alignment. The close button used to be
* pinned to the top with a negative margin while the action sat centred,
* so the two sat on different lines and read as misaligned. Both are
* centred against the toast body now, and both are the same height, so
* their centres line up whether the toast is one line or three.
*/
.wire-toast__actions {
display: flex;
align-items: center;
flex: 0 0 auto;
align-self: center;
gap: 0.35rem;
}
/* A destructive action reads in the danger colour regardless of tone. */
.wire-toast__action[data-tone="danger"] {
color: var(--wire-color-danger);
border-color: color-mix(in srgb, var(--wire-color-danger) 45%, transparent);
}
.wire-toast__action {
appearance: none;
flex: 0 0 auto;
min-height: 1.75rem;
display: inline-flex;
align-items: center;
padding: 0 0.6rem;
color: var(--toast-accent, var(--wire-color-primary));
background: transparent;
border: 1px solid color-mix(in srgb, var(--toast-accent, var(--wire-color-primary)) 40%, transparent);
border-radius: 0.55rem;
font: inherit;
font-size: 0.78rem;
font-weight: 650;
cursor: pointer;
}
/*
* padding is reset explicitly: an app-level button padding rule beats the
* browser default and collapses the icon to a sliver inside this
* fixed-size button.
*/
.wire-toast__close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
align-self: center;
padding: 0;
width: 1.75rem;
height: 1.75rem;
margin: 0;
color: var(--wire-color-text-muted);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
transition: color 150ms ease, background 150ms ease;
}
.wire-toast__close:hover {
color: var(--wire-color-text);
background: var(--wire-color-surface-soft);
}
.wire-toast__close svg {
flex: 0 0 auto;
width: 0.875rem;
height: 0.875rem;
}
/*
* Sweeps left to right as the toast lives out its delay, reaching full
* width as it is dismissed, so the remaining time is readable at a glance.
* (Flip the keyframes below to run 1 -> 0 if you would rather it drain.)
*/
.wire-toast__progress {
position: absolute;
left: 0;
bottom: 0;
height: 2px;
width: 100%;
transform-origin: left center;
background: var(--toast-accent, var(--wire-color-primary));
animation: wire-toast-progress var(--toast-duration, 4500ms) linear forwards;
}
/* Hover pauses the clock, so the bar must pause with it or it would lie
about how much time is left. */
/*
* Paused straight from :hover rather than from a state flag. A flag would
* mean writing state on every hover, and that rebuilds the rows -- which
* restarts the bars from zero and is exactly what made them look stuck.
* CSS pauses the animation in place and touches no DOM, and the JS timer
* is banked on the same mouseenter, so the two stay in step.
*/
.wire-toaster__list:hover .wire-toast__progress,
.wire-toaster__list:focus-within .wire-toast__progress {
animation-play-state: paused;
}
@keyframes wire-toast-in {
from {
opacity: 0;
transform: translateX(18px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes wire-toast-in-left {
from {
opacity: 0;
transform: translateX(-18px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes wire-toast-out {
to {
opacity: 0;
transform: translateX(12px) scale(0.97);
}
}
@keyframes wire-toast-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@media (max-width: 639px) {
.wire-toaster {
justify-content: stretch;
}
.wire-toaster__list {
width: 100%;
}
}
/*
* Reduced motion drops the entry/exit movement but KEEPS the progress
* sweep: it is a clock, not decoration, and a frozen bar would both
* misreport the time left and look like the bug it used to be. A linear
* 2px bar carries no vestibular risk.
*/
@media (prefers-reduced-motion: reduce) {
.wire-toast,
.wire-toast[data-leaving="true"] {
animation: none;
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
component Table {
outputs {
sort(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
rowClick(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props {
size: string = "default"
color: string = "primary"
caption: string = "Table"
columns: unknown[] = []
rows: unknown[] = []
striped: boolean = true
class: string = ""
}
view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} 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>
}
}
+2 -1
View File
@@ -97,11 +97,12 @@ id: string = ""
<span
id='{id}'
class="wire-tooltip__content"
data-wrn-anchored="true"
data-show='{open || visible}'
role="tooltip"
>
{#if showArrow}
<span class="wire-tooltip__arrow" aria-hidden="true"></span>
<span class="wire-tooltip__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
{/if}
{#if title}