fix(csr,ui): deliver component outputs to parent bindings
Quality / quality (ubuntu-latest) (push) Failing after 13m39s
Quality / quality (windows-latest) (push) Canceled after 0s

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:
2026-08-09 01:40:39 +05:30
co-authored by Claude Opus 5
parent 84dc4e06a2
commit 5e65627305
24 changed files with 384 additions and 224 deletions
+48 -25
View File
@@ -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 - The semantic token spread sat _after_ the primary and secondary palette
entries, so it would have overridden them. It now sits before. 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.<name>()`. 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 ## Outstanding, with numbers
**32 outputs across 16 components have no emitter of any kind.** This is the **22 outputs across 11 components have no emitter of any kind** (was 32 across
bug LayoutSplitter had: the component advertises an output, a caller binds to 16; see the correction above). The component advertises an output, a caller
it, and nothing ever fires. Native event names are excluded — the runtime binds binds to it, and nothing ever fires. Native event names are excluded — the
a DOM-listener fallback on component tags, so `click` and `input` do arrive. runtime binds a DOM-listener fallback on component tags, so `click` and `input`
do arrive.
| Component | Dead outputs | | Component | Dead outputs |
| ------------------------------------ | ----------------------------------------- | | ------------------- | ----------------------------------------- |
| FileUpload | upload, progress, success, cancel, remove | | FileUpload | upload, progress, success, cancel, remove |
| ToastNotifications | add, dismiss, clear, action | | ToastNotifications | add, dismiss, clear, action |
| AdvancedDatePicker | open, close, clear | | AdvancedDatePicker | open, close, clear |
| Card | action, navigate, dismiss | | AdvancedRangeSlider | start, end |
| Accordion | open, close | | Chart | dataPointClick, legendToggle |
| AdvancedRangeSlider | start, end | | Confetti | start, complete |
| Chart | dataPointClick, legendToggle | | TreeView | expand, collapse |
| Confetti | start, complete | | Toast | dismiss |
| TreeView | expand, collapse | | CopyMarkup | success |
| AnnouncementBar, Badge, Toast, alert | dismiss |
| AvatarGroup | overflow |
| CopyMarkup | success |
| Footer | action |
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 **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. 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 ## 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 - **A component tag nested inside another component slot is dropped**, leaving
only its children. only its children.
- **Component props and slot content render once** and do not track page state, - **Component props and slot content render once** and do not track page state,
+16 -16
View File
@@ -2,21 +2,21 @@
"schemaVersion": 1, "schemaVersion": 1,
"releaseLine": "0.8", "releaseLine": "0.8",
"artifacts": { "artifacts": {
"packages/ui/components/Accordion.wrn": "e74433d405767edd50e58237ac3986e08e14a893450d712f69679c8361485734", "packages/ui/components/Accordion.wrn": "2b2e756a2197052bbcaf7499d01ec4b68b53a3acaf4524b7ca6424b71ba2e9a3",
"packages/ui/components/AdvancedDatePicker.wrn": "b9b5b08e9464544837a5400bbf9a2cb25724fac037fe196a219b42e5841ad672", "packages/ui/components/AdvancedDatePicker.wrn": "b9b5b08e9464544837a5400bbf9a2cb25724fac037fe196a219b42e5841ad672",
"packages/ui/components/AdvancedRangeSlider.wrn": "e78b5cf825503d0cd306c6e490d71317ee3db1844882d13b500df1f7f1c408e1", "packages/ui/components/AdvancedRangeSlider.wrn": "e78b5cf825503d0cd306c6e490d71317ee3db1844882d13b500df1f7f1c408e1",
"packages/ui/components/AdvancedSelect.wrn": "29d43f8019a3e0e98af7cf12440f41523665535a39a973441b4faea78e67114a", "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/AuthForm.wrn": "496d240090269e69679e01cda3b071c012ec4e925a7c01ae883d25e359e381e1",
"packages/ui/components/AuthSplitLayout.wrn": "72b82495dd14d40a2d594ccdcf3adc925d1567c155750fb1072347b8845aab7b", "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/BackToTop.wrn": "b12c56ec8b7aabad68cbdc47f4b3237e2c21a61398f0aa51f9add6229b7357cd",
"packages/ui/components/Badge.wrn": "6b55b4d85100d605cf168857aec1dc57da62c1f6a0a5cd6dd877023f24b189cc", "packages/ui/components/Badge.wrn": "e96cdcf182ff9834509464bb2fa4c0b041748d3f7671aa46ab489bae6f3d98ae",
"packages/ui/components/Blockquote.wrn": "6016dd4450de7cbf5c3cb413599baa2e502321e8eedef54439a0df6d0aae8bcb", "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/ButtonGroup.wrn": "0d97fbeed531d1d8ef4b59531923b47b34100d0de71d7bf524b02c668920b3d8",
"packages/ui/components/CTASection.wrn": "2d2241d4f1018cfd2f8ea1ddd5fa8c8fcb8d1bbb2d4e4498cb9f39ff47a3801c", "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/Chart.wrn": "841def6b024720bd24898975a1434a1cb39c89e1272d42db431d35d27c98c553",
"packages/ui/components/ChatBubble.wrn": "0da2bc86830ac712481559bd2fe7214d26f25101f2e8c2e4629e25c004388b52", "packages/ui/components/ChatBubble.wrn": "0da2bc86830ac712481559bd2fe7214d26f25101f2e8c2e4629e25c004388b52",
"packages/ui/components/Checkbox.wrn": "45e6046e3ad4acb1c1805b51fd5bf0b95b346d3d53973ededc5e43ea2fd91f70", "packages/ui/components/Checkbox.wrn": "45e6046e3ad4acb1c1805b51fd5bf0b95b346d3d53973ededc5e43ea2fd91f70",
@@ -44,23 +44,23 @@
"packages/ui/components/FileInput.wrn": "8b63811deb90a03763620bedf0d5d3c7d05a20b8ae34292eaface9b756b32ecd", "packages/ui/components/FileInput.wrn": "8b63811deb90a03763620bedf0d5d3c7d05a20b8ae34292eaface9b756b32ecd",
"packages/ui/components/FileUpload.wrn": "3346d8978e134c3a1e6bc742201a3fbb0cffb89da40da6fcd856a2adbb971393", "packages/ui/components/FileUpload.wrn": "3346d8978e134c3a1e6bc742201a3fbb0cffb89da40da6fcd856a2adbb971393",
"packages/ui/components/FileUploadProgress.wrn": "83bcc8558c70fbe0e0670642f65998abd222c235f1031be46bd7c65e6b5c5154", "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/Grid.wrn": "d089d8657298b4d33993ba4bf7b581f816c65db513d8e48cf1fdd18da19e05a8",
"packages/ui/components/Hero.wrn": "e4e39dcd59c273192c7e4136f6583279e20b0956bcba4cfbc3f6e01a1fade152", "packages/ui/components/Hero.wrn": "e4e39dcd59c273192c7e4136f6583279e20b0956bcba4cfbc3f6e01a1fade152",
"packages/ui/components/HeroActions.wrn": "c5720efe2ce7f6c114eb2ea4ccc3e8102a1099078ba23fd13770eddeb6aec640", "packages/ui/components/HeroActions.wrn": "c5720efe2ce7f6c114eb2ea4ccc3e8102a1099078ba23fd13770eddeb6aec640",
"packages/ui/components/Image.wrn": "8113441bf90ab0d1d8618d477125ad339021f39e853db89d656c72c492e7a20b", "packages/ui/components/Image.wrn": "8113441bf90ab0d1d8618d477125ad339021f39e853db89d656c72c492e7a20b",
"packages/ui/components/Input.wrn": "9ad394eed60f1b05bbd2ccd50dc2de7b78245c52c87afd46055d772c38b7b0c9", "packages/ui/components/Input.wrn": "9ad394eed60f1b05bbd2ccd50dc2de7b78245c52c87afd46055d772c38b7b0c9",
"packages/ui/components/InputGroup.wrn": "0e23ea541a60e893c9d9e6f95113911ee459468d8d6105bf03aeb55395359123", "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/Kbd.wrn": "5afaabbb15902bd8fb82cb4155cf6cbfff55600927b54d564d27e2dd8b752386",
"packages/ui/components/LayoutSplitter.wrn": "677e2dba6dbf385d8aef14829d785137ea26272a62affcd1839fa74f5145716d", "packages/ui/components/LayoutSplitter.wrn": "6bd6812e364420ed5d3439862116c58d0b011b298679005795bdccdbb7455066",
"packages/ui/components/LegendIndicator.wrn": "6a7607b27a17196f899132eb952203a1073e4077365a94935f485e700cbac665", "packages/ui/components/LegendIndicator.wrn": "6a7607b27a17196f899132eb952203a1073e4077365a94935f485e700cbac665",
"packages/ui/components/Link.wrn": "b986abcc66bc0b1c6296f3ff4e560812e35d89cc680d5251d7a3883e68c1d053", "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/ListGroup.wrn": "37e2ec61ceab20ed020a834da03208c493702b41abcf586816e29f2e50d8ee11",
"packages/ui/components/Map.wrn": "7b8a0d5412a464fd7cab8977d816542c506d8c5dab8230a3c984c2caee407c91", "packages/ui/components/Map.wrn": "990656db93e1acd73a301a4addaf353afa14430805366d2e5f124b831044afbe",
"packages/ui/components/MarketingSectionHeader.wrn": "7d6ffcc01a9331474475ea57ca58598be757abd9428b66bdfaf6f3603c5b145b", "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/MegaMenu.wrn": "8a9a9b348ca1c5e182914ff55d981a91f47a49c52ef5e5efd8e4cb6e56361a43",
"packages/ui/components/MetricCard.wrn": "6451182739298691908f68258c0250cce2a78b0dc27c97115579ece30d7d9f92", "packages/ui/components/MetricCard.wrn": "6451182739298691908f68258c0250cce2a78b0dc27c97115579ece30d7d9f92",
"packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364", "packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364",
@@ -78,7 +78,7 @@
"packages/ui/components/RangeSlider.wrn": "21eb7a5df0ee2cb5e78f628eb920265998eb9f1a4933d4e5c57db0323fd66b41", "packages/ui/components/RangeSlider.wrn": "21eb7a5df0ee2cb5e78f628eb920265998eb9f1a4933d4e5c57db0323fd66b41",
"packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb", "packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb",
"packages/ui/components/Scrollspy.wrn": "76d1c17580c8d460f1222ce97585bdcac9779141f2f30d5460c84ff9f75413d8", "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/Section.wrn": "33eb8a82a2102229c2b1f64cec497e9e8e70b67adab8dbe396be860fccbb699c",
"packages/ui/components/SectionHeader.wrn": "8eecf2ea7a93139512260e3d46ca2a1afeb341b8bd46b7c30baa6e2c75e25d10", "packages/ui/components/SectionHeader.wrn": "8eecf2ea7a93139512260e3d46ca2a1afeb341b8bd46b7c30baa6e2c75e25d10",
"packages/ui/components/Select.wrn": "7f046bb11b7c2470dae26d91a4b2261c66a40ae054d0e04c69b6748912d1e204", "packages/ui/components/Select.wrn": "7f046bb11b7c2470dae26d91a4b2261c66a40ae054d0e04c69b6748912d1e204",
@@ -93,17 +93,17 @@
"packages/ui/components/TextLink.wrn": "30782039293eb36d63b7b3a4f32a71a47177a3a68c7e90184be7cf7b4385eb19", "packages/ui/components/TextLink.wrn": "30782039293eb36d63b7b3a4f32a71a47177a3a68c7e90184be7cf7b4385eb19",
"packages/ui/components/Textarea.wrn": "ddf0b4f124b2cf0c0ab3d820d3ac0085f7c20466e977231949be264cd0cee8cf", "packages/ui/components/Textarea.wrn": "ddf0b4f124b2cf0c0ab3d820d3ac0085f7c20466e977231949be264cd0cee8cf",
"packages/ui/components/TimePicker.wrn": "2e8e7a90f6b6069a07e7ffd2725ba1e1031e84d55a4f1254025befbb314fa695", "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/Toast.wrn": "f37c584d1c1a66401deb53d915c8ee0c70aaf7baa1d3c297fa74c5bdb914dd1d",
"packages/ui/components/ToastNotifications.wrn": "33ff76b2a0a129ff896baea8979b4be97f7ec4471a92a2da970403a5c46e8b03", "packages/ui/components/ToastNotifications.wrn": "33ff76b2a0a129ff896baea8979b4be97f7ec4471a92a2da970403a5c46e8b03",
"packages/ui/components/Toaster.wrn": "7481ecddde9ed5bf1f448d45a78da7ee0009f84963814bf681735db4e5ab75f9", "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/TogglePassword.wrn": "405a85cbfa3d0ff0185b52d2805fba88497a5f25501f01b2438ed3a28591a38d",
"packages/ui/components/Tooltip.wrn": "e6c8c14a75062d04a2e64245df1a40e67785240573c73bae4b0b32e76ef822bc", "packages/ui/components/Tooltip.wrn": "e6c8c14a75062d04a2e64245df1a40e67785240573c73bae4b0b32e76ef822bc",
"packages/ui/components/TreeView.wrn": "1791a10135595b17b5c746af9e0476e8520d516ba46d64c79dc4efe0af2b500d", "packages/ui/components/TreeView.wrn": "1791a10135595b17b5c746af9e0476e8520d516ba46d64c79dc4efe0af2b500d",
"packages/ui/components/Typography.wrn": "1b4d1b91dee0ff522d27c566becc3bc49bd9ec979ae6334e6a15fca5d88eccac", "packages/ui/components/Typography.wrn": "1b4d1b91dee0ff522d27c566becc3bc49bd9ec979ae6334e6a15fca5d88eccac",
"packages/ui/components/WysiwygEditor.wrn": "636e60b9f9be7a5807cca7ad20e0b0f370ee1ec6c1ecba5d0a577726cc86bc98", "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/avatar.wrn": "571b70790aff28a5b7d6adbf0196e77ece1ce153a95b384148cb2bb5f74ab2e9",
"packages/ui/components/button.wrn": "647af3918142fd9151e46555345532980633cc3677ec26d0940c253ea789bc31", "packages/ui/components/button.wrn": "647af3918142fd9151e46555345532980633cc3677ec26d0940c253ea789bc31",
"packages/ui/components/carousel.wrn": "37bdde82de59e90cf02c9fed0ee11f5ddd2979246932701dad9412c4fd358a6a", "packages/ui/components/carousel.wrn": "37bdde82de59e90cf02c9fed0ee11f5ddd2979246932701dad9412c4fd358a6a",
+27 -1
View File
@@ -2033,11 +2033,37 @@ const MIGRATIONS: Migration[] = [
version: "0.8.6", version: "0.8.6",
id: "0.8.6-navigation-and-layout-groups", id: "0.8.6-navigation-and-layout-groups",
description: 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() { apply() {
// Source changes no codemod can make safely, so they are listed rather // Source changes no codemod can make safely, so they are listed rather
// than attempted. // 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 // Tabs replaced its raw CustomEvents with declared outputs. Code
// listening for the old change and select events on the element must // listening for the old change and select events on the element must
// move to the @change and @select bindings. // move to the @change and @select bindings.
+19
View File
@@ -3927,6 +3927,25 @@ export const REACTIVE_RUNTIME = String.raw`
if (!root || !name) return undefined; if (!root || !name) return undefined;
var registry = root.__wrnexusOutputHandlers; var registry = root.__wrnexusOutputHandlers;
var handlers = registry && registry[name]; 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) { if (handlers && handlers.size) {
var values = []; var values = [];
handlers.forEach(function (handler) { values.push(handler(payload)); }); handlers.forEach(function (handler) { values.push(handler(payload)); });
+32
View File
@@ -631,6 +631,38 @@ test("component output handlers run in the parent scope", () => {
expect(win.document.querySelector("#out")?.textContent).toBe("yes"); 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 ---------------- // --- browser globals + regex literals in client expressions ----------------
// Client functions and inline handlers are interpreted by the runtime's own // 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 // eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
+5 -5
View File
@@ -229,7 +229,7 @@ Present structured responsive linked or status items with icons, descriptions, a
- Mount: `data-component="List"` - Mount: `data-component="List"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### ListGroup ### ListGroup
@@ -247,7 +247,7 @@ Continuously present responsive labels, partners, notices, or capabilities with
- Mount: `data-component="Marquee"` - Mount: `data-component="Marquee"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - 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 ### Progress
@@ -301,7 +301,7 @@ Present responsive chronological activity, milestones, or workflow status with r
- Mount: `data-component="Timeline"` - Mount: `data-component="Timeline"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - Slots: `default`
- Outputs: None - Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Toast ### Toast
@@ -469,7 +469,7 @@ Provide an accessible responsive search field with labels, validation states, si
- Mount: `data-component="SearchBox"` - 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 = ""` - 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 - 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 ### Select
@@ -588,7 +588,7 @@ Present responsive location information and markers with map-ready metadata and
- Mount: `data-component="Map"` - Mount: `data-component="Map"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""` - Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default` - 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 ### ToastNotifications
+51 -10
View File
@@ -8064,8 +8064,13 @@
} }
], ],
"slots": ["default"], "slots": ["default"],
"outputs": [], "outputs": [
"events": [], {
"name": "select",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["select"],
"source": "components/List.wrn" "source": "components/List.wrn"
}, },
{ {
@@ -8195,8 +8200,21 @@
} }
], ],
"slots": ["default"], "slots": ["default"],
"outputs": [], "outputs": [
"events": [], {
"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" "source": "components/Map.wrn"
}, },
{ {
@@ -8352,8 +8370,17 @@
} }
], ],
"slots": ["default"], "slots": ["default"],
"outputs": [], "outputs": [
"events": [], {
"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" "source": "components/Marquee.wrn"
}, },
{ {
@@ -10911,8 +10938,17 @@
} }
], ],
"slots": [], "slots": [],
"outputs": [], "outputs": [
"events": [], {
"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" "source": "components/SearchBox.wrn"
}, },
{ {
@@ -13034,8 +13070,13 @@
} }
], ],
"slots": ["default"], "slots": ["default"],
"outputs": [], "outputs": [
"events": [], {
"name": "select",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["select"],
"source": "components/Timeline.wrn" "source": "components/Timeline.wrn"
}, },
{ {
+13 -11
View File
@@ -49,22 +49,24 @@ component Accordion {
return multiple || alwaysOpen return multiple || alwaysOpen
} }
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) { // Named rather than computed: an output is resolved as a property name, so
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]") // output[eventName] would not reach a parent binding.
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, payload) {
if (!root) { payload = {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "Accordion", component: "Accordion",
value: value, value: value,
item: item, item: item,
open: isOpen(value), open: isOpen(value),
openValues: openValues 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) { client function toggleItem(sourceEvent, value, item, wasOpen) {
+1 -1
View File
@@ -127,7 +127,7 @@ badge: string = ""
aria-label='{dismissLabel}' aria-label='{dismissLabel}'
title='{dismissLabel}' title='{dismissLabel}'
class="wire-announcement__dismiss" 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 <span
class="icon-[lucide--x]" class="icon-[lucide--x]"
+2 -6
View File
@@ -30,17 +30,13 @@ component AvatarGroup {
return items.slice(Number(maxVisible)) return items.slice(Number(maxVisible))
} }
client function toggleOverflow(sourceEvent, root, customEvent) { client function toggleOverflow() {
overflowOpen = !overflowOpen overflowOpen = !overflowOpen
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]") output.overflow({
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("overflow", true, false, {
component: "AvatarGroup", component: "AvatarGroup",
open: overflowOpen, open: overflowOpen,
hiddenCount: hiddenMembers().length hiddenCount: hiddenMembers().length
}) })
root.dispatchEvent(customEvent)
} }
} }
+2 -2
View File
@@ -34,7 +34,7 @@ label: string = "Breadcrumb"
<a <a
href='{homeHref || "/"}' href='{homeHref || "/"}'
class="wire-breadcrumb__link wire-breadcrumb__home" 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]"} {#if homeIcon === "icon-[lucide--house]"}
<span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span> <span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span>
@@ -72,7 +72,7 @@ label: string = "Breadcrumb"
target='{item.target || ""}' target='{item.target || ""}'
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}' rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
class="wire-breadcrumb__link" 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} {#if item.icon}
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span> <span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
+3 -3
View File
@@ -79,7 +79,7 @@ component Footer {
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}' rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
aria-current='{child.active ? "page" : ""}' aria-current='{child.active ? "page" : ""}'
class="wire-footer__link" 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 child.icon}
<span <span
@@ -127,7 +127,7 @@ component Footer {
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}' rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
aria-current='{item.active ? "page" : ""}' aria-current='{item.active ? "page" : ""}'
class="wire-footer__link" 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} {#if item.icon}
<span <span
@@ -199,7 +199,7 @@ component Footer {
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}' rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
aria-current='{child.active ? "page" : ""}' aria-current='{child.active ? "page" : ""}'
class="wire-footer__link" 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 child.icon}
<span <span
+16 -16
View File
@@ -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( client function dispatchInputNumberEvent(
sourceEvent, sourceEvent,
eventName, eventName,
action, action,
previousValue, previousValue,
root, payload
customEvent
) { ) {
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]") payload = {
if (!root && sourceEvent.target) {
root = sourceEvent.target.closest("[data-wrn-input-number]")
}
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "InputNumber", component: "InputNumber",
name: name, name: name,
value: currentValue, value: currentValue,
@@ -261,8 +252,17 @@ component InputNumber {
max: hasMax() ? Number(max) : null, max: hasMax() ? Number(max) : null,
step: normalizedStep(), step: normalizedStep(),
valid: !isInvalid() 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) { client function applyControlValue(nextValue, action, sourceEvent, previousValue) {
+4 -3
View File
@@ -18,9 +18,10 @@
// and silently swallows the rule that follows it. // and silently swallows the rule that follows it.
component LayoutSplitter { component LayoutSplitter {
outputs { outputs {
// Not named resize. An output named after a native DOM event never reaches // Named sizeChange rather than resize so it cannot be confused with the
// a parent binding: the component emits it, but @resize on the tag is // native window event a caller may already be listening for. An earlier
// never invoked. sizeChange is unambiguous and does arrive. // 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 }) sizeChange(payload: { size: number })
} }
+5 -1
View File
@@ -1,4 +1,8 @@
component List { component List {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -52,7 +56,7 @@ component List {
class:py-2.5='size === "sm"' class:py-2.5='size === "sm"'
class:px-5='size === "lg"' class:px-5='size === "lg"'
class:py-4='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} {#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)]"> <span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
+11 -3
View File
@@ -1,4 +1,12 @@
component Map { 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 { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -54,7 +62,7 @@ component Map {
aria-label='{item.label || item.title || "Map marker"}' 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)]" 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)}%;' 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> <span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
</button> </button>
@@ -66,7 +74,7 @@ component Map {
type="button" type="button"
aria-label="Zoom in" 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)]" 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> <span class="icon-[lucide--plus] size-4" aria-hidden="true"></span>
</button> </button>
@@ -74,7 +82,7 @@ component Map {
type="button" type="button"
aria-label="Zoom out" 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)]" 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> <span class="icon-[lucide--minus] size-4" aria-hidden="true"></span>
</button> </button>
+9 -3
View File
@@ -1,4 +1,10 @@
component Marquee { 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 { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -27,8 +33,8 @@ component Marquee {
<div <div
class="group relative min-w-0 flex-1 overflow-hidden" class="group relative min-w-0 flex-1 overflow-hidden"
@mouseenter='paused = true; event.currentTarget.dispatchEvent(new CustomEvent("pause", { bubbles: true }))' @mouseenter='paused = true; output.pause({})'
@mouseleave='paused = false; event.currentTarget.dispatchEvent(new CustomEvent("resume", { bubbles: true }))' @mouseleave='paused = false; output.resume({})'
@focusin='paused = true' @focusin='paused = true'
@focusout='paused = false' @focusout='paused = false'
> >
@@ -77,7 +83,7 @@ component Marquee {
type="button" type="button"
aria-label='{paused ? "Resume announcements" : "Pause announcements"}' 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)]" 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--pause] size-4" data-show='!paused' aria-hidden="true"></span>
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span> <span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
+7 -2
View File
@@ -1,4 +1,9 @@
component SearchBox { 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 { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -22,7 +27,7 @@ component SearchBox {
data-ui-component="SearchBox" data-ui-component="SearchBox"
role="search" role="search"
class='w-full {class}' 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 <label
class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]" class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]"
@@ -61,7 +66,7 @@ component SearchBox {
aria-label="Clear search" aria-label="Clear search"
data-show='query.length > 0 && !disabled' 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)]" 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> <span class="icon-[lucide--x] size-4" aria-hidden="true"></span>
</button> </button>
+5 -1
View File
@@ -1,4 +1,8 @@
component Timeline { component Timeline {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size: string = "default" size: string = "default"
color: string = "primary" color: string = "primary"
@@ -30,7 +34,7 @@ component Timeline {
{#each items as item, index} {#each items as item, index}
<li <li
class="relative pb-8 pl-8 last:pb-0" 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 <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" 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"
+10 -27
View File
@@ -57,34 +57,17 @@ component ToggleCount {
: emptyValue : emptyValue
} }
client function dispatchToggleEvent(sourceEvent, previousValue, root, customEvent) { client function dispatchToggleEvent(sourceEvent, previousValue, payload) {
root = sourceEvent.currentTarget.closest("[data-wrn-toggle-count]") payload = {
component: "ToggleCount",
if (!root) { name: name,
return value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
} }
output.change(payload)
customEvent = document.createEvent("CustomEvent") output.toggle(payload)
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)
} }
client function selectValue(sourceEvent, nextValue, previousValue) { client function selectValue(sourceEvent, nextValue, previousValue) {
+12 -11
View File
@@ -35,22 +35,23 @@ component Alert {
state visible: boolean = true state visible: boolean = true
functions { functions {
client function dispatchAlertEvent(sourceEvent, eventName, action, root, customEvent) { // The output name has to be written out rather than computed: outputs are
root = sourceEvent.currentTarget.closest("[data-wrn-alert]") // 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.
if (!root) { client function dispatchAlertEvent(sourceEvent, eventName, action, payload) {
return payload = {
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
component: "Alert", component: "Alert",
title: title, title: title,
color: color, color: color,
variant: variant, variant: variant,
action: action action: action
}) }
root.dispatchEvent(customEvent)
if (eventName === "dismiss") {
output.dismiss(payload)
} else {
output.action(payload)
}
} }
client function dismissAlert(sourceEvent) { client function dismissAlert(sourceEvent) {
+6 -8
View File
@@ -35,15 +35,13 @@ component Badge {
state visible: boolean = true state visible: boolean = true
functions { 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 visible = false
root = sourceEvent.currentTarget.closest("[data-wrn-badge]") output.dismiss({ component: "Badge", label: label })
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("dismiss", true, false, {
component: "Badge",
label: label
})
root.dispatchEvent(customEvent)
} }
} }
+21 -61
View File
@@ -45,67 +45,27 @@ component Card {
state dismissed: boolean = false state dismissed: boolean = false
functions { functions {
client function dispatchCardNavigation(sourceEvent, item, index, root, customEvent) { // Outputs must go through output.*; a CustomEvent dispatched on the root
root = sourceEvent.currentTarget.closest("[data-wrn-card]") // bubbles past every parent @binding without being seen, because the
// runtime keeps parent handlers in a registry only the output proxy reads.
if (!root) { client function dispatchCardNavigation(sourceEvent, item, index) {
return output.navigate({ component: "Card", item: item, index: index })
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("navigate", true, false, {
component: "Card",
item: item,
index: index
})
root.dispatchEvent(customEvent)
} }
client function dispatchCardNavigationValue(sourceEvent, root, customEvent) { client function dispatchCardNavigationValue(sourceEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]") output.navigate({
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("navigate", true, false, {
component: "Card", component: "Card",
value: sourceEvent.currentTarget.value value: sourceEvent.currentTarget.value
}) })
root.dispatchEvent(customEvent)
} }
client function dispatchCardHeaderAction(sourceEvent, action, index, root, customEvent) { client function dispatchCardHeaderAction(sourceEvent, action, index) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]") output.action({ component: "Card", action: action, index: index })
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("action", true, false, {
component: "Card",
action: action,
index: index
})
root.dispatchEvent(customEvent)
} }
client function dismissCard(sourceEvent, root, customEvent) { client function dismissCard() {
dismissed = true dismissed = true
root = sourceEvent.currentTarget.closest("[data-wrn-card]") output.dismiss({ component: "Card", title: title })
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("dismiss", true, false, {
component: "Card",
title: title
})
root.dispatchEvent(customEvent)
} }
} }
@@ -137,8 +97,8 @@ component Card {
alt='{item.imageAlt || item.alt || ""}' alt='{item.imageAlt || item.alt || ""}'
loading='{item.loading || "lazy"}' loading='{item.loading || "lazy"}'
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { item: item, index: itemIndex } }))' @load='output.load({ item: item, index: itemIndex })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { item: item, index: itemIndex } }))' @error='output.error({ item: item, index: itemIndex })'
/> />
</div> </div>
{/if} {/if}
@@ -157,7 +117,7 @@ component Card {
<a <a
href='{item.actionHref || item.href || "#"}' href='{item.actionHref || item.href || "#"}'
class="wire-next__card-action" 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>{item.actionLabel || "Learn more"}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span> <span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
@@ -180,8 +140,8 @@ component Card {
alt='{imageAlt}' alt='{imageAlt}'
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))' @load='output.load({ src: imageSrc })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))' @error='output.error({ src: imageSrc })'
/> />
<div class="wire-next__card-overlay-shade" aria-hidden="true"></div> <div class="wire-next__card-overlay-shade" aria-hidden="true"></div>
</div> </div>
@@ -194,8 +154,8 @@ component Card {
alt='{imageAlt}' alt='{imageAlt}'
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))' @load='output.load({ src: imageSrc })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))' @error='output.error({ src: imageSrc })'
/> />
</div> </div>
{/if} {/if}
@@ -323,7 +283,7 @@ component Card {
<a <a
href='{actionHref || "#"}' href='{actionHref || "#"}'
class="wire-next__card-action" 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>{actionLabel}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span> <span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
@@ -340,8 +300,8 @@ component Card {
alt='{imageAlt}' alt='{imageAlt}'
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))' @load='output.load({ src: imageSrc })'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))' @error='output.error({ src: imageSrc })'
/> />
</div> </div>
{/if} {/if}
+59 -8
View File
@@ -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", () => { test("no component gains an output that nothing ever emits", () => {
/* /*
* LayoutSplitter declared resizeStart, resize and resizeEnd with no pointer * An output only reaches a parent @binding when the component calls
* handling at all: a caller wired up @resize and received nothing, for ever, * output.<name>(). The runtime keeps parent handlers in a registry that only
* with no error. The same shape survives in other components, so this pins * invokeComponentOutput reads, so a component that instead dispatches its own
* the count rather than letting it grow while the rest are rebuilt. * 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 * Native event names are excluded, and that exclusion is real rather than
* on component tags, so declaring click or input as an output does reach a * assumed: invokeComponentOutput falls back to dispatchComponentEvent when no
* parent binding through bubbling. * 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([ const native = new Set([
"click", "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 * A ceiling, not a target. It only ever moves down: rebuilding one of these
* components should tighten it. * components should tighten it.
*/ */
expect(offenders.length).toBeLessThanOrEqual(32); expect(offenders.length).toBeLessThanOrEqual(22);
expect(offenders).not.toContain("LayoutSplitter.sizeChange"); expect(offenders).not.toContain("LayoutSplitter.sizeChange");
expect(offenders).not.toContain("CustomScrollbar.scroll"); 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([]);
});