Skip to content

Commit cfa97bb

Browse files
authored
fix(cdk/dialog): avoid setting aria-hidden before focus has moved (angular#31030)
The dialog moves focus in an `afterRender`, because it needs to give the content some time to be rendered. This is problematic with some relatively recent behavior in Chrome where the `aria-hidden` gets blocked and a warning is logged if it contains the focused element. These changes add a way for the container to indicate when it's done moving focus and use the new API to apply the `aria-hidden`. Fixes angular#30187.
1 parent 9f249d0 commit cfa97bb

File tree

5 files changed

+59
-38
lines changed

5 files changed

+59
-38
lines changed

goldens/cdk/dialog/index.api.md

+14-8
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { ViewContainerRef } from '@angular/core';
3838
export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
3939

4040
// @public
41-
export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends BasePortalOutlet implements OnDestroy {
41+
export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends BasePortalOutlet implements DialogContainer, OnDestroy {
4242
constructor(...args: unknown[]);
4343
// (undocumented)
4444
_addAriaLabelledBy(id: string): void;
@@ -62,6 +62,8 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends B
6262
// (undocumented)
6363
protected _focusTrapFactory: FocusTrapFactory;
6464
// (undocumented)
65+
_focusTrapped: Observable<void>;
66+
// (undocumented)
6567
ngOnDestroy(): void;
6668
// (undocumented)
6769
protected _ngZone: NgZone;
@@ -111,7 +113,7 @@ export interface DialogCloseOptions {
111113
}
112114

113115
// @public
114-
export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet = BasePortalOutlet> {
116+
export class DialogConfig<D = unknown, R = unknown, C extends DialogContainer = BasePortalOutlet> {
115117
ariaDescribedBy?: string | null;
116118
ariaLabel?: string | null;
117119
ariaLabelledBy?: string | null;
@@ -149,6 +151,13 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
149151
width?: string;
150152
}
151153

154+
// @public
155+
export type DialogContainer = BasePortalOutlet & {
156+
_focusTrapped?: Observable<void>;
157+
_closeInteractionType?: FocusOrigin;
158+
_recaptureFocus?: () => void;
159+
};
160+
152161
// @public (undocumented)
153162
export class DialogModule {
154163
// (undocumented)
@@ -161,19 +170,16 @@ export class DialogModule {
161170

162171
// @public
163172
export class DialogRef<R = unknown, C = unknown> {
164-
constructor(overlayRef: OverlayRef, config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>);
173+
constructor(overlayRef: OverlayRef, config: DialogConfig<any, DialogRef<R, C>, DialogContainer>);
165174
addPanelClass(classes: string | string[]): this;
166175
readonly backdropClick: Observable<MouseEvent>;
167176
close(result?: R, options?: DialogCloseOptions): void;
168177
readonly closed: Observable<R | undefined>;
169178
readonly componentInstance: C | null;
170179
readonly componentRef: ComponentRef<C> | null;
171180
// (undocumented)
172-
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>;
173-
readonly containerInstance: BasePortalOutlet & {
174-
_closeInteractionType?: FocusOrigin;
175-
_recaptureFocus?: () => void;
176-
};
181+
readonly config: DialogConfig<any, DialogRef<R, C>, DialogContainer>;
182+
readonly containerInstance: DialogContainer;
177183
disableClose: boolean | undefined;
178184
readonly id: string;
179185
readonly keydownEvents: Observable<KeyboardEvent>;

src/cdk/dialog/dialog-config.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,25 @@
99
import {ViewContainerRef, Injector, StaticProvider, Type} from '@angular/core';
1010
import {Direction} from '../bidi';
1111
import {PositionStrategy, ScrollStrategy} from '../overlay';
12+
import {Observable} from 'rxjs';
1213
import {BasePortalOutlet} from '../portal';
14+
import {FocusOrigin} from '../a11y';
1315

1416
/** Options for where to set focus to automatically on dialog open */
1517
export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
1618

1719
/** Valid ARIA roles for a dialog. */
1820
export type DialogRole = 'dialog' | 'alertdialog';
1921

22+
/** Component that can be used as the container for the dialog. */
23+
export type DialogContainer = BasePortalOutlet & {
24+
_focusTrapped?: Observable<void>;
25+
_closeInteractionType?: FocusOrigin;
26+
_recaptureFocus?: () => void;
27+
};
28+
2029
/** Configuration for opening a modal dialog. */
21-
export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet = BasePortalOutlet> {
30+
export class DialogConfig<D = unknown, R = unknown, C extends DialogContainer = BasePortalOutlet> {
2231
/**
2332
* Where the attached component should live in Angular's *logical* component tree.
2433
* This affects what is available for injection and the change detection order for the

src/cdk/dialog/dialog-container.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import {
3939
inject,
4040
DOCUMENT,
4141
} from '@angular/core';
42-
import {DialogConfig} from './dialog-config';
42+
import {DialogConfig, DialogContainer} from './dialog-config';
43+
import {Observable, Subject} from 'rxjs';
4344

4445
export function throwDialogContentAlreadyAttachedError() {
4546
throw Error('Attempting to attach dialog content after content is already attached');
@@ -71,7 +72,7 @@ export function throwDialogContentAlreadyAttachedError() {
7172
})
7273
export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
7374
extends BasePortalOutlet
74-
implements OnDestroy
75+
implements DialogContainer, OnDestroy
7576
{
7677
protected _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
7778
protected _focusTrapFactory = inject(FocusTrapFactory);
@@ -80,13 +81,16 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
8081
protected _ngZone = inject(NgZone);
8182
private _focusMonitor = inject(FocusMonitor);
8283
private _renderer = inject(Renderer2);
83-
84+
protected readonly _changeDetectorRef = inject(ChangeDetectorRef);
85+
private _injector = inject(Injector);
8486
private _platform = inject(Platform);
8587
protected _document = inject(DOCUMENT, {optional: true})!;
8688

8789
/** The portal outlet inside of this container into which the dialog content will be loaded. */
8890
@ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet;
8991

92+
_focusTrapped: Observable<void> = new Subject<void>();
93+
9094
/** The class that traps and manages focus within the dialog. */
9195
private _focusTrap: FocusTrap | null = null;
9296

@@ -108,10 +112,6 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
108112
*/
109113
_ariaLabelledByQueue: string[] = [];
110114

111-
protected readonly _changeDetectorRef = inject(ChangeDetectorRef);
112-
113-
private _injector = inject(Injector);
114-
115115
private _isDestroyed = false;
116116

117117
constructor(...args: unknown[]);
@@ -156,6 +156,7 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
156156
}
157157

158158
ngOnDestroy() {
159+
(this._focusTrapped as Subject<void>).complete();
159160
this._isDestroyed = true;
160161
this._restoreFocus();
161162
}
@@ -291,6 +292,7 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
291292
this._focusByCssSelector(this._config.autoFocus!, options);
292293
break;
293294
}
295+
(this._focusTrapped as Subject<void>).next();
294296
},
295297
{injector: this._injector},
296298
);

src/cdk/dialog/dialog-ref.ts

+5-9
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@
99
import {OverlayRef} from '../overlay';
1010
import {ESCAPE, hasModifierKey} from '../keycodes';
1111
import {Observable, Subject, Subscription} from 'rxjs';
12-
import {DialogConfig} from './dialog-config';
12+
import {DialogConfig, DialogContainer} from './dialog-config';
1313
import {FocusOrigin} from '../a11y';
14-
import {BasePortalOutlet} from '../portal';
1514
import {ComponentRef} from '@angular/core';
1615

1716
/** Additional options that can be passed in when closing a dialog. */
@@ -37,10 +36,7 @@ export class DialogRef<R = unknown, C = unknown> {
3736
readonly componentRef: ComponentRef<C> | null;
3837

3938
/** Instance of the container that is rendering out the dialog content. */
40-
readonly containerInstance: BasePortalOutlet & {
41-
_closeInteractionType?: FocusOrigin;
42-
_recaptureFocus?: () => void;
43-
};
39+
readonly containerInstance: DialogContainer;
4440

4541
/** Whether the user is allowed to close the dialog. */
4642
disableClose: boolean | undefined;
@@ -65,7 +61,7 @@ export class DialogRef<R = unknown, C = unknown> {
6561

6662
constructor(
6763
readonly overlayRef: OverlayRef,
68-
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>,
64+
readonly config: DialogConfig<any, DialogRef<R, C>, DialogContainer>,
6965
) {
7066
this.disableClose = config.disableClose;
7167
this.backdropClick = overlayRef.backdropClick();
@@ -114,7 +110,7 @@ export class DialogRef<R = unknown, C = unknown> {
114110
closedSubject.next(result);
115111
closedSubject.complete();
116112
(this as {componentInstance: C}).componentInstance = (
117-
this as {containerInstance: BasePortalOutlet}
113+
this as {containerInstance: DialogContainer}
118114
).containerInstance = null!;
119115
}
120116
}
@@ -149,7 +145,7 @@ export class DialogRef<R = unknown, C = unknown> {
149145

150146
/** Whether the dialog is allowed to close. */
151147
private _canClose(result?: R): boolean {
152-
const config = this.config as DialogConfig<unknown, unknown, BasePortalOutlet>;
148+
const config = this.config as DialogConfig<unknown, unknown, DialogContainer>;
153149

154150
return (
155151
!!this.containerInstance &&

src/cdk/dialog/dialog.ts

+21-13
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
signal,
2020
} from '@angular/core';
2121
import {Observable, Subject, defer} from 'rxjs';
22-
import {startWith} from 'rxjs/operators';
22+
import {startWith, take} from 'rxjs/operators';
2323
import {_IdGenerator} from '../a11y';
2424
import {Direction, Directionality} from '../bidi';
2525
import {
@@ -30,8 +30,8 @@ import {
3030
OverlayContainer,
3131
OverlayRef,
3232
} from '../overlay';
33-
import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '../portal';
34-
import {DialogConfig} from './dialog-config';
33+
import {ComponentPortal, TemplatePortal} from '../portal';
34+
import {DialogConfig, DialogContainer} from './dialog-config';
3535
import {DialogRef} from './dialog-ref';
3636

3737
import {CdkDialogContainer} from './dialog-container';
@@ -141,14 +141,24 @@ export class Dialog implements OnDestroy {
141141
const dialogRef = new DialogRef(overlayRef, config);
142142
const dialogContainer = this._attachContainer(overlayRef, dialogRef, config);
143143

144-
(dialogRef as {containerInstance: BasePortalOutlet}).containerInstance = dialogContainer;
145-
this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config);
144+
(dialogRef as {containerInstance: DialogContainer}).containerInstance = dialogContainer;
146145

147146
// If this is the first dialog that we're opening, hide all the non-overlay content.
148147
if (!this.openDialogs.length) {
149-
this._hideNonDialogContentFromAssistiveTechnology();
148+
// Resolve this ahead of time, because some internal apps
149+
// mock it out and depend on it being synchronous.
150+
const overlayContainer = this._overlayContainer.getContainerElement();
151+
152+
if (dialogContainer._focusTrapped) {
153+
dialogContainer._focusTrapped.pipe(take(1)).subscribe(() => {
154+
this._hideNonDialogContentFromAssistiveTechnology(overlayContainer);
155+
});
156+
} else {
157+
this._hideNonDialogContentFromAssistiveTechnology(overlayContainer);
158+
}
150159
}
151160

161+
this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config);
152162
(this.openDialogs as DialogRef<R, C>[]).push(dialogRef);
153163
dialogRef.closed.subscribe(() => this._removeOpenDialog(dialogRef, true));
154164
this.afterOpened.next(dialogRef);
@@ -233,14 +243,14 @@ export class Dialog implements OnDestroy {
233243
overlay: OverlayRef,
234244
dialogRef: DialogRef<R, C>,
235245
config: DialogConfig<D, DialogRef<R, C>>,
236-
): BasePortalOutlet {
246+
): DialogContainer {
237247
const userInjector = config.injector || config.viewContainerRef?.injector;
238248
const providers: StaticProvider[] = [
239249
{provide: DialogConfig, useValue: config},
240250
{provide: DialogRef, useValue: dialogRef},
241251
{provide: OverlayRef, useValue: overlay},
242252
];
243-
let containerType: Type<BasePortalOutlet>;
253+
let containerType: Type<DialogContainer>;
244254

245255
if (config.container) {
246256
if (typeof config.container === 'function') {
@@ -274,7 +284,7 @@ export class Dialog implements OnDestroy {
274284
private _attachDialogContent<R, D, C>(
275285
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
276286
dialogRef: DialogRef<R, C>,
277-
dialogContainer: BasePortalOutlet,
287+
dialogContainer: DialogContainer,
278288
config: DialogConfig<D, DialogRef<R, C>>,
279289
) {
280290
if (componentOrTemplateRef instanceof TemplateRef) {
@@ -316,7 +326,7 @@ export class Dialog implements OnDestroy {
316326
private _createInjector<R, D, C>(
317327
config: DialogConfig<D, DialogRef<R, C>>,
318328
dialogRef: DialogRef<R, C>,
319-
dialogContainer: BasePortalOutlet,
329+
dialogContainer: DialogContainer,
320330
fallbackInjector: Injector | undefined,
321331
): Injector {
322332
const userInjector = config.injector || config.viewContainerRef?.injector;
@@ -379,9 +389,7 @@ export class Dialog implements OnDestroy {
379389
}
380390

381391
/** Hides all of the content that isn't an overlay from assistive technology. */
382-
private _hideNonDialogContentFromAssistiveTechnology() {
383-
const overlayContainer = this._overlayContainer.getContainerElement();
384-
392+
private _hideNonDialogContentFromAssistiveTechnology(overlayContainer: HTMLElement) {
385393
// Ensure that the overlay container is attached to the DOM.
386394
if (overlayContainer.parentElement) {
387395
const siblings = overlayContainer.parentElement.children;

0 commit comments

Comments
 (0)