Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: allows dispatching keyborg:focusin on focusable shadow root #81

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/FocusEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -135,6 +148,7 @@ export function setupFocusEvent(win: Window): void {
| Node
| null
| undefined;
const invocationTarget = node;

const currentShadows: Set<ShadowRoot> = new Set();

Expand All @@ -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();

Expand All @@ -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;

Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 5 additions & 4 deletions tests/common/ShadowRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<root.div
tabIndex={tabIndex}
{...rest}
style={{
border: "2px solid magenta",
Expand Down
52 changes: 52 additions & 0 deletions tests/focus-in-event.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,55 @@ test("behavior in shadow roots", async ({ page }) => {
"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",
);
});
29 changes: 29 additions & 0 deletions tests/focus-in-event.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,32 @@ export const NestedShadowRoots = () => (
</FocusInListener>
</div>
);

export const NestedFocusableShadowRoots = () => (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<FocusInListener>
<div
style={{
border: "2px solid blue",
display: "flex",
gap: 5,
padding: 20,
}}
>
<button>Light DOM: Button A</button>
</div>

<ShadowRoot data-testid="focusable-shadow-root-l1" tabIndex={0}>
<button>Shadow DOM: Button B</button>

<ShadowRoot data-testid="shadow-root-l2">
<button>Shadow DOM: Button C</button>

<ShadowRoot data-testid="focusable-shadow-root-l3" tabIndex={0}>
<span>Without focusable child</span>
</ShadowRoot>
</ShadowRoot>
</ShadowRoot>
</FocusInListener>
</div>
);