Skip to content

Commit c40cb80

Browse files
authored
refactor(cdk/a11y): make focus-trap zoneless compatible (angular#29031)
1 parent 3e19768 commit c40cb80

File tree

4 files changed

+36
-37
lines changed

4 files changed

+36
-37
lines changed

src/cdk/a11y/focus-trap/focus-trap.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@ import {DOCUMENT} from '@angular/common';
1111
import {
1212
AfterContentInit,
1313
Directive,
14+
DoCheck,
1415
ElementRef,
1516
Inject,
1617
Injectable,
18+
Injector,
1719
Input,
1820
NgZone,
21+
OnChanges,
1922
OnDestroy,
20-
DoCheck,
2123
SimpleChanges,
22-
OnChanges,
24+
afterNextRender,
2325
booleanAttribute,
2426
inject,
2527
} from '@angular/core';
@@ -62,6 +64,8 @@ export class FocusTrap {
6264
readonly _ngZone: NgZone,
6365
readonly _document: Document,
6466
deferAnchors = false,
67+
/** @breaking-change 20.0.0 param to become required */
68+
readonly _injector?: Injector,
6569
) {
6670
if (!deferAnchors) {
6771
this.attachAnchors();
@@ -355,10 +359,27 @@ export class FocusTrap {
355359

356360
/** Executes a function when the zone is stable. */
357361
private _executeOnStable(fn: () => any): void {
358-
if (this._ngZone.isStable) {
359-
fn();
360-
} else {
362+
// TODO(mmalerba): Make this behave consistently across zonefull / zoneless.
363+
if (!this._ngZone.isStable) {
364+
// Subscribing `onStable` has slightly different behavior than `afterNextRender`.
365+
// `afterNextRender` does not wait for state changes queued up in a Promise
366+
// to avoid change after checked errors. In most cases we would consider this an
367+
// acceptable behavior change, the dialog at least made its best effort to focus the
368+
// first element. However, this is particularly problematic when combined with the
369+
// current behavior of the mat-radio-group, which adjusts the tabindex of its child
370+
// radios based on the selected value of the group. When the selected value is bound
371+
// via `[(ngModel)]` it hits this "state change in a promise" edge-case and can wind up
372+
// putting the focus on a radio button that is not supposed to be eligible to receive
373+
// focus. For now, we side-step this whole sequence of events by continuing to use
374+
// `onStable` in zonefull apps, but it should be noted that zoneless apps can still
375+
// suffer from this issue.
361376
this._ngZone.onStable.pipe(take(1)).subscribe(fn);
377+
} else {
378+
if (this._injector) {
379+
afterNextRender(fn, {injector: this._injector});
380+
} else {
381+
fn();
382+
}
362383
}
363384
}
364385
}
@@ -369,6 +390,7 @@ export class FocusTrap {
369390
@Injectable({providedIn: 'root'})
370391
export class FocusTrapFactory {
371392
private _document: Document;
393+
private _injector = inject(Injector);
372394

373395
constructor(
374396
private _checker: InteractivityChecker,
@@ -392,6 +414,7 @@ export class FocusTrapFactory {
392414
this._ngZone,
393415
this._document,
394416
deferCaptureElements,
417+
this._injector,
395418
);
396419
}
397420
}

src/cdk/dialog/dialog-container.ts

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,13 @@ import {
3131
ElementRef,
3232
EmbeddedViewRef,
3333
Inject,
34-
Injector,
3534
NgZone,
3635
OnDestroy,
3736
Optional,
3837
ViewChild,
3938
ViewEncapsulation,
40-
afterNextRender,
4139
inject,
4240
} from '@angular/core';
43-
import {take} from 'rxjs/operators';
4441
import {DialogConfig} from './dialog-config';
4542

4643
export function throwDialogContentAlreadyAttachedError() {
@@ -105,8 +102,6 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
105102

106103
protected readonly _changeDetectorRef = inject(ChangeDetectorRef);
107104

108-
private _injector = inject(Injector);
109-
110105
constructor(
111106
protected _elementRef: ElementRef,
112107
protected _focusTrapFactory: FocusTrapFactory,
@@ -271,33 +266,13 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
271266
break;
272267
case true:
273268
case 'first-tabbable':
274-
const doFocus = () => {
275-
const focusedSuccessfully = this._focusTrap?.focusInitialElement();
276-
// If we weren't able to find a focusable element in the dialog, then focus the
277-
// dialog container instead.
269+
this._focusTrap?.focusInitialElementWhenReady().then(focusedSuccessfully => {
270+
// If we weren't able to find a focusable element in the dialog, then focus the dialog
271+
// container instead.
278272
if (!focusedSuccessfully) {
279273
this._focusDialogContainer();
280274
}
281-
};
282-
283-
// TODO(mmalerba): Make this behave consistently across zonefull / zoneless.
284-
if (!this._ngZone.isStable) {
285-
// Subscribing `onStable` has slightly different behavior than `afterNextRender`.
286-
// `afterNextRender` does not wait for state changes queued up in a Promise
287-
// to avoid change after checked errors. In most cases we would consider this an
288-
// acceptable behavior change, the dialog at least made its best effort to focus the
289-
// first element. However, this is particularly problematic when combined with the
290-
// current behavior of the mat-radio-group, which adjusts the tabindex of its child
291-
// radios based on the selected value of the group. When the selected value is bound
292-
// via `[(ngModel)]` it hits this "state change in a promise" edge-case and can wind up
293-
// putting the focus on a radio button that is not supposed to be eligible to receive
294-
// focus. For now, we side-step this whole sequence of events by continuing to use
295-
// `onStable` in zonefull apps, but it should be noted that zoneless apps can still
296-
// suffer from this issue.
297-
this._ngZone.onStable.pipe(take(1)).subscribe(doFocus);
298-
} else {
299-
afterNextRender(doFocus, {injector: this._injector});
300-
}
275+
});
301276
break;
302277
case 'first-heading':
303278
this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]');

tools/public_api_guard/cdk/a11y.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,8 @@ export type FocusOrigin = 'touch' | 'mouse' | 'keyboard' | 'program' | null;
219219

220220
// @public
221221
export class FocusTrap {
222-
constructor(_element: HTMLElement, _checker: InteractivityChecker, _ngZone: NgZone, _document: Document, deferAnchors?: boolean);
222+
constructor(_element: HTMLElement, _checker: InteractivityChecker, _ngZone: NgZone, _document: Document, deferAnchors?: boolean,
223+
_injector?: Injector | undefined);
223224
attachAnchors(): boolean;
224225
destroy(): void;
225226
// (undocumented)
@@ -239,6 +240,7 @@ export class FocusTrap {
239240
focusLastTabbableElement(options?: FocusOptions): boolean;
240241
focusLastTabbableElementWhenReady(options?: FocusOptions): Promise<boolean>;
241242
hasAttached(): boolean;
243+
readonly _injector?: Injector | undefined;
242244
// (undocumented)
243245
readonly _ngZone: NgZone;
244246
// (undocumented)

tslint.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,7 @@
188188
// Tests may need to verify behavior with zones.
189189
"**/*.spec.ts",
190190
// TODO(mmalerba): following files to be cleaned up and removed from this list:
191-
"**/cdk/a11y/focus-trap/focus-trap.ts",
192-
"**/cdk/dialog/dialog-container.ts"
191+
"**/cdk/a11y/focus-trap/focus-trap.ts"
193192
]
194193
]
195194
},

0 commit comments

Comments
 (0)