diff --git a/src/cdk/a11y/focus-trap/focus-trap.spec.ts b/src/cdk/a11y/focus-trap/focus-trap.spec.ts index ee0c5b254861..d457b02cd7bc 100644 --- a/src/cdk/a11y/focus-trap/focus-trap.spec.ts +++ b/src/cdk/a11y/focus-trap/focus-trap.spec.ts @@ -8,6 +8,7 @@ import { ViewEncapsulation, inject, ChangeDetectionStrategy, + CUSTOM_ELEMENTS_SCHEMA, } from '@angular/core'; import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -186,6 +187,21 @@ describe('FocusTrap', () => { expect(() => focusTrapInstance.focusFirstTabbableElement()).not.toThrow(); expect(() => focusTrapInstance.focusLastTabbableElement()).not.toThrow(); }); + + it('should find a tabbable element nested inside a child element shadow root', () => { + if (!_supportsShadowDom()) { + return; + } + + const fixture = TestBed.createComponent(FocusTrapWithNestedShadowDomChild); + fixture.detectChanges(); + const focusTrapInstance = fixture.componentInstance.focusTrapDirective.focusTrap; + + const result = focusTrapInstance.focusFirstTabbableElement(); + + expect(getActiveElement()?.id).toBe('shadow-child-button'); + expect(result).toBe(true); + }); }); describe('with autoCapture', () => { @@ -424,6 +440,38 @@ class FocusTrapWithSvg { @ViewChild(CdkTrapFocus) focusTrapDirective!: CdkTrapFocus; } +if ( + typeof customElements !== 'undefined' && + !customElements.get('cdk-focus-trap-test-shadow-child') +) { + customElements.define( + 'cdk-focus-trap-test-shadow-child', + class extends HTMLElement { + connectedCallback() { + const root = this.attachShadow({mode: 'open'}); + const button = document.createElement('button'); + button.id = 'shadow-child-button'; + button.textContent = 'Shadow button'; + root.appendChild(button); + } + }, + ); +} + +@Component({ + template: ` +
+ +
+ `, + imports: [A11yModule, PortalModule], + changeDetection: ChangeDetectionStrategy.Eager, + schemas: [CUSTOM_ELEMENTS_SCHEMA], +}) +class FocusTrapWithNestedShadowDomChild { + @ViewChild(CdkTrapFocus) focusTrapDirective!: CdkTrapFocus; +} + @Component({ template: `
diff --git a/src/cdk/a11y/focus-trap/focus-trap.ts b/src/cdk/a11y/focus-trap/focus-trap.ts index 18567653f3e6..b8ba1cdbc316 100644 --- a/src/cdk/a11y/focus-trap/focus-trap.ts +++ b/src/cdk/a11y/focus-trap/focus-trap.ts @@ -279,13 +279,23 @@ export class FocusTrap { return this._hasAttached; } + /** + * Gets the children to consider for a subtree, in shadow-including tree order: a root's + * shadow DOM content (if any) precedes its light DOM content. + */ + private _getChildren(root: HTMLElement): Element[] { + return root.shadowRoot + ? [...root.shadowRoot.children, ...root.children] + : Array.from(root.children); + } + /** Get the first tabbable element from a DOM subtree (inclusive). */ private _getFirstTabbableElement(root: HTMLElement): HTMLElement | null { if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) { return root; } - const children = root.children; + const children = this._getChildren(root); for (let i = 0; i < children.length; i++) { const tabbableChild = @@ -308,7 +318,7 @@ export class FocusTrap { } // Iterate in reverse DOM order. - const children = root.children; + const children = this._getChildren(root); for (let i = children.length - 1; i >= 0; i--) { const tabbableChild =