From 96dcb1f53c7518014cd7f1928728345e47e4ca28 Mon Sep 17 00:00:00 2001 From: Dominik Noszlopi Date: Mon, 21 Sep 2026 11:50:04 +0200 Subject: [PATCH] fix(cdk/a11y): treat inert elements as non-focusable `InteractivityChecker.isFocusable` ignored the `inert` attribute, so `FocusTrap` could pick an element inside an inert subtree as the first or last tabbable element. Calling `focus()` on such an element is a no-op, which left focus on the trap anchor and let Tab / Shift+Tab escape the trapped region. Add an `isInert` check that walks the ancestors with `closest('[inert]')` and run it before the visibility check. The fix propagates through the shared checker to `FocusTrap`, `ConfigurableFocusTrap`, `Dialog` and `FocusKeyManager`. --- goldens/cdk/a11y/index.api.md | 1 + src/cdk/a11y/focus-trap/focus-trap.spec.ts | 32 ++++++++ .../interactivity-checker.spec.ts | 73 +++++++++++++++++++ .../interactivity-checker.ts | 16 +++- 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/goldens/cdk/a11y/index.api.md b/goldens/cdk/a11y/index.api.md index 97e249a3dbf6..64d95ac14d6c 100644 --- a/goldens/cdk/a11y/index.api.md +++ b/goldens/cdk/a11y/index.api.md @@ -327,6 +327,7 @@ export interface InputModalityDetectorOptions { export class InteractivityChecker { isDisabled(element: HTMLElement): boolean; isFocusable(element: HTMLElement, config?: IsFocusableConfig): boolean; + isInert(element: HTMLElement): boolean; isTabbable(element: HTMLElement): boolean; isVisible(element: HTMLElement): boolean; // (undocumented) diff --git a/src/cdk/a11y/focus-trap/focus-trap.spec.ts b/src/cdk/a11y/focus-trap/focus-trap.spec.ts index a196f6715273..fb584b81c9d2 100644 --- a/src/cdk/a11y/focus-trap/focus-trap.spec.ts +++ b/src/cdk/a11y/focus-trap/focus-trap.spec.ts @@ -54,6 +54,21 @@ describe('FocusTrap', () => { .toBe(true); }); + it('should skip elements inside an inert subtree when focusing the last element', () => { + const fixture = TestBed.createComponent(FocusTrapWithInertLastChild); + fixture.detectChanges(); + + const result = + fixture.componentInstance.focusTrapDirective.focusTrap.focusLastTabbableElement(); + + expect(getActiveElement().id) + .withContext('Expected the last non-inert element to be focused') + .toBe('last-visible'); + expect(result) + .withContext('Expected return value to be true if focus was shifted.') + .toBe(true); + }); + it('should return false if it did not manage to find a focusable element', () => { const fixture = TestBed.createComponent(FocusTrapWithoutFocusableElements); fixture.detectChanges(); @@ -341,6 +356,23 @@ class SimpleFocusTrap { @ViewChild(CdkTrapFocus) focusTrapDirective!: CdkTrapFocus; } +@Component({ + template: ` +
+ + +
+ +
+
+ `, + imports: [A11yModule, PortalModule], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class FocusTrapWithInertLastChild { + @ViewChild(CdkTrapFocus) focusTrapDirective!: CdkTrapFocus; +} + const AUTO_FOCUS_TEMPLATE = ` @if (showTrappedRegion) { diff --git a/src/cdk/a11y/interactivity-checker/interactivity-checker.spec.ts b/src/cdk/a11y/interactivity-checker/interactivity-checker.spec.ts index 6e4d348d612a..e0db3cb64156 100644 --- a/src/cdk/a11y/interactivity-checker/interactivity-checker.spec.ts +++ b/src/cdk/a11y/interactivity-checker/interactivity-checker.spec.ts @@ -44,6 +44,40 @@ describe('InteractivityChecker', () => { }); }); + describe('isInert', () => { + it('should return true for an element with the inert attribute', () => { + const button = document.createElement('button'); + button.setAttribute('inert', ''); + testContainerElement.appendChild(button); + + expect(checker.isInert(button)).withContext('Expected + + `; + const button = testContainerElement.querySelector('button') as HTMLElement; + + expect(checker.isInert(button)) + .withContext('Expected element with an inert ancestor to be inert') + .toBe(true); + }); + + it('should return false for an element without an inert ancestor', () => { + testContainerElement.innerHTML = `
+ +
`; + const button = testContainerElement.querySelector('button') as HTMLElement; + + expect(checker.isInert(button)) + .withContext('Expected element without an inert ancestor not to be inert') + .toBe(false); + }); + }); + describe('isVisible', () => { it('should return false for a `display: none` element (isVisible)', () => { testContainerElement.innerHTML = ``; @@ -158,6 +192,45 @@ describe('InteractivityChecker', () => { }); }); + it('should return false for a focusable element with the inert attribute', () => { + const button = document.createElement('button'); + button.setAttribute('inert', ''); + testContainerElement.appendChild(button); + + expect(checker.isFocusable(button)) + .withContext('Expected + `; + const button = testContainerElement.querySelector('button') as HTMLElement; + + expect(checker.isFocusable(button)) + .withContext('Expected element with an inert ancestor not to be focusable') + .toBe(false); + }); + + it('should return true for a focusable element once inert is removed', () => { + testContainerElement.innerHTML = `
+ +
`; + const container = testContainerElement.firstElementChild as HTMLElement; + const button = testContainerElement.querySelector('button') as HTMLElement; + + expect(checker.isFocusable(button)) + .withContext('Expected element with an inert ancestor not to be focusable') + .toBe(false); + + container.removeAttribute('inert'); + + expect(checker.isFocusable(button)) + .withContext('Expected element to be focusable once inert is removed') + .toBe(true); + }); + it('should return false for a `display: none` element (isFocusable)', () => { testContainerElement.innerHTML = ``; const input = testContainerElement.querySelector('input') as HTMLElement; diff --git a/src/cdk/a11y/interactivity-checker/interactivity-checker.ts b/src/cdk/a11y/interactivity-checker/interactivity-checker.ts index a43143541310..d1a34d5d66b1 100644 --- a/src/cdk/a11y/interactivity-checker/interactivity-checker.ts +++ b/src/cdk/a11y/interactivity-checker/interactivity-checker.ts @@ -43,6 +43,19 @@ export class InteractivityChecker { return element.hasAttribute('disabled'); } + /** + * Gets whether an element is inert, either directly or through an inert ancestor. + * Inert elements cannot receive focus, so calling `focus()` on them has no effect. + * + * @param element Element to be checked. + * @returns Whether the element is inert. + */ + isInert(element: HTMLElement): boolean { + // The `inert` attribute applies to the element and all of its descendants, so the + // ancestors have to be checked as well. `closest` may be missing in non-DOM environments. + return element.closest?.('[inert]') != null; + } + /** * Gets whether an element is visible for the purposes of interactivity. * @@ -142,11 +155,12 @@ export class InteractivityChecker { * @returns Whether the element is focusable. */ isFocusable(element: HTMLElement, config?: IsFocusableConfig): boolean { - // Perform checks in order of left to most expensive. + // Perform checks in order of least to most expensive. // Again, naive approach that does not capture many edge cases and browser quirks. return ( isPotentiallyFocusable(element) && !this.isDisabled(element) && + !this.isInert(element) && (config?.ignoreVisibility || this.isVisible(element)) ); }