diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index 10ecb49f..7c18a8a0 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -89,7 +89,7 @@ "packages/ui/components/TogglePassword.wrn": "6bd4d9fa82b0c8e80178430d5a42b1b3ec9725fb62be158585f68af9338c901a", "packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b", "packages/ui/components/Typography.wrn": "9676af57f2a937b21f45512e0a290b5bee4a16267fbf404850297d29645d88b5", - "packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5", + "packages/ui/components/WysiwygEditor.wrn": "a858ebd4e6e2672463ba57d4d049176ca1cc98dac32ab3df3638d6a9cdcd0f2d", "packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d", "packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc", "packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1", diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 5b8c915b..4a50ed30 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -2724,9 +2724,17 @@ export const REACTIVE_RUNTIME = String.raw` ); } }; - node.addEventListener(outName, domListener); + /* + * focus and blur do not bubble, so a bubble-phase listener on the + * component root never hears its own input or button take focus -- + * the binding silently did nothing. Capture reaches the descendant. + * The same flag must be passed to removeEventListener or the + * listener outlives the component. + */ + var capture = outName === "focus" || outName === "blur"; + node.addEventListener(outName, domListener, capture); cleanupCallbacks.push(function () { - node.removeEventListener(outName, domListener); + node.removeEventListener(outName, domListener, capture); }); }); }); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index b68ad915..d8d8fd74 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -1781,3 +1781,57 @@ test("a class binding inside data-for follows state the row never mentions", () expect(items()[0]?.classList.contains("is-active")).toBe(false); expect(items()[1]?.classList.contains("is-active")).toBe(true); }); + +test("a @focus binding on a component tag fires for a focusable element inside it", () => { + /* + * focus and blur do NOT bubble. The runtime bound every output-named DOM + * fallback listener in the bubble phase, so a parent writing @focus on a + * component tag never heard the component's own input or button take focus: + * the event fired on the descendant and stopped there. Nothing errored -- + * the binding simply did nothing, which is why the library still declares + * focus/blur outputs on components that could never deliver them + * (button, TextLink, WysiwygEditor). + * + * The ui ratchet for "outputs nothing emits" excludes natively-named + * outputs on the stated grounds that a native event reaches the root + * anyway. That reasoning holds for click and change, which bubble, and + * fails for focus and blur, which do not. + */ + const win = mount( + `