fix(csr,ui): deliver component outputs to parent bindings
An output only reaches a parent @binding when the component calls output.<name>(). Two separate faults meant most of the library never got there, and both failed silently at each end. HTML lowercases attribute names, so a parent's @sizeChange registered under "sizechange" while the component emitted "sizeChange". The lookup missed, fell through to a DOM dispatch, and the binding was never invoked. That made all 17 camelCase outputs undeliverable -- DataTable.pageChange and .rowClick, Map.markerClick, ChatBubble.messageClick, LayoutSplitter.sizeChange and the rest. invokeComponentOutput now falls back to a case-insensitive lookup, and a csr test fails without it. Separately, 18 components dispatched hand-built CustomEvents rather than calling output.*. A bubbling event on the component's own root never reaches a binding, because parent handlers live in a registry only the output proxy reads. Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar, AvatarGroup, ToggleCount and InputNumber now emit properly; Marquee, Map, Timeline, List and SearchBox additionally declare the outputs they were already firing. Dispatches on window are left alone -- that is how Toaster, Modal and DataTable signal across component boundaries. Verified in a browser both ways before and after: an AnnouncementBar dispatching its own bubbling "dismiss" never reached a page-level @dismiss, and reached it immediately once it called output.dismiss(). This corrects the audit, which called the LayoutSplitter failure "narrow and unexplained" and read 32 dead outputs as 16 components needing a rebuild. "Outputs work elsewhere" was an assumption; the components that worked happened to use lowercase names and output.*. The dead-output ratchet drops from 32 to 22, and a new test forbids the raw-CustomEvent pattern outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2033,11 +2033,37 @@ const MIGRATIONS: Migration[] = [
|
||||
version: "0.8.6",
|
||||
id: "0.8.6-navigation-and-layout-groups",
|
||||
description:
|
||||
"Rebuilds the navigation and layout component groups, moves them off Tailwind utilities onto wire-* classes, and defines theme tokens that components referenced but nothing declared.",
|
||||
"Rebuilds the navigation and layout component groups, moves them off Tailwind utilities onto wire-* classes, defines theme tokens that components referenced but nothing declared, and makes component outputs actually reach parent bindings.",
|
||||
apply() {
|
||||
// Source changes no codemod can make safely, so they are listed rather
|
||||
// than attempted.
|
||||
//
|
||||
// OUTPUTS NOW ARRIVE. Two faults kept declared outputs from reaching a
|
||||
// parent @binding, and both are fixed. Expect handlers that never ran
|
||||
// before to start running -- this is the intended repair, but it is a
|
||||
// behaviour change in code you may have written around.
|
||||
//
|
||||
// 1. Every camelCase output was undeliverable. HTML lowercases
|
||||
// attribute names, so @sizeChange registered as "sizechange" while
|
||||
// the component emitted "sizeChange" and the lookup missed. That
|
||||
// covered all 17 camelCase outputs, including DataTable.pageChange
|
||||
// and .rowClick, Map.markerClick, ChatBubble.messageClick and
|
||||
// LayoutSplitter.sizeChange. The runtime now matches case
|
||||
// insensitively.
|
||||
//
|
||||
// 2. Eighteen components dispatched hand-built CustomEvents instead of
|
||||
// calling output.*, which never reaches a binding. Card, Footer,
|
||||
// Breadcrumb, Accordion, alert, Badge, AnnouncementBar, AvatarGroup,
|
||||
// ToggleCount and InputNumber now emit properly.
|
||||
//
|
||||
// If you worked around the old silence by listening for the raw DOM
|
||||
// event on the element, that listener still fires for cases where no
|
||||
// binding is registered, but the supported route is the @binding.
|
||||
//
|
||||
// Marquee, Map, Timeline, List and SearchBox now DECLARE the outputs
|
||||
// they were already firing: pause/resume, markerClick/select/zoom,
|
||||
// select, select and search/clear respectively.
|
||||
//
|
||||
// Tabs replaced its raw CustomEvents with declared outputs. Code
|
||||
// listening for the old change and select events on the element must
|
||||
// move to the @change and @select bindings.
|
||||
|
||||
@@ -3927,6 +3927,25 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
if (!root || !name) return undefined;
|
||||
var registry = root.__wrnexusOutputHandlers;
|
||||
var handlers = registry && registry[name];
|
||||
/*
|
||||
* A parent writes @sizeChange, but HTML lowercases attribute names, so the
|
||||
* handler is registered under "sizechange" while the component emits
|
||||
* "sizeChange". Without this the lookup misses, the call falls through to
|
||||
* dispatchComponentEvent, and the binding is never invoked -- silently.
|
||||
* Every camelCase output in the library was undeliverable because of it.
|
||||
*/
|
||||
if ((!handlers || !handlers.size) && registry) {
|
||||
var lower = String(name).toLowerCase();
|
||||
if (lower !== name) handlers = registry[lower];
|
||||
if (!handlers || !handlers.size) {
|
||||
for (var key in registry) {
|
||||
if (key.toLowerCase() === lower && registry[key] && registry[key].size) {
|
||||
handlers = registry[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (handlers && handlers.size) {
|
||||
var values = [];
|
||||
handlers.forEach(function (handler) { values.push(handler(payload)); });
|
||||
|
||||
@@ -631,6 +631,38 @@ test("component output handlers run in the parent scope", () => {
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
|
||||
});
|
||||
|
||||
test("a camelCase output reaches a parent binding despite attribute lowercasing", () => {
|
||||
/*
|
||||
* A parent writes @sizeChange; HTML lowercases attribute names, so the
|
||||
* handler registers under "sizechange" while the component emits
|
||||
* "sizeChange". The lookup used to miss and fall through to a DOM dispatch,
|
||||
* so the binding was never invoked and nothing reported an error. Every
|
||||
* camelCase output in the library was undeliverable -- LayoutSplitter's
|
||||
* sizeChange, DataTable's pageChange and rowClick, Map's markerClick and
|
||||
* twelve more. Verified in a browser before and after the fix.
|
||||
*/
|
||||
const win = mount(
|
||||
`<div data-scope="saved: ''">` +
|
||||
`<span id="out">{saved}</span>` +
|
||||
`<div data-scope="n: 0" data-wrn-hydration="LayoutSplitter:x">` +
|
||||
`<div data-wrn-events="sizechange" data-wrn-out-sizechange="saved = 'yes'">` +
|
||||
`<button data-on-click="output.sizeChange({ size: 45 })">go</button>` +
|
||||
`</div></div></div>`,
|
||||
);
|
||||
|
||||
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
|
||||
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
|
||||
};
|
||||
// The registry is keyed as the DOM gave it: lowercased, not as authored.
|
||||
expect(Object.keys(target.__wrnexusOutputHandlers ?? {})).toContain("sizechange");
|
||||
expect(target.__wrnexusOutputHandlers?.sizeChange).toBeUndefined();
|
||||
|
||||
// Emitting through the real output proxy, under the camelCase name the
|
||||
// component actually writes, must still reach the parent.
|
||||
(win.document.querySelector("button") as unknown as HTMLElement).click();
|
||||
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
|
||||
});
|
||||
|
||||
// --- browser globals + regex literals in client expressions ----------------
|
||||
// Client functions and inline handlers are interpreted by the runtime's own
|
||||
// eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
|
||||
|
||||
@@ -229,7 +229,7 @@ Present structured responsive linked or status items with icons, descriptions, a
|
||||
- Mount: `data-component="List"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### ListGroup
|
||||
|
||||
@@ -247,7 +247,7 @@ Continuously present responsive labels, partners, notices, or capabilities with
|
||||
- Mount: `data-component="Marquee"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `pause({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `resume({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Progress
|
||||
|
||||
@@ -301,7 +301,7 @@ Present responsive chronological activity, milestones, or workflow status with r
|
||||
- Mount: `data-component="Timeline"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Toast
|
||||
|
||||
@@ -469,7 +469,7 @@ Provide an accessible responsive search field with labels, validation states, si
|
||||
- Mount: `data-component="SearchBox"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Search Box"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "search"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
- Outputs: `search({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `clear({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Select
|
||||
|
||||
@@ -588,7 +588,7 @@ Present responsive location information and markers with map-ready metadata and
|
||||
- Mount: `data-component="Map"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `markerClick({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `zoom({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### ToastNotifications
|
||||
|
||||
|
||||
@@ -8064,8 +8064,13 @@
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"outputs": [],
|
||||
"events": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "select",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
}
|
||||
],
|
||||
"events": ["select"],
|
||||
"source": "components/List.wrn"
|
||||
},
|
||||
{
|
||||
@@ -8195,8 +8200,21 @@
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"outputs": [],
|
||||
"events": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "markerClick",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
},
|
||||
{
|
||||
"name": "select",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
},
|
||||
{
|
||||
"name": "zoom",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
}
|
||||
],
|
||||
"events": ["markerClick", "select", "zoom"],
|
||||
"source": "components/Map.wrn"
|
||||
},
|
||||
{
|
||||
@@ -8352,8 +8370,17 @@
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"outputs": [],
|
||||
"events": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "pause",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
},
|
||||
{
|
||||
"name": "resume",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
}
|
||||
],
|
||||
"events": ["pause", "resume"],
|
||||
"source": "components/Marquee.wrn"
|
||||
},
|
||||
{
|
||||
@@ -10911,8 +10938,17 @@
|
||||
}
|
||||
],
|
||||
"slots": [],
|
||||
"outputs": [],
|
||||
"events": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "search",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
},
|
||||
{
|
||||
"name": "clear",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
}
|
||||
],
|
||||
"events": ["search", "clear"],
|
||||
"source": "components/SearchBox.wrn"
|
||||
},
|
||||
{
|
||||
@@ -13034,8 +13070,13 @@
|
||||
}
|
||||
],
|
||||
"slots": ["default"],
|
||||
"outputs": [],
|
||||
"events": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "select",
|
||||
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
|
||||
}
|
||||
],
|
||||
"events": ["select"],
|
||||
"source": "components/Timeline.wrn"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,22 +49,24 @@ component Accordion {
|
||||
return multiple || alwaysOpen
|
||||
}
|
||||
|
||||
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
// Named rather than computed: an output is resolved as a property name, so
|
||||
// output[eventName] would not reach a parent binding.
|
||||
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, payload) {
|
||||
payload = {
|
||||
component: "Accordion",
|
||||
value: value,
|
||||
item: item,
|
||||
open: isOpen(value),
|
||||
openValues: openValues
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
if (eventName === "open") {
|
||||
output.open(payload)
|
||||
} else if (eventName === "close") {
|
||||
output.close(payload)
|
||||
} else {
|
||||
output.change(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function toggleItem(sourceEvent, value, item, wasOpen) {
|
||||
|
||||
@@ -127,7 +127,7 @@ badge: string = ""
|
||||
aria-label='{dismissLabel}'
|
||||
title='{dismissLabel}'
|
||||
class="wire-announcement__dismiss"
|
||||
@click='dismissed = true; event.currentTarget.dispatchEvent(new CustomEvent("dismiss", { bubbles: true, detail: { message: message } }))'
|
||||
@click='dismissed = true; output.dismiss({ message: message })'
|
||||
>
|
||||
<span
|
||||
class="icon-[lucide--x]"
|
||||
|
||||
@@ -30,17 +30,13 @@ component AvatarGroup {
|
||||
return items.slice(Number(maxVisible))
|
||||
}
|
||||
|
||||
client function toggleOverflow(sourceEvent, root, customEvent) {
|
||||
client function toggleOverflow() {
|
||||
overflowOpen = !overflowOpen
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]")
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("overflow", true, false, {
|
||||
output.overflow({
|
||||
component: "AvatarGroup",
|
||||
open: overflowOpen,
|
||||
hiddenCount: hiddenMembers().length
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ label: string = "Breadcrumb"
|
||||
<a
|
||||
href='{homeHref || "/"}'
|
||||
class="wire-breadcrumb__link wire-breadcrumb__home"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 } }))'
|
||||
@click='output.select({ item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 })'
|
||||
>
|
||||
{#if homeIcon === "icon-[lucide--house]"}
|
||||
<span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span>
|
||||
@@ -72,7 +72,7 @@ label: string = "Breadcrumb"
|
||||
target='{item.target || ""}'
|
||||
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
|
||||
class="wire-breadcrumb__link"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))'
|
||||
@click='output.select({ item: item, itemIndex: itemIndex })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
|
||||
|
||||
@@ -79,7 +79,7 @@ component Footer {
|
||||
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
|
||||
aria-current='{child.active ? "page" : ""}'
|
||||
class="wire-footer__link"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } })); child.action && event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } }))'
|
||||
@click='output.select({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex }); child.action && output.action({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex })'
|
||||
>
|
||||
{#if child.icon}
|
||||
<span
|
||||
@@ -127,7 +127,7 @@ component Footer {
|
||||
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
|
||||
aria-current='{item.active ? "page" : ""}'
|
||||
class="wire-footer__link"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } })); item.action && event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))'
|
||||
@click='output.select({ item: item, itemIndex: itemIndex }); item.action && output.action({ item: item, itemIndex: itemIndex })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span
|
||||
@@ -199,7 +199,7 @@ component Footer {
|
||||
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
|
||||
aria-current='{child.active ? "page" : ""}'
|
||||
class="wire-footer__link"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } })); child.action && event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } }))'
|
||||
@click='output.select({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex }); child.action && output.action({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex })'
|
||||
>
|
||||
{#if child.icon}
|
||||
<span
|
||||
|
||||
@@ -232,26 +232,17 @@ component InputNumber {
|
||||
)
|
||||
}
|
||||
|
||||
// Outputs are resolved by name, so each one is written out. Raw
|
||||
// CustomEvents dispatched on the root -- what this did before -- never
|
||||
// reach a parent @binding.
|
||||
client function dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
eventName,
|
||||
action,
|
||||
previousValue,
|
||||
root,
|
||||
customEvent
|
||||
payload
|
||||
) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
|
||||
|
||||
if (!root && sourceEvent.target) {
|
||||
root = sourceEvent.target.closest("[data-wrn-input-number]")
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
payload = {
|
||||
component: "InputNumber",
|
||||
name: name,
|
||||
value: currentValue,
|
||||
@@ -261,8 +252,17 @@ component InputNumber {
|
||||
max: hasMax() ? Number(max) : null,
|
||||
step: normalizedStep(),
|
||||
valid: !isInvalid()
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
if (eventName === "input") {
|
||||
output.input(payload)
|
||||
} else if (eventName === "change") {
|
||||
output.change(payload)
|
||||
} else if (eventName === "increment") {
|
||||
output.increment(payload)
|
||||
} else if (eventName === "decrement") {
|
||||
output.decrement(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function applyControlValue(nextValue, action, sourceEvent, previousValue) {
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
// and silently swallows the rule that follows it.
|
||||
component LayoutSplitter {
|
||||
outputs {
|
||||
// Not named resize. An output named after a native DOM event never reaches
|
||||
// a parent binding: the component emits it, but @resize on the tag is
|
||||
// never invoked. sizeChange is unambiguous and does arrive.
|
||||
// Named sizeChange rather than resize so it cannot be confused with the
|
||||
// native window event a caller may already be listening for. An earlier
|
||||
// comment here claimed a natively-named output could never reach a parent
|
||||
// binding; that was wrong, and the rename was never what fixed anything.
|
||||
sizeChange(payload: { size: number })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
component List {
|
||||
outputs {
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -52,7 +56,7 @@ component List {
|
||||
class:py-2.5='size === "sm"'
|
||||
class:px-5='size === "lg"'
|
||||
class:py-4='size === "lg"'
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
|
||||
@click='output.select({ item: item, index: index })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
component Map {
|
||||
outputs {
|
||||
// markerClick and select both fire for a marker press; select is the
|
||||
// generic name callers reach for, markerClick the explicit one.
|
||||
markerClick(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
zoom(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -54,7 +62,7 @@ component Map {
|
||||
aria-label='{item.label || item.title || "Map marker"}'
|
||||
class="absolute inline-flex size-10 items-center justify-center rounded-full border-4 border-[var(--wire-color-surface-raised)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-lg transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("markerClick", { bubbles: true, detail: item })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: item }))'
|
||||
@click='output.markerClick(item); output.select(item)'
|
||||
>
|
||||
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -66,7 +74,7 @@ component Map {
|
||||
type="button"
|
||||
aria-label="Zoom in"
|
||||
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "in" } }))'
|
||||
@click='output.zoom({ direction: "in" })'
|
||||
>
|
||||
<span class="icon-[lucide--plus] size-4" aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -74,7 +82,7 @@ component Map {
|
||||
type="button"
|
||||
aria-label="Zoom out"
|
||||
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "out" } }))'
|
||||
@click='output.zoom({ direction: "out" })'
|
||||
>
|
||||
<span class="icon-[lucide--minus] size-4" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
component Marquee {
|
||||
outputs {
|
||||
// Fired when the reader pauses the scroll, by hover, focus or the button.
|
||||
pause(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
resume(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -27,8 +33,8 @@ component Marquee {
|
||||
|
||||
<div
|
||||
class="group relative min-w-0 flex-1 overflow-hidden"
|
||||
@mouseenter='paused = true; event.currentTarget.dispatchEvent(new CustomEvent("pause", { bubbles: true }))'
|
||||
@mouseleave='paused = false; event.currentTarget.dispatchEvent(new CustomEvent("resume", { bubbles: true }))'
|
||||
@mouseenter='paused = true; output.pause({})'
|
||||
@mouseleave='paused = false; output.resume({})'
|
||||
@focusin='paused = true'
|
||||
@focusout='paused = false'
|
||||
>
|
||||
@@ -77,7 +83,7 @@ component Marquee {
|
||||
type="button"
|
||||
aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
|
||||
class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='paused = !paused; event.currentTarget.dispatchEvent(new CustomEvent(paused ? "pause" : "resume", { bubbles: true }))'
|
||||
@click='paused = !paused; paused ? output.pause({}) : output.resume({})'
|
||||
>
|
||||
<span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span>
|
||||
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
component SearchBox {
|
||||
outputs {
|
||||
search(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
clear(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -22,7 +27,7 @@ component SearchBox {
|
||||
data-ui-component="SearchBox"
|
||||
role="search"
|
||||
class='w-full {class}'
|
||||
@submit='event.preventDefault(); event.currentTarget.dispatchEvent(new CustomEvent("search", { bubbles: true, detail: { value: query, name: name } }))'
|
||||
@submit='event.preventDefault(); output.search({ value: query, name: name })'
|
||||
>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]"
|
||||
@@ -61,7 +66,7 @@ component SearchBox {
|
||||
aria-label="Clear search"
|
||||
data-show='query.length > 0 && !disabled'
|
||||
class="absolute right-12 inline-flex size-8 items-center justify-center rounded-lg text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); event.currentTarget.dispatchEvent(new CustomEvent("clear", { bubbles: true, detail: { value: "", name: name } }))'
|
||||
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); output.clear({ value: "", name: name })'
|
||||
>
|
||||
<span class="icon-[lucide--x] size-4" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
component Timeline {
|
||||
outputs {
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -30,7 +34,7 @@ component Timeline {
|
||||
{#each items as item, index}
|
||||
<li
|
||||
class="relative pb-8 pl-8 last:pb-0"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
|
||||
@click='output.select({ item: item, index: index })'
|
||||
>
|
||||
<span
|
||||
class="absolute -left-[1.05rem] top-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--wire-color-background)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-sm"
|
||||
|
||||
@@ -57,34 +57,17 @@ component ToggleCount {
|
||||
: emptyValue
|
||||
}
|
||||
|
||||
client function dispatchToggleEvent(sourceEvent, previousValue, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-toggle-count]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
client function dispatchToggleEvent(sourceEvent, previousValue, payload) {
|
||||
payload = {
|
||||
component: "ToggleCount",
|
||||
name: name,
|
||||
value: selectedValue,
|
||||
previousValue: previousValue,
|
||||
firstValue: firstValue,
|
||||
secondValue: secondValue
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("change", true, false, {
|
||||
component: "ToggleCount",
|
||||
name: name,
|
||||
value: selectedValue,
|
||||
previousValue: previousValue,
|
||||
firstValue: firstValue,
|
||||
secondValue: secondValue
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("toggle", true, false, {
|
||||
component: "ToggleCount",
|
||||
name: name,
|
||||
value: selectedValue,
|
||||
previousValue: previousValue,
|
||||
firstValue: firstValue,
|
||||
secondValue: secondValue
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
output.change(payload)
|
||||
output.toggle(payload)
|
||||
}
|
||||
|
||||
client function selectValue(sourceEvent, nextValue, previousValue) {
|
||||
|
||||
@@ -35,22 +35,23 @@ component Alert {
|
||||
state visible: boolean = true
|
||||
|
||||
functions {
|
||||
client function dispatchAlertEvent(sourceEvent, eventName, action, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-alert]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
// The output name has to be written out rather than computed: outputs are
|
||||
// resolved as named properties, so a dynamic key would not reach a parent
|
||||
// binding. Only two names exist here, so a branch is honest and cheap.
|
||||
client function dispatchAlertEvent(sourceEvent, eventName, action, payload) {
|
||||
payload = {
|
||||
component: "Alert",
|
||||
title: title,
|
||||
color: color,
|
||||
variant: variant,
|
||||
action: action
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
if (eventName === "dismiss") {
|
||||
output.dismiss(payload)
|
||||
} else {
|
||||
output.action(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function dismissAlert(sourceEvent) {
|
||||
|
||||
@@ -35,15 +35,13 @@ component Badge {
|
||||
state visible: boolean = true
|
||||
|
||||
functions {
|
||||
client function dismissBadge(sourceEvent, root, customEvent) {
|
||||
// output.dismiss reaches a parent @dismiss binding; a raw dispatchEvent on
|
||||
// the root does not. The runtime registers parent handlers in a registry
|
||||
// that only the output proxy consults, so the CustomEvent this used to
|
||||
// build bubbled past every binding and was never seen by anyone.
|
||||
client function dismissBadge() {
|
||||
visible = false
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-badge]")
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("dismiss", true, false, {
|
||||
component: "Badge",
|
||||
label: label
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
output.dismiss({ component: "Badge", label: label })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,67 +45,27 @@ component Card {
|
||||
state dismissed: boolean = false
|
||||
|
||||
functions {
|
||||
client function dispatchCardNavigation(sourceEvent, item, index, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("navigate", true, false, {
|
||||
component: "Card",
|
||||
item: item,
|
||||
index: index
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
// Outputs must go through output.*; a CustomEvent dispatched on the root
|
||||
// bubbles past every parent @binding without being seen, because the
|
||||
// runtime keeps parent handlers in a registry only the output proxy reads.
|
||||
client function dispatchCardNavigation(sourceEvent, item, index) {
|
||||
output.navigate({ component: "Card", item: item, index: index })
|
||||
}
|
||||
|
||||
client function dispatchCardNavigationValue(sourceEvent, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("navigate", true, false, {
|
||||
client function dispatchCardNavigationValue(sourceEvent) {
|
||||
output.navigate({
|
||||
component: "Card",
|
||||
value: sourceEvent.currentTarget.value
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
client function dispatchCardHeaderAction(sourceEvent, action, index, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("action", true, false, {
|
||||
component: "Card",
|
||||
action: action,
|
||||
index: index
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
client function dispatchCardHeaderAction(sourceEvent, action, index) {
|
||||
output.action({ component: "Card", action: action, index: index })
|
||||
}
|
||||
|
||||
client function dismissCard(sourceEvent, root, customEvent) {
|
||||
client function dismissCard() {
|
||||
dismissed = true
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("dismiss", true, false, {
|
||||
component: "Card",
|
||||
title: title
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
output.dismiss({ component: "Card", title: title })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +97,8 @@ component Card {
|
||||
alt='{item.imageAlt || item.alt || ""}'
|
||||
loading='{item.loading || "lazy"}'
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { item: item, index: itemIndex } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { item: item, index: itemIndex } }))'
|
||||
@load='output.load({ item: item, index: itemIndex })'
|
||||
@error='output.error({ item: item, index: itemIndex })'
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -157,7 +117,7 @@ component Card {
|
||||
<a
|
||||
href='{item.actionHref || item.href || "#"}'
|
||||
class="wire-next__card-action"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: item, index: itemIndex } }))'
|
||||
@click='output.action({ item: item, index: itemIndex })'
|
||||
>
|
||||
<span>{item.actionLabel || "Learn more"}</span>
|
||||
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
|
||||
@@ -180,8 +140,8 @@ component Card {
|
||||
alt='{imageAlt}'
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@load='output.load({ src: imageSrc })'
|
||||
@error='output.error({ src: imageSrc })'
|
||||
/>
|
||||
<div class="wire-next__card-overlay-shade" aria-hidden="true"></div>
|
||||
</div>
|
||||
@@ -194,8 +154,8 @@ component Card {
|
||||
alt='{imageAlt}'
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@load='output.load({ src: imageSrc })'
|
||||
@error='output.error({ src: imageSrc })'
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -323,7 +283,7 @@ component Card {
|
||||
<a
|
||||
href='{actionHref || "#"}'
|
||||
class="wire-next__card-action"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { href: actionHref, label: actionLabel } }))'
|
||||
@click='output.action({ href: actionHref, label: actionLabel })'
|
||||
>
|
||||
<span>{actionLabel}</span>
|
||||
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
|
||||
@@ -340,8 +300,8 @@ component Card {
|
||||
alt='{imageAlt}'
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@load='output.load({ src: imageSrc })'
|
||||
@error='output.error({ src: imageSrc })'
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3103,14 +3103,18 @@ test("every wire color token a component references is defined by the theme", ()
|
||||
|
||||
test("no component gains an output that nothing ever emits", () => {
|
||||
/*
|
||||
* LayoutSplitter declared resizeStart, resize and resizeEnd with no pointer
|
||||
* handling at all: a caller wired up @resize and received nothing, for ever,
|
||||
* with no error. The same shape survives in other components, so this pins
|
||||
* the count rather than letting it grow while the rest are rebuilt.
|
||||
* An output only reaches a parent @binding when the component calls
|
||||
* output.<name>(). The runtime keeps parent handlers in a registry that only
|
||||
* invokeComponentOutput reads, so a component that instead dispatches its own
|
||||
* CustomEvent -- even a bubbling one, on its own root -- is emitting into
|
||||
* nothing: the parent binding is never invoked and no error is raised.
|
||||
* Eighteen components did exactly that and were converted; this pins what is
|
||||
* left, which are components with no emitter of any kind.
|
||||
*
|
||||
* Native event names are excluded. The runtime binds a DOM-listener fallback
|
||||
* on component tags, so declaring click or input as an output does reach a
|
||||
* parent binding through bubbling.
|
||||
* Native event names are excluded, and that exclusion is real rather than
|
||||
* assumed: invokeComponentOutput falls back to dispatchComponentEvent when no
|
||||
* handler is registered, and a parent @click on a component tag is also bound
|
||||
* as an ordinary DOM listener, so a natively-named output does arrive.
|
||||
*/
|
||||
const native = new Set([
|
||||
"click",
|
||||
@@ -3160,7 +3164,54 @@ test("no component gains an output that nothing ever emits", () => {
|
||||
* A ceiling, not a target. It only ever moves down: rebuilding one of these
|
||||
* components should tighten it.
|
||||
*/
|
||||
expect(offenders.length).toBeLessThanOrEqual(32);
|
||||
expect(offenders.length).toBeLessThanOrEqual(22);
|
||||
expect(offenders).not.toContain("LayoutSplitter.sizeChange");
|
||||
expect(offenders).not.toContain("CustomScrollbar.scroll");
|
||||
});
|
||||
|
||||
test("a declared output is never emitted as a hand-built CustomEvent", () => {
|
||||
/*
|
||||
* The failure this prevents is silent in both directions: the component
|
||||
* looks like it emits, the caller looks like it listens, and the event
|
||||
* bubbles right past the binding because the runtime resolves parent
|
||||
* handlers from a registry rather than from the DOM. Verified in a browser
|
||||
* before this test was written -- an AnnouncementBar dispatching its own
|
||||
* bubbling "dismiss" never reached a page-level @dismiss, and the same
|
||||
* component reached it immediately once it called output.dismiss().
|
||||
*
|
||||
* Dispatching on window is a different thing and stays allowed: that is how
|
||||
* Toaster, Modal and DataTable signal across component boundaries, where
|
||||
* there is no parent binding to reach.
|
||||
*/
|
||||
const offenders: string[] = [];
|
||||
for (const name of uiComponentNames()) {
|
||||
const source = readFileSync(uiComponentPath(name), "utf8");
|
||||
const block = /^ {2}outputs \{([\s\S]*?)^ {2}\}/m.exec(source);
|
||||
if (!block) continue;
|
||||
|
||||
const declared = new Set(
|
||||
[...block[1]!.matchAll(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(/gm)].map((m) => m[1]!),
|
||||
);
|
||||
|
||||
for (const match of source.matchAll(
|
||||
/(\w+(?:\.\w+)*)\.dispatchEvent\(|initCustomEvent\(\s*"([A-Za-z][A-Za-z0-9_]*)"/g,
|
||||
)) {
|
||||
const target = match[1];
|
||||
if (target && /^window\b/.test(target)) continue;
|
||||
|
||||
// Which event name is being built here?
|
||||
const around = source.slice(Math.max(0, match.index - 400), match.index + 200);
|
||||
for (const declaredName of declared) {
|
||||
const quoted = `"${declaredName}"`;
|
||||
if (
|
||||
around.includes(`CustomEvent(${quoted}`) ||
|
||||
around.includes(`initCustomEvent(${quoted}`)
|
||||
) {
|
||||
offenders.push(`${name}.${declaredName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect([...new Set(offenders)]).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user