diff --git a/docs/ui-library-audit-2026-08-08.md b/docs/ui-library-audit-2026-08-08.md index 7b1fa10c..b5366771 100644 --- a/docs/ui-library-audit-2026-08-08.md +++ b/docs/ui-library-audit-2026-08-08.md @@ -29,30 +29,58 @@ Two mistakes worth recording, because both nearly became wrong conclusions: - The semantic token spread sat _after_ the primary and secondary palette entries, so it would have overridden them. It now sits before. +## Correction: outputs, and what was actually wrong + +The first version of this audit called the LayoutSplitter output failure +"narrow and unexplained" and treated 32 dead outputs as 16 components needing a +rebuild. Both were wrong, and the real cause was found by binding a page +handler to a component in a browser and watching what arrived. + +**Two distinct faults were hiding behind one symptom.** + +_Emitting the wrong way._ An output only reaches a parent `@binding` when the +component calls `output.()`. The runtime resolves parent handlers from a +registry that only `invokeComponentOutput` reads, so a component that builds +its own `CustomEvent` — even a bubbling one, dispatched on its own root — +emits into nothing. Eighteen components did exactly that, so their outputs were +declared, documented, apparently fired, and never delivered. Converting them +took ten dead outputs off the list and cost no new machinery. + +_Case._ HTML lowercases attribute names, so a parent's `@sizeChange` registers +under `sizechange` while the component emits `sizeChange`. The lookup missed, +fell through to a DOM dispatch, and the binding was never invoked — silently, +with no error at either end. **All 17 camelCase outputs in the library were +undeliverable**, including `DataTable.pageChange` and `DataTable.rowClick`, +`Map.markerClick`, `ChatBubble.messageClick` and `LayoutSplitter.sizeChange`. +The runtime now falls back to a case-insensitive lookup, and a test in +`packages/csr` fails without that fallback. + +The lesson worth keeping: "outputs work elsewhere" was an assumption, not a +measurement. The components that worked happened to use all-lowercase names and +`output.*`; that coincidence made a framework-wide bug look like one broken +component. + ## Outstanding, with numbers -**32 outputs across 16 components have no emitter of any kind.** This is the -bug LayoutSplitter had: the component advertises an output, a caller binds to -it, and nothing ever fires. Native event names are excluded — the runtime binds -a DOM-listener fallback on component tags, so `click` and `input` do arrive. +**22 outputs across 11 components have no emitter of any kind** (was 32 across +16; see the correction above). The component advertises an output, a caller +binds to it, and nothing ever fires. Native event names are excluded — the +runtime binds a DOM-listener fallback on component tags, so `click` and `input` +do arrive. -| Component | Dead outputs | -| ------------------------------------ | ----------------------------------------- | -| FileUpload | upload, progress, success, cancel, remove | -| ToastNotifications | add, dismiss, clear, action | -| AdvancedDatePicker | open, close, clear | -| Card | action, navigate, dismiss | -| Accordion | open, close | -| AdvancedRangeSlider | start, end | -| Chart | dataPointClick, legendToggle | -| Confetti | start, complete | -| TreeView | expand, collapse | -| AnnouncementBar, Badge, Toast, alert | dismiss | -| AvatarGroup | overflow | -| CopyMarkup | success | -| Footer | action | +| Component | Dead outputs | +| ------------------- | ----------------------------------------- | +| FileUpload | upload, progress, success, cancel, remove | +| ToastNotifications | add, dismiss, clear, action | +| AdvancedDatePicker | open, close, clear | +| AdvancedRangeSlider | start, end | +| Chart | dataPointClick, legendToggle | +| Confetti | start, complete | +| TreeView | expand, collapse | +| Toast | dismiss | +| CopyMarkup | success | -A test pins this at 32 as a ceiling that only moves down. +A test pins this at 22 as a ceiling that only moves down. **23 components are still on the `wire-next` scaffold pattern** — no style block, no functions, and for 19 of them an outputs block they never honour. @@ -81,11 +109,6 @@ the busiest activity in the library. ## Known framework defects -- **A component output is not delivered to a parent binding in at least one - case.** LayoutSplitter emits `sizeChange` and the component genuinely fires - the event, but `@sizeChange` on the tag is not invoked. Outputs work - elsewhere, so this is narrow and unexplained. Renaming away from the native - `resize` did not fix it, so the first hypothesis was wrong. - **A component tag nested inside another component slot is dropped**, leaving only its children. - **Component props and slot content render once** and do not track page state, diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index 18c9f663..85c417bf 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -2,21 +2,21 @@ "schemaVersion": 1, "releaseLine": "0.8", "artifacts": { - "packages/ui/components/Accordion.wrn": "e74433d405767edd50e58237ac3986e08e14a893450d712f69679c8361485734", + "packages/ui/components/Accordion.wrn": "2b2e756a2197052bbcaf7499d01ec4b68b53a3acaf4524b7ca6424b71ba2e9a3", "packages/ui/components/AdvancedDatePicker.wrn": "b9b5b08e9464544837a5400bbf9a2cb25724fac037fe196a219b42e5841ad672", "packages/ui/components/AdvancedRangeSlider.wrn": "e78b5cf825503d0cd306c6e490d71317ee3db1844882d13b500df1f7f1c408e1", "packages/ui/components/AdvancedSelect.wrn": "29d43f8019a3e0e98af7cf12440f41523665535a39a973441b4faea78e67114a", - "packages/ui/components/AnnouncementBar.wrn": "5946ef5eb646f7414056f59d9cfe14680aa3c935acc9a1ca6528dc53b23caa48", + "packages/ui/components/AnnouncementBar.wrn": "903bfd9421a7350c923ace3315cedc5763fe3e6b7b54b660a0f26281d46a8754", "packages/ui/components/AuthForm.wrn": "496d240090269e69679e01cda3b071c012ec4e925a7c01ae883d25e359e381e1", "packages/ui/components/AuthSplitLayout.wrn": "72b82495dd14d40a2d594ccdcf3adc925d1567c155750fb1072347b8845aab7b", - "packages/ui/components/AvatarGroup.wrn": "9c583f567213f8e1ad4506a7d2abc3fb462d11679fd2196d3828030f28a28964", + "packages/ui/components/AvatarGroup.wrn": "66f3d211b7658ca5193c750d0ccb3e392185038d8960d3206f6e8a249e10214e", "packages/ui/components/BackToTop.wrn": "b12c56ec8b7aabad68cbdc47f4b3237e2c21a61398f0aa51f9add6229b7357cd", - "packages/ui/components/Badge.wrn": "6b55b4d85100d605cf168857aec1dc57da62c1f6a0a5cd6dd877023f24b189cc", + "packages/ui/components/Badge.wrn": "e96cdcf182ff9834509464bb2fa4c0b041748d3f7671aa46ab489bae6f3d98ae", "packages/ui/components/Blockquote.wrn": "6016dd4450de7cbf5c3cb413599baa2e502321e8eedef54439a0df6d0aae8bcb", - "packages/ui/components/Breadcrumb.wrn": "df27101d41cf018d55b6909e0399286a4661637140e341c8624615051d485142", + "packages/ui/components/Breadcrumb.wrn": "14732e91468f5793ee5da8599f8e40d84ec51c93d660fb0c0056b956ec707850", "packages/ui/components/ButtonGroup.wrn": "0d97fbeed531d1d8ef4b59531923b47b34100d0de71d7bf524b02c668920b3d8", "packages/ui/components/CTASection.wrn": "2d2241d4f1018cfd2f8ea1ddd5fa8c8fcb8d1bbb2d4e4498cb9f39ff47a3801c", - "packages/ui/components/Card.wrn": "4370df341235819b8a968a3f812209c2e6c29e4471d06578aaa5b577fb6e2dc8", + "packages/ui/components/Card.wrn": "457bc546e0b227692ac96ec8f413823463d3cfe9194b6f4847c21a5beee5e8a4", "packages/ui/components/Chart.wrn": "841def6b024720bd24898975a1434a1cb39c89e1272d42db431d35d27c98c553", "packages/ui/components/ChatBubble.wrn": "0da2bc86830ac712481559bd2fe7214d26f25101f2e8c2e4629e25c004388b52", "packages/ui/components/Checkbox.wrn": "45e6046e3ad4acb1c1805b51fd5bf0b95b346d3d53973ededc5e43ea2fd91f70", @@ -44,23 +44,23 @@ "packages/ui/components/FileInput.wrn": "8b63811deb90a03763620bedf0d5d3c7d05a20b8ae34292eaface9b756b32ecd", "packages/ui/components/FileUpload.wrn": "3346d8978e134c3a1e6bc742201a3fbb0cffb89da40da6fcd856a2adbb971393", "packages/ui/components/FileUploadProgress.wrn": "83bcc8558c70fbe0e0670642f65998abd222c235f1031be46bd7c65e6b5c5154", - "packages/ui/components/Footer.wrn": "a5dfaa5f9548b7fdd4b556c63428e0ea8d22e5e776a5714d76619999438cf618", + "packages/ui/components/Footer.wrn": "d9075d2206af75a1ec4317d7974d47311889400699260e990d672780f43f40bf", "packages/ui/components/Grid.wrn": "d089d8657298b4d33993ba4bf7b581f816c65db513d8e48cf1fdd18da19e05a8", "packages/ui/components/Hero.wrn": "e4e39dcd59c273192c7e4136f6583279e20b0956bcba4cfbc3f6e01a1fade152", "packages/ui/components/HeroActions.wrn": "c5720efe2ce7f6c114eb2ea4ccc3e8102a1099078ba23fd13770eddeb6aec640", "packages/ui/components/Image.wrn": "8113441bf90ab0d1d8618d477125ad339021f39e853db89d656c72c492e7a20b", "packages/ui/components/Input.wrn": "9ad394eed60f1b05bbd2ccd50dc2de7b78245c52c87afd46055d772c38b7b0c9", "packages/ui/components/InputGroup.wrn": "0e23ea541a60e893c9d9e6f95113911ee459468d8d6105bf03aeb55395359123", - "packages/ui/components/InputNumber.wrn": "a0aa4f4566bf54ffbef54c99648eab05b5f9870be6ea07639a4ecf3930afb631", + "packages/ui/components/InputNumber.wrn": "26936385e9f01ec888a1df61719991aa7a2782305328b02c939d3c2c94e3e8e0", "packages/ui/components/Kbd.wrn": "5afaabbb15902bd8fb82cb4155cf6cbfff55600927b54d564d27e2dd8b752386", - "packages/ui/components/LayoutSplitter.wrn": "677e2dba6dbf385d8aef14829d785137ea26272a62affcd1839fa74f5145716d", + "packages/ui/components/LayoutSplitter.wrn": "6bd6812e364420ed5d3439862116c58d0b011b298679005795bdccdbb7455066", "packages/ui/components/LegendIndicator.wrn": "6a7607b27a17196f899132eb952203a1073e4077365a94935f485e700cbac665", "packages/ui/components/Link.wrn": "b986abcc66bc0b1c6296f3ff4e560812e35d89cc680d5251d7a3883e68c1d053", - "packages/ui/components/List.wrn": "02f960a8e82fa049aac477bac9c1d9371394c86d9c47513138f61bffc79dc7d8", + "packages/ui/components/List.wrn": "8cf4505ff6a079feb3b77822d7ed6977a70393dd1e6267f94e75234d3ac215d6", "packages/ui/components/ListGroup.wrn": "37e2ec61ceab20ed020a834da03208c493702b41abcf586816e29f2e50d8ee11", - "packages/ui/components/Map.wrn": "7b8a0d5412a464fd7cab8977d816542c506d8c5dab8230a3c984c2caee407c91", + "packages/ui/components/Map.wrn": "990656db93e1acd73a301a4addaf353afa14430805366d2e5f124b831044afbe", "packages/ui/components/MarketingSectionHeader.wrn": "7d6ffcc01a9331474475ea57ca58598be757abd9428b66bdfaf6f3603c5b145b", - "packages/ui/components/Marquee.wrn": "b2b35eedf3297ba12ab3776385a9f3eab001027648d87a2a1968f576eee6c025", + "packages/ui/components/Marquee.wrn": "3395235807aea43ac10c4d562706e510dac2a32786b4a5d5d73927989ede68df", "packages/ui/components/MegaMenu.wrn": "8a9a9b348ca1c5e182914ff55d981a91f47a49c52ef5e5efd8e4cb6e56361a43", "packages/ui/components/MetricCard.wrn": "6451182739298691908f68258c0250cce2a78b0dc27c97115579ece30d7d9f92", "packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364", @@ -78,7 +78,7 @@ "packages/ui/components/RangeSlider.wrn": "21eb7a5df0ee2cb5e78f628eb920265998eb9f1a4933d4e5c57db0323fd66b41", "packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb", "packages/ui/components/Scrollspy.wrn": "76d1c17580c8d460f1222ce97585bdcac9779141f2f30d5460c84ff9f75413d8", - "packages/ui/components/SearchBox.wrn": "315f54f1feaaa47a815d1e2d7a35b492f09fbfadfc6d37228e1009e19e0652ad", + "packages/ui/components/SearchBox.wrn": "4a07358530ffb197d80d816b6ee4335a20812b6bee1e66a845c4075305bee6f7", "packages/ui/components/Section.wrn": "33eb8a82a2102229c2b1f64cec497e9e8e70b67adab8dbe396be860fccbb699c", "packages/ui/components/SectionHeader.wrn": "8eecf2ea7a93139512260e3d46ca2a1afeb341b8bd46b7c30baa6e2c75e25d10", "packages/ui/components/Select.wrn": "7f046bb11b7c2470dae26d91a4b2261c66a40ae054d0e04c69b6748912d1e204", @@ -93,17 +93,17 @@ "packages/ui/components/TextLink.wrn": "30782039293eb36d63b7b3a4f32a71a47177a3a68c7e90184be7cf7b4385eb19", "packages/ui/components/Textarea.wrn": "ddf0b4f124b2cf0c0ab3d820d3ac0085f7c20466e977231949be264cd0cee8cf", "packages/ui/components/TimePicker.wrn": "2e8e7a90f6b6069a07e7ffd2725ba1e1031e84d55a4f1254025befbb314fa695", - "packages/ui/components/Timeline.wrn": "5708c656eefd12f31c93490075ee482bf527059028844bd58cdcddc88461e2b5", + "packages/ui/components/Timeline.wrn": "aec123bf69f6bfa41c3564e4eac18b2a245214dc6905ebd0bf5b69f7c2736ae6", "packages/ui/components/Toast.wrn": "f37c584d1c1a66401deb53d915c8ee0c70aaf7baa1d3c297fa74c5bdb914dd1d", "packages/ui/components/ToastNotifications.wrn": "33ff76b2a0a129ff896baea8979b4be97f7ec4471a92a2da970403a5c46e8b03", "packages/ui/components/Toaster.wrn": "7481ecddde9ed5bf1f448d45a78da7ee0009f84963814bf681735db4e5ab75f9", - "packages/ui/components/ToggleCount.wrn": "70a75b2bdcc89103f8ca300ee6d21cd61baf8c6a4aeade9b68d1dc529f77064b", + "packages/ui/components/ToggleCount.wrn": "17710fb37fc6953911acec4e80cd95c1202836e9b958ae4ce34c9a9639edae95", "packages/ui/components/TogglePassword.wrn": "405a85cbfa3d0ff0185b52d2805fba88497a5f25501f01b2438ed3a28591a38d", "packages/ui/components/Tooltip.wrn": "e6c8c14a75062d04a2e64245df1a40e67785240573c73bae4b0b32e76ef822bc", "packages/ui/components/TreeView.wrn": "1791a10135595b17b5c746af9e0476e8520d516ba46d64c79dc4efe0af2b500d", "packages/ui/components/Typography.wrn": "1b4d1b91dee0ff522d27c566becc3bc49bd9ec979ae6334e6a15fca5d88eccac", "packages/ui/components/WysiwygEditor.wrn": "636e60b9f9be7a5807cca7ad20e0b0f370ee1ec6c1ecba5d0a577726cc86bc98", - "packages/ui/components/alert.wrn": "a6020b3fbf02481f76cea4cfa563a7165c7ccc2920492f6f6b79e98cbf2cf0fa", + "packages/ui/components/alert.wrn": "3f4c4c90c2b5a00a13d0fa1e7e48e4898869dd27a50ff412a51d7bcc87a0baac", "packages/ui/components/avatar.wrn": "571b70790aff28a5b7d6adbf0196e77ece1ce153a95b384148cb2bb5f74ab2e9", "packages/ui/components/button.wrn": "647af3918142fd9151e46555345532980633cc3677ec26d0940c253ea789bc31", "packages/ui/components/carousel.wrn": "37bdde82de59e90cf02c9fed0ee11f5ddd2979246932701dad9412c4fd358a6a", diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 2a32df5d..6d79fb18 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -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. diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index ab6cb703..fb301ad8 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -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)); }); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 7d782584..336545d1 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -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( + `
` + + `{saved}` + + `
` + + `
` + + `` + + `
`, + ); + + const target = win.document.querySelector("[data-wrn-events]") as unknown as { + __wrnexusOutputHandlers?: Record 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 diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md index a3d1a4da..7b47b446 100644 --- a/packages/ui/COMPONENTS.md +++ b/packages/ui/COMPONENTS.md @@ -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 diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json index de0940ab..cb9b51af 100644 --- a/packages/ui/component-reference.json +++ b/packages/ui/component-reference.json @@ -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" }, { diff --git a/packages/ui/components/Accordion.wrn b/packages/ui/components/Accordion.wrn index b75e6df9..5973f986 100644 --- a/packages/ui/components/Accordion.wrn +++ b/packages/ui/components/Accordion.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) { diff --git a/packages/ui/components/AnnouncementBar.wrn b/packages/ui/components/AnnouncementBar.wrn index 9a06236b..ddd451c6 100644 --- a/packages/ui/components/AnnouncementBar.wrn +++ b/packages/ui/components/AnnouncementBar.wrn @@ -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 })' > {#if homeIcon === "icon-[lucide--house]"} @@ -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} diff --git a/packages/ui/components/Footer.wrn b/packages/ui/components/Footer.wrn index c9323124..63665f8c 100644 --- a/packages/ui/components/Footer.wrn +++ b/packages/ui/components/Footer.wrn @@ -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} {#if item.icon} {#if child.icon} {#if item.icon} diff --git a/packages/ui/components/Map.wrn b/packages/ui/components/Map.wrn index 792efa20..57fe068a 100644 --- a/packages/ui/components/Map.wrn +++ b/packages/ui/components/Map.wrn @@ -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)' > @@ -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" })' > @@ -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" })' > diff --git a/packages/ui/components/Marquee.wrn b/packages/ui/components/Marquee.wrn index aed8d3ea..1f61a96a 100644 --- a/packages/ui/components/Marquee.wrn +++ b/packages/ui/components/Marquee.wrn @@ -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 {
@@ -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({})' > diff --git a/packages/ui/components/SearchBox.wrn b/packages/ui/components/SearchBox.wrn index ed614f3e..ca935db8 100644 --- a/packages/ui/components/SearchBox.wrn +++ b/packages/ui/components/SearchBox.wrn @@ -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 })' >
{/if} @@ -157,7 +117,7 @@ component Card { {item.actionLabel || "Learn more"} @@ -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 })' /> @@ -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 })' /> {/if} @@ -323,7 +283,7 @@ component Card { {actionLabel} @@ -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 })' /> {/if} diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index cb6aae99..157fe17e 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -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.(). 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([]); +});