Skip to content
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
1 change: 1 addition & 0 deletions goldens/cdk/a11y/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions src/cdk/a11y/focus-trap/focus-trap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -341,6 +356,23 @@ class SimpleFocusTrap {
@ViewChild(CdkTrapFocus) focusTrapDirective!: CdkTrapFocus;
}

@Component({
template: `
<div cdkTrapFocus>
<button>SAVE</button>
<input id="last-visible">
<div inert>
<input>
</div>
</div>
`,
imports: [A11yModule, PortalModule],
changeDetection: ChangeDetectionStrategy.Eager,
})
class FocusTrapWithInertLastChild {
@ViewChild(CdkTrapFocus) focusTrapDirective!: CdkTrapFocus;
}

const AUTO_FOCUS_TEMPLATE = `
<button type="button">Toggle</button>
@if (showTrappedRegion) {
Expand Down
73 changes: 73 additions & 0 deletions src/cdk/a11y/interactivity-checker/interactivity-checker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <button inert> to be inert').toBe(true);
});

it('should return true for a descendant of an inert element', () => {
testContainerElement.innerHTML = `<div inert>
<div>
<button></button>
</div>
</div>`;
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 = `<div>
<button></button>
</div>`;
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 = `<input style="display: none;">`;
Expand Down Expand Up @@ -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 <button inert> not to be focusable')
.toBe(false);
});

it('should return false for a focusable element inside an inert container', () => {
testContainerElement.innerHTML = `<div inert>
<button></button>
</div>`;
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 = `<div inert>
<button></button>
</div>`;
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 = `<input style="display: none;">`;
const input = testContainerElement.querySelector('input') as HTMLElement;
Expand Down
16 changes: 15 additions & 1 deletion src/cdk/a11y/interactivity-checker/interactivity-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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))
);
}
Expand Down
Loading