Skip to content

refactor(material/bottom-sheet): use CDK dialog internally #24951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 23, 2022
Merged
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 src/material/bottom-sheet/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ ng_module(
"//src/cdk/a11y",
"//src/cdk/bidi",
"//src/cdk/coercion",
"//src/cdk/dialog",
"//src/cdk/keycodes",
"//src/cdk/layout",
"//src/cdk/overlay",
Expand Down
233 changes: 31 additions & 202 deletions src/material/bottom-sheet/bottom-sheet-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,26 @@
*/

import {AnimationEvent} from '@angular/animations';
import {FocusTrap, FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y';
import {coerceArray} from '@angular/cdk/coercion';
import {CdkDialogContainer, DialogConfig} from '@angular/cdk/dialog';
import {FocusMonitor, FocusTrapFactory, InteractivityChecker} from '@angular/cdk/a11y';
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
import {OverlayRef} from '@angular/cdk/overlay';
import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform';
import {
BasePortalOutlet,
CdkPortalOutlet,
ComponentPortal,
DomPortal,
TemplatePortal,
} from '@angular/cdk/portal';
import {DOCUMENT} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentRef,
ElementRef,
EmbeddedViewRef,
EventEmitter,
Inject,
NgZone,
OnDestroy,
Optional,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {Subscription} from 'rxjs';
import {matBottomSheetAnimations} from './bottom-sheet-animations';
import {MatBottomSheetConfig} from './bottom-sheet-config';

// TODO(crisbeto): consolidate some logic between this, MatDialog and MatSnackBar

/**
* Internal component that wraps user-provided bottom sheet content.
Expand All @@ -58,52 +46,49 @@ import {MatBottomSheetConfig} from './bottom-sheet-config';
host: {
'class': 'mat-bottom-sheet-container',
'tabindex': '-1',
'role': 'dialog',
'aria-modal': 'true',
'[attr.aria-label]': 'bottomSheetConfig?.ariaLabel',
'[attr.role]': '_config.role',
'[attr.aria-modal]': '_config.isModal',
'[attr.aria-label]': '_config.ariaLabel',
'[@state]': '_animationState',
'(@state.start)': '_onAnimationStart($event)',
'(@state.done)': '_onAnimationDone($event)',
},
})
export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestroy {
export class MatBottomSheetContainer extends CdkDialogContainer implements OnDestroy {
private _breakpointSubscription: Subscription;

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

/** The state of the bottom sheet animations. */
_animationState: 'void' | 'visible' | 'hidden' = 'void';

/** Emits whenever the state of the animation changes. */
_animationStateChanged = new EventEmitter<AnimationEvent>();

/** The class that traps and manages focus within the bottom sheet. */
private _focusTrap: FocusTrap;

/** Element that was focused before the bottom sheet was opened. */
private _elementFocusedBeforeOpened: HTMLElement | null = null;

/** Server-side rendering-compatible reference to the global document object. */
private _document: Document;

/** Whether the component has been destroyed. */
private _destroyed: boolean;

constructor(
private _elementRef: ElementRef<HTMLElement>,
private _changeDetectorRef: ChangeDetectorRef,
private _focusTrapFactory: FocusTrapFactory,
private readonly _interactivityChecker: InteractivityChecker,
private readonly _ngZone: NgZone,
breakpointObserver: BreakpointObserver,
elementRef: ElementRef,
focusTrapFactory: FocusTrapFactory,
@Optional() @Inject(DOCUMENT) document: any,
/** The bottom sheet configuration. */
public bottomSheetConfig: MatBottomSheetConfig,
config: DialogConfig,
checker: InteractivityChecker,
ngZone: NgZone,
overlayRef: OverlayRef,
breakpointObserver: BreakpointObserver,
private _changeDetectorRef: ChangeDetectorRef,
focusMonitor?: FocusMonitor,
) {
super();
super(
elementRef,
focusTrapFactory,
document,
config,
checker,
ngZone,
overlayRef,
focusMonitor,
);

this._document = document;
this._breakpointSubscription = breakpointObserver
.observe([Breakpoints.Medium, Breakpoints.Large, Breakpoints.XLarge])
.subscribe(() => {
Expand All @@ -122,34 +107,6 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr
});
}

/** Attach a component portal as content to this bottom sheet container. */
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
this._validatePortalAttached();
this._setPanelClass();
this._savePreviouslyFocusedElement();
return this._portalOutlet.attachComponentPortal(portal);
}

/** Attach a template portal as content to this bottom sheet container. */
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
this._validatePortalAttached();
this._setPanelClass();
this._savePreviouslyFocusedElement();
return this._portalOutlet.attachTemplatePortal(portal);
}

/**
* Attaches a DOM portal to the bottom sheet container.
* @deprecated To be turned into a method.
* @breaking-change 10.0.0
*/
override attachDomPortal = (portal: DomPortal) => {
this._validatePortalAttached();
this._setPanelClass();
this._savePreviouslyFocusedElement();
return this._portalOutlet.attachDomPortal(portal);
};

/** Begin animation of bottom sheet entrance into view. */
enter(): void {
if (!this._destroyed) {
Expand All @@ -166,15 +123,14 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr
}
}

ngOnDestroy() {
override ngOnDestroy() {
super.ngOnDestroy();
this._breakpointSubscription.unsubscribe();
this._destroyed = true;
}

_onAnimationDone(event: AnimationEvent) {
if (event.toState === 'hidden') {
this._restoreFocus();
} else if (event.toState === 'visible') {
if (event.toState === 'visible') {
this._trapFocus();
}

Expand All @@ -185,136 +141,9 @@ export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestr
this._animationStateChanged.emit(event);
}

protected override _captureInitialFocus(): void {}

private _toggleClass(cssClass: string, add: boolean) {
this._elementRef.nativeElement.classList.toggle(cssClass, add);
}

private _validatePortalAttached() {
if (this._portalOutlet.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw Error('Attempting to attach bottom sheet content after content is already attached');
}
}

private _setPanelClass() {
const element: HTMLElement = this._elementRef.nativeElement;
element.classList.add(...coerceArray(this.bottomSheetConfig.panelClass || []));
}

/**
* Focuses the provided element. If the element is not focusable, it will add a tabIndex
* attribute to forcefully focus it. The attribute is removed after focus is moved.
* @param element The element to focus.
*/
private _forceFocus(element: HTMLElement, options?: FocusOptions) {
if (!this._interactivityChecker.isFocusable(element)) {
element.tabIndex = -1;
// The tabindex attribute should be removed to avoid navigating to that element again
this._ngZone.runOutsideAngular(() => {
const callback = () => {
element.removeEventListener('blur', callback);
element.removeEventListener('mousedown', callback);
element.removeAttribute('tabindex');
};

element.addEventListener('blur', callback);
element.addEventListener('mousedown', callback);
});
}
element.focus(options);
}

/**
* Focuses the first element that matches the given selector within the focus trap.
* @param selector The CSS selector for the element to set focus to.
*/
private _focusByCssSelector(selector: string, options?: FocusOptions) {
let elementToFocus = this._elementRef.nativeElement.querySelector(
selector,
) as HTMLElement | null;
if (elementToFocus) {
this._forceFocus(elementToFocus, options);
}
}

/**
* Moves the focus inside the focus trap. When autoFocus is not set to 'bottom-sheet',
* if focus cannot be moved then focus will go to the bottom sheet container.
*/
private _trapFocus() {
const element = this._elementRef.nativeElement;

if (!this._focusTrap) {
this._focusTrap = this._focusTrapFactory.create(element);
}

// If were to attempt to focus immediately, then the content of the bottom sheet would not
// yet be ready in instances where change detection has to run first. To deal with this,
// we simply wait for the microtask queue to be empty when setting focus when autoFocus
// isn't set to bottom sheet. If the element inside the bottom sheet can't be focused,
// then the container is focused so the user can't tab into other elements behind it.
switch (this.bottomSheetConfig.autoFocus) {
case false:
case 'dialog':
const activeElement = _getFocusedElementPierceShadowDom();
// Ensure that focus is on the bottom sheet container. It's possible that a different
// component tried to move focus while the open animation was running. See:
// https://github.com/angular/components/issues/16215. Note that we only want to do this
// if the focus isn't inside the bottom sheet already, because it's possible that the
// consumer specified `autoFocus` in order to move focus themselves.
if (activeElement !== element && !element.contains(activeElement)) {
element.focus();
}
break;
case true:
case 'first-tabbable':
this._focusTrap.focusInitialElementWhenReady();
break;
case 'first-heading':
this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
break;
default:
this._focusByCssSelector(this.bottomSheetConfig.autoFocus!);
break;
}
}

/** Restores focus to the element that was focused before the bottom sheet was opened. */
private _restoreFocus() {
const toFocus = this._elementFocusedBeforeOpened;

// We need the extra check, because IE can set the `activeElement` to null in some cases.
if (this.bottomSheetConfig.restoreFocus && toFocus && typeof toFocus.focus === 'function') {
const activeElement = _getFocusedElementPierceShadowDom();
const element = this._elementRef.nativeElement;

// Make sure that focus is still inside the bottom sheet or is on the body (usually because a
// non-focusable element like the backdrop was clicked) before moving it. It's possible that
// the consumer moved it themselves before the animation was done, in which case we shouldn't
// do anything.
if (
!activeElement ||
activeElement === this._document.body ||
activeElement === element ||
element.contains(activeElement)
) {
toFocus.focus();
}
}

if (this._focusTrap) {
this._focusTrap.destroy();
}
}

/** Saves a reference to the element that was focused before the bottom sheet was opened. */
private _savePreviouslyFocusedElement() {
this._elementFocusedBeforeOpened = _getFocusedElementPierceShadowDom();

// The `focus` method isn't available during server-side rendering.
if (this._elementRef.nativeElement.focus) {
this._ngZone.runOutsideAngular(() => {
Promise.resolve().then(() => this._elementRef.nativeElement.focus());
});
}
}
}
4 changes: 2 additions & 2 deletions src/material/bottom-sheet/bottom-sheet-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/

import {OverlayModule} from '@angular/cdk/overlay';
import {DialogModule} from '@angular/cdk/dialog';
import {PortalModule} from '@angular/cdk/portal';
import {NgModule} from '@angular/core';
import {MatCommonModule} from '@angular/material/core';
import {MatBottomSheetContainer} from './bottom-sheet-container';

@NgModule({
imports: [OverlayModule, MatCommonModule, PortalModule],
imports: [DialogModule, MatCommonModule, PortalModule],
exports: [MatBottomSheetContainer, MatCommonModule],
declarations: [MatBottomSheetContainer],
})
Expand Down
Loading