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( + `
` + + `{seen}` + + `
` + + `
` + + `` + + `
`, + ); + + const inner = win.document.querySelector("#inner") as unknown as { + dispatchEvent: (event: unknown) => boolean; + }; + // A real focus event: fires on the descendant, does not bubble. + const FocusEventCtor = (win as unknown as { Event: typeof Event }).Event; + inner.dispatchEvent(new FocusEventCtor("focus", { bubbles: false })); + + expect(win.document.querySelector("#out")?.textContent).toBe("focused"); +}); + +test("a @change binding still fires for a bubbling event inside a component", () => { + // The companion case: change DOES bubble, and must keep working unchanged. + const win = mount( + `
` + + `{seen}` + + `
` + + `
` + + `` + + `
`, + ); + + const inner = win.document.querySelector("#inner") as unknown as { + dispatchEvent: (event: unknown) => boolean; + }; + const EventCtor = (win as unknown as { Event: typeof Event }).Event; + inner.dispatchEvent(new EventCtor("change", { bubbles: true })); + + expect(win.document.querySelector("#out")?.textContent).toBe("changed"); +}); diff --git a/packages/ui/components/WysiwygEditor.wrn b/packages/ui/components/WysiwygEditor.wrn index 67a5f358..e231b96c 100644 --- a/packages/ui/components/WysiwygEditor.wrn +++ b/packages/ui/components/WysiwygEditor.wrn @@ -1,4 +1,11 @@ component WysiwygEditor { + // A chrome shell around a slotted editor, not an editor itself: it renders a + // heading, a description, an optional item strip and a slot. It therefore + // emits none of these outputs on its own -- they forward whatever the slotted + // control raises. Slot a textarea or input and a parent @change or @input + // hears it, because those events bubble to this root; @focus and @blur arrive + // too, since the runtime binds the non-bubbling pair in the capture phase. + // Slot nothing interactive and there is nothing to hear. outputs { input(payload: { value?: string | number | boolean | null; values?: Array; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) change(payload: { value?: string | number | boolean | null; values?: Array; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts index b496cf63..00780b3e 100644 --- a/packages/ui/test/ui.test.ts +++ b/packages/ui/test/ui.test.ts @@ -3320,6 +3320,14 @@ test("no component gains an output that nothing ever emits", () => { * 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. + * + * That reasoning held only for events that bubble. focus and blur do not, so + * the bubble-phase fallback never heard a descendant take focus and every + * focus/blur output here was undeliverable -- silently, since a missing + * output raises nothing. The runtime now binds those two in the capture + * phase, which is what makes their exclusion honest rather than convenient. + * See "a @focus binding on a component tag fires for a focusable element + * inside it" in packages/csr/test/reactive.test.ts. */ const native = new Set([ "click",