diff --git a/src/FocusEvent.ts b/src/FocusEvent.ts index 58672108c..0add491d6 100644 --- a/src/FocusEvent.ts +++ b/src/FocusEvent.ts @@ -122,6 +122,19 @@ export function setupFocusEvent(win: Window): void { }); target.dispatchEvent(event); + + // If focus travels backwards from element inside shadow root to shadow root itself, + // there will be no focusin event dispatched for shadow root. + // Calling onFocusIn to dispatch KeyborgFocusInEvent for + // relatedTarget - element that receives. + const relatedTarget = e.relatedTarget as Element; + + if ( + relatedTarget?.shadowRoot && + relatedTarget.shadowRoot.contains(target) + ) { + onFocusIn(relatedTarget, target, e, relatedTarget as HTMLElement); + } }; const focusInHandler = (e: FocusEvent) => { @@ -135,6 +148,7 @@ export function setupFocusEvent(win: Window): void { | Node | null | undefined; + const invocationTarget = node; const currentShadows: Set = new Set(); @@ -147,6 +161,10 @@ export function setupFocusEvent(win: Window): void { } } + if (target.shadowRoot && invocationTarget === target) { + currentShadows.add(target.shadowRoot); + } + for (const shadowRootWeakRef of shadowTargets) { const shadowRoot = shadowRootWeakRef.deref(); @@ -160,13 +178,19 @@ export function setupFocusEvent(win: Window): void { } } - onFocusIn(target, (e.relatedTarget as HTMLElement | null) || undefined); + onFocusIn( + target, + (e.relatedTarget as HTMLElement | null) || undefined, + e, + (invocationTarget as HTMLElement | null) || undefined, + ); }; const onFocusIn = ( target: Element, relatedTarget?: HTMLElement, originalEvent?: FocusEvent, + invocationTarget?: HTMLElement | null, ) => { const shadowRoot = target.shadowRoot; @@ -219,18 +243,29 @@ export function setupFocusEvent(win: Window): void { * > focused element ✅ (no shadow root - dispatch keyborg event) */ + let registerShadowRoot = true; for (const shadowRootWeakRef of shadowTargets) { if (shadowRootWeakRef.deref() === shadowRoot) { - return; + if (target === invocationTarget) { + // Skip adding listners when focus comes from child element in shadow DOM + registerShadowRoot = false; + } else { + return; + } } } - shadowRoot.addEventListener("focusin", focusInHandler, true); - shadowRoot.addEventListener("focusout", focusOutHandler, true); + if (registerShadowRoot) { + shadowRoot.addEventListener("focusin", focusInHandler, true); + shadowRoot.addEventListener("focusout", focusOutHandler, true); - shadowTargets.add(new WeakRefInstance(shadowRoot)); + shadowTargets.add(new WeakRefInstance(shadowRoot)); + } - return; + // If shadow root is not the one focused, don't proceed with event dispatch + if (target !== invocationTarget) { + return; + } } const details: KeyborgFocusInEventDetails = { diff --git a/tests/common/ShadowRoot.tsx b/tests/common/ShadowRoot.tsx index 239958fd8..2c93339a5 100644 --- a/tests/common/ShadowRoot.tsx +++ b/tests/common/ShadowRoot.tsx @@ -6,12 +6,13 @@ import * as React from "react"; import root from "react-shadow"; -export const ShadowRoot: React.FC<{ children: React.ReactNode }> = ({ - children, - ...rest -}) => { +export const ShadowRoot: React.FC<{ + children: React.ReactNode; + tabIndex?: number; +}> = ({ children, tabIndex, ...rest }) => { return ( { "focus-visible", ); }); + +test("behavior in focusable shadow roots", async ({ page }) => { + await page.goto("/?story=focus-in-event--nested-focusable-shadow-roots"); + + // Click the button [nav by mouse in light DOM] + await page.getByText("Light DOM: Button A").click(); + await expect(await page.getByText("Light DOM: Button A")).not.toHaveClass( + "focus-visible", + ); + + // Press Tab [nav by keyboard to focusable shadow root] + await page.keyboard.press("Tab"); + await expect(await page.getByTestId("focusable-shadow-root-l1")).toHaveClass( + "focus-visible", + ); + + // Press Tab [nav by keyboard to nested shadow root] + await page.keyboard.press("Tab"); + await expect(await page.getByText("Shadow DOM: Button B")).toHaveClass( + "focus-visible", + ); + + // Press Tab [nav by keyboard to focusable nested shadow root] + await page.keyboard.press("Tab"); + await expect(await page.getByText("Shadow DOM: Button C")).toHaveClass( + "focus-visible", + ); + + // Press Tab [nav by keyboard to nested shadow root] + await page.keyboard.press("Tab"); + await expect(await page.getByTestId("focusable-shadow-root-l3")).toHaveClass( + "focus-visible", + ); + + // Press Shift+Tab [nav back by keyboard to nested shadow root] + await page.keyboard.press("Shift+Tab"); + await expect(await page.getByText("Shadow DOM: Button C")).toHaveClass( + "focus-visible", + ); + + // Press Shift+Tab [nav back by keyboard to nested shadow root] + await page.keyboard.press("Shift+Tab"); + await expect(await page.getByText("Shadow DOM: Button B")).toHaveClass( + "focus-visible", + ); + + // Press Shift+Tab [nav back by keyboard to shadow root] + await page.keyboard.press("Shift+Tab"); + await expect(await page.getByTestId("focusable-shadow-root-l1")).toHaveClass( + "focus-visible", + ); +}); diff --git a/tests/focus-in-event.stories.tsx b/tests/focus-in-event.stories.tsx index cdbcfe1e3..e8b667c1e 100644 --- a/tests/focus-in-event.stories.tsx +++ b/tests/focus-in-event.stories.tsx @@ -118,3 +118,32 @@ export const NestedShadowRoots = () => ( ); + +export const NestedFocusableShadowRoots = () => ( +
+ +
+ +
+ + + + + + + + + Without focusable child + + + +
+
+);