Skip to content

Commit

Permalink
fix: keyboard navigation within shadow dom (#1571)
Browse files Browse the repository at this point in the history
  • Loading branch information
diegopf authored Jul 18, 2024
1 parent b854c1c commit 1784c4c
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FOCUSABLE_SELECTORS } from '../utils/focus';
import { ArrowKey } from '../utils/types';
import { getActiveElement } from '../utils/html';
import {
AbsoluteDistances,
Intersection,
Expand Down Expand Up @@ -121,9 +122,11 @@ export class DirectionalFocusNavigationService implements SpatialNavigation {
* SHIFT+TAB keys.
*/
private updateOrigin(): void {
const newOrigin = document.activeElement as HTMLElement;
this.origin = newOrigin;
this.originRect = newOrigin.getBoundingClientRect();
const newOrigin = getActiveElement();
if (newOrigin) {
this.origin = newOrigin as HTMLElement;
this.originRect = newOrigin.getBoundingClientRect();
}
}

/**
Expand Down
39 changes: 38 additions & 1 deletion packages/x-components/src/utils/__tests__/html.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isElementEqualOrContained } from '../html';
import { isElementEqualOrContained, getActiveElement } from '../html';

describe(`testing ${isElementEqualOrContained.name} utility method`, () => {
it('returns `true` the two elements are the same', () => {
Expand All @@ -24,3 +24,40 @@ describe(`testing ${isElementEqualOrContained.name} utility method`, () => {
expect(isElementEqualOrContained(a, b)).toBe(false);
});
});

describe('getActiveElement', () => {
it('returns body when there is no active element', () => {
document.body.innerHTML = ''; // Clear the body to ensure no active element
expect(getActiveElement()).toBe(document.body);
});

it('returns the active element when it is directly on the document', () => {
document.body.innerHTML = '<input id="testInput" />';
const testInput = document.getElementById('testInput')!;
testInput.focus();
expect(getActiveElement()).toBe(testInput);
});

it('returns the deepest active element within a shadow DOM', () => {
document.body.innerHTML = '<div id="shadowHost"></div>';
const shadowHost = document.getElementById('shadowHost')!;
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = '<input id="shadowInput">';
const shadowInput = shadowRoot.getElementById('shadowInput')!;
shadowInput.focus();
expect(getActiveElement(shadowRoot)).toBe(shadowInput);
});

it('returns the active element when nested in multiple shadow DOMs', () => {
document.body.innerHTML = '<div id="outerShadowHost"></div>';
const outerShadowHost = document.getElementById('outerShadowHost')!;
const outerShadowRoot = outerShadowHost.attachShadow({ mode: 'open' });
outerShadowRoot.innerHTML = '<div id="innerShadowHost"></div>';
const innerShadowHost = outerShadowRoot.getElementById('innerShadowHost')!;
const innerShadowRoot = innerShadowHost.attachShadow({ mode: 'open' });
innerShadowRoot.innerHTML = '<input id="deepShadowInput">';
const deepShadowInput = innerShadowRoot.getElementById('deepShadowInput')!;
deepShadowInput.focus();
expect(getActiveElement(outerShadowRoot)).toBe(deepShadowInput);
});
});
21 changes: 21 additions & 0 deletions packages/x-components/src/utils/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,24 @@ export function isElementEqualOrContained(a: Element, b: Element): boolean {
export function getTargetElement(event: Event): Element {
return event.composedPath()[0] as Element;
}

/**
* Retrieves the currently active element from the specified document or shadow root.
* This function is recursive to handle nested shadow DOMs, ensuring the actual active
* element is returned even if it resides deep within multiple shadow DOM layers.
*
* @param root - The root document or shadow root from which to retrieve the active element.
* Defaults to the global document if not specified.
* @returns The active element if one exists, or null if no active element can be found.
* In the context of shadow DOM, this will return the deepest active element
* within nested shadow roots.
*
* @public
*/
export function getActiveElement(root: Document | ShadowRoot = document): Element | null {
let current = root.activeElement;
while (current?.shadowRoot) {
current = current.shadowRoot.activeElement;
}
return current;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import { AnimationProp } from '../../../types';
import { XEvent } from '../../../wiring';
import { empathizeXModule } from '../x-module';
import { getActiveElement } from '../../../utils/html';
/**
* Component containing the empathize. It has a required slot to define its content and two props
Expand Down Expand Up @@ -97,7 +98,8 @@
* element.
*/
function close() {
if (!empathizeRef.value?.contains(document.activeElement)) {
const activeElement = getActiveElement();
if (!empathizeRef.value?.contains(activeElement)) {
changeOpen(false);
}
}
Expand Down

0 comments on commit 1784c4c

Please sign in to comment.