diff --git a/packages/x-components/src/services/directional-focus-navigation.service.ts b/packages/x-components/src/services/directional-focus-navigation.service.ts index 3ef3db1582..5959f1a64a 100644 --- a/packages/x-components/src/services/directional-focus-navigation.service.ts +++ b/packages/x-components/src/services/directional-focus-navigation.service.ts @@ -1,5 +1,6 @@ import { FOCUSABLE_SELECTORS } from '../utils/focus'; import { ArrowKey } from '../utils/types'; +import { getActiveElement } from '../utils/html'; import { AbsoluteDistances, Intersection, @@ -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(); + } } /** diff --git a/packages/x-components/src/utils/__tests__/html.spec.ts b/packages/x-components/src/utils/__tests__/html.spec.ts index 4780e2e557..c76c28d2cc 100644 --- a/packages/x-components/src/utils/__tests__/html.spec.ts +++ b/packages/x-components/src/utils/__tests__/html.spec.ts @@ -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', () => { @@ -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 = ''; + const testInput = document.getElementById('testInput')!; + testInput.focus(); + expect(getActiveElement()).toBe(testInput); + }); + + it('returns the deepest active element within a shadow DOM', () => { + document.body.innerHTML = '
'; + const shadowHost = document.getElementById('shadowHost')!; + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + 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 = ''; + const outerShadowHost = document.getElementById('outerShadowHost')!; + const outerShadowRoot = outerShadowHost.attachShadow({ mode: 'open' }); + outerShadowRoot.innerHTML = ''; + const innerShadowHost = outerShadowRoot.getElementById('innerShadowHost')!; + const innerShadowRoot = innerShadowHost.attachShadow({ mode: 'open' }); + innerShadowRoot.innerHTML = ''; + const deepShadowInput = innerShadowRoot.getElementById('deepShadowInput')!; + deepShadowInput.focus(); + expect(getActiveElement(outerShadowRoot)).toBe(deepShadowInput); + }); +}); diff --git a/packages/x-components/src/utils/html.ts b/packages/x-components/src/utils/html.ts index 1a68192e36..9d56c4a7a5 100644 --- a/packages/x-components/src/utils/html.ts +++ b/packages/x-components/src/utils/html.ts @@ -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; +} diff --git a/packages/x-components/src/x-modules/empathize/components/empathize.vue b/packages/x-components/src/x-modules/empathize/components/empathize.vue index 4a9f5150e7..4d1b83828f 100644 --- a/packages/x-components/src/x-modules/empathize/components/empathize.vue +++ b/packages/x-components/src/x-modules/empathize/components/empathize.vue @@ -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 @@ -97,7 +98,8 @@ * element. */ function close() { - if (!empathizeRef.value?.contains(document.activeElement)) { + const activeElement = getActiveElement(); + if (!empathizeRef.value?.contains(activeElement)) { changeOpen(false); } }