Skip to content

Commit

Permalink
refactor(material/bottom-sheet): use CDK dialog internally (#24951)
Browse files Browse the repository at this point in the history
Switches the internals of the bottom sheet to use the CDK dialog.
  • Loading branch information
crisbeto committed May 23, 2022
1 parent c67a299 commit 64b6506
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 393 deletions.
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

0 comments on commit 64b6506

Please sign in to comment.