diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 3e027a0978d6..489fd441716e 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,6 +1,7 @@
# Angular Material components
/src/lib/* @jelbourn
/src/lib/autocomplete/** @kara @crisbeto
+/src/lib/bottom-sheet/** @jelbourn @crisbeto
/src/lib/button-toggle/** @tinayuangao
/src/lib/button/** @tinayuangao
/src/lib/card/** @jelbourn
@@ -88,6 +89,7 @@
/src/demo-app/* @jelbourn
/src/demo-app/a11y/** @tinayuangao
/src/demo-app/autocomplete/** @kara @crisbeto
+/src/demo-app/bottom-sheet/** @jelbourn @crisbeto
/src/demo-app/baseline/** @mmalerba
/src/demo-app/button-toggle/** @tinayuangao
/src/demo-app/button/** @tinayuangao
diff --git a/src/demo-app/bottom-sheet/bottom-sheet-demo.html b/src/demo-app/bottom-sheet/bottom-sheet-demo.html
new file mode 100644
index 000000000000..802782cc7f91
--- /dev/null
+++ b/src/demo-app/bottom-sheet/bottom-sheet-demo.html
@@ -0,0 +1,45 @@
+
Bottom sheet demo
+
+
+
+
+
+
+ Options
+
+
+ Has backdrop
+
+
+
+ Disable close
+
+
+
+
+
+
+
+
+
+
+
+ LTR
+ RTL
+
+
+
+
+
+
+
+
+
+
+
+ folder
+ Action {{ link }}
+ Description
+
+
+
diff --git a/src/demo-app/bottom-sheet/bottom-sheet-demo.scss b/src/demo-app/bottom-sheet/bottom-sheet-demo.scss
new file mode 100644
index 000000000000..a19b4d826750
--- /dev/null
+++ b/src/demo-app/bottom-sheet/bottom-sheet-demo.scss
@@ -0,0 +1,8 @@
+.demo-dialog-card {
+ max-width: 405px;
+ margin: 20px 0;
+}
+
+.mat-raised-button {
+ margin-right: 5px;
+}
diff --git a/src/demo-app/bottom-sheet/bottom-sheet-demo.ts b/src/demo-app/bottom-sheet/bottom-sheet-demo.ts
new file mode 100644
index 000000000000..c588c41522c9
--- /dev/null
+++ b/src/demo-app/bottom-sheet/bottom-sheet-demo.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {Component, ViewEncapsulation, TemplateRef, ViewChild} from '@angular/core';
+import {
+ MatBottomSheet,
+ MatBottomSheetRef,
+ MatBottomSheetConfig,
+} from '@angular/material/bottom-sheet';
+
+const defaultConfig = new MatBottomSheetConfig();
+
+@Component({
+ moduleId: module.id,
+ selector: 'bottom-sheet-demo',
+ styleUrls: ['bottom-sheet-demo.css'],
+ templateUrl: 'bottom-sheet-demo.html',
+ encapsulation: ViewEncapsulation.None,
+ preserveWhitespaces: false,
+})
+export class BottomSheetDemo {
+ config: MatBottomSheetConfig = {
+ hasBackdrop: defaultConfig.hasBackdrop,
+ disableClose: defaultConfig.disableClose,
+ backdropClass: defaultConfig.backdropClass,
+ direction: 'ltr'
+ };
+
+ @ViewChild(TemplateRef) template: TemplateRef;
+
+ constructor(private _bottomSheet: MatBottomSheet) {}
+
+ openComponent() {
+ this._bottomSheet.open(ExampleBottomSheet, this.config);
+ }
+
+ openTemplate() {
+ this._bottomSheet.open(this.template, this.config);
+ }
+}
+
+
+@Component({
+ template: `
+
+
+ folder
+ Action {{ link }}
+ Description
+
+
+ `
+})
+export class ExampleBottomSheet {
+ constructor(private sheet: MatBottomSheetRef) {}
+
+ handleClick(event: MouseEvent) {
+ event.preventDefault();
+ this.sheet.dismiss();
+ }
+}
diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts
index 579efc8966ee..26e5a0f0e4b8 100644
--- a/src/demo-app/demo-app/demo-app.ts
+++ b/src/demo-app/demo-app/demo-app.ts
@@ -51,6 +51,7 @@ export class DemoApp {
dark = false;
navItems = [
{name: 'Autocomplete', route: '/autocomplete'},
+ {name: 'Bottom sheet', route: '/bottom-sheet'},
{name: 'Button Toggle', route: '/button-toggle'},
{name: 'Button', route: '/button'},
{name: 'Card', route: '/card'},
diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts
index 566e52b3d27c..38665e31b4ae 100644
--- a/src/demo-app/demo-app/demo-module.ts
+++ b/src/demo-app/demo-app/demo-module.ts
@@ -12,6 +12,7 @@ import {NgModule} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {RouterModule} from '@angular/router';
import {AutocompleteDemo} from '../autocomplete/autocomplete-demo';
+import {BottomSheetDemo, ExampleBottomSheet} from '../bottom-sheet/bottom-sheet-demo';
import {BaselineDemo} from '../baseline/baseline-demo';
import {ButtonToggleDemo} from '../button-toggle/button-toggle-demo';
import {ButtonDemo} from '../button/button-demo';
@@ -71,6 +72,7 @@ import {TableDemoModule} from '../table/table-demo-module';
],
declarations: [
AutocompleteDemo,
+ BottomSheetDemo,
BaselineDemo,
ButtonDemo,
ButtonToggleDemo,
@@ -120,6 +122,7 @@ import {TableDemoModule} from '../table/table-demo-module';
ToolbarDemo,
TooltipDemo,
TypographyDemo,
+ ExampleBottomSheet,
],
providers: [
{provide: OverlayContainer, useClass: FullscreenOverlayContainer},
@@ -133,6 +136,7 @@ import {TableDemoModule} from '../table/table-demo-module';
RotiniPanel,
ScienceJoke,
SpagettiPanel,
+ ExampleBottomSheet,
],
})
export class DemoModule {}
diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts
index c581cc175994..865634f7eeb4 100644
--- a/src/demo-app/demo-app/routes.ts
+++ b/src/demo-app/demo-app/routes.ts
@@ -10,6 +10,7 @@ import {Routes} from '@angular/router';
import {AccessibilityDemo} from '../a11y/a11y';
import {ACCESSIBILITY_DEMO_ROUTES} from '../a11y/routes';
import {AutocompleteDemo} from '../autocomplete/autocomplete-demo';
+import {BottomSheetDemo} from '../bottom-sheet/bottom-sheet-demo';
import {BaselineDemo} from '../baseline/baseline-demo';
import {ButtonToggleDemo} from '../button-toggle/button-toggle-demo';
import {ButtonDemo} from '../button/button-demo';
@@ -55,6 +56,7 @@ export const DEMO_APP_ROUTES: Routes = [
{path: '', component: DemoApp, children: [
{path: '', component: Home},
{path: 'autocomplete', component: AutocompleteDemo},
+ {path: 'bottom-sheet', component: BottomSheetDemo},
{path: 'baseline', component: BaselineDemo},
{path: 'button', component: ButtonDemo},
{path: 'button-toggle', component: ButtonToggleDemo},
diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts
index 33ff49f3c389..194e543261f7 100644
--- a/src/demo-app/demo-material-module.ts
+++ b/src/demo-app/demo-material-module.ts
@@ -9,6 +9,7 @@
import {NgModule} from '@angular/core';
import {
MatAutocompleteModule,
+ MatBottomSheetModule,
MatButtonModule,
MatButtonToggleModule,
MatCardModule,
@@ -56,6 +57,7 @@ import {PortalModule} from '@angular/cdk/portal';
@NgModule({
exports: [
MatAutocompleteModule,
+ MatBottomSheetModule,
MatButtonModule,
MatButtonToggleModule,
MatCardModule,
diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts
index fa0a07207038..4f45b0820945 100644
--- a/src/demo-app/system-config.ts
+++ b/src/demo-app/system-config.ts
@@ -59,6 +59,7 @@ System.config({
'@angular/cdk/table': 'dist/packages/cdk/table/index.js',
'@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js',
+ '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js',
'@angular/material/button': 'dist/packages/material/button/index.js',
'@angular/material/button-toggle': 'dist/packages/material/button-toggle/index.js',
'@angular/material/card': 'dist/packages/material/card/index.js',
diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts
index 20ddfba9d6ec..6dd969fb92e2 100644
--- a/src/e2e-app/system-config.ts
+++ b/src/e2e-app/system-config.ts
@@ -51,6 +51,7 @@ System.config({
'@angular/material-examples': 'dist/bundles/material-examples.umd.js',
'@angular/material/autocomplete': 'dist/bundles/material-autocomplete.umd.js',
+ '@angular/material/bottom-sheet': 'dist/bundles/material-bottom-sheet.umd.js',
'@angular/material/button': 'dist/bundles/material-button.umd.js',
'@angular/material/button-toggle': 'dist/bundles/material-button-toggle.umd.js',
'@angular/material/card': 'dist/bundles/material-card.umd.js',
diff --git a/src/lib/bottom-sheet/BUILD.bazel b/src/lib/bottom-sheet/BUILD.bazel
new file mode 100644
index 000000000000..a43a94eb6721
--- /dev/null
+++ b/src/lib/bottom-sheet/BUILD.bazel
@@ -0,0 +1,38 @@
+package(default_visibility=["//visibility:public"])
+load("@angular//:index.bzl", "ng_module")
+load("@io_bazel_rules_sass//sass:sass.bzl", "sass_library", "sass_binary")
+
+
+ng_module(
+ name = "bottom-sheet",
+ srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]),
+ module_name = "@angular/material/bottom_sheet",
+ assets = [
+ ":bottom_sheet_container_css",
+ ],
+ deps = [
+ "//src/lib/core",
+ "//src/cdk/a11y",
+ "//src/cdk/overlay",
+ "//src/cdk/portal",
+ "//src/cdk/layout",
+ "@rxjs",
+ ],
+ tsconfig = ":tsconfig-build.json",
+)
+
+
+sass_binary(
+ name = "bottom_sheet_container_scss",
+ src = "bottom-sheet-container.scss",
+ deps = ["//src/lib/core:core_scss_lib"],
+)
+
+# TODO(jelbourn): remove this when sass_binary supports specifying an output filename and dir.
+# Copy the output of the sass_binary such that the filename and path match what we expect.
+genrule(
+ name = "bottom_sheet_container_css",
+ srcs = [":bottom_sheet_container_scss"],
+ outs = ["bottom-sheet-container.css"],
+ cmd = "cat $(locations :bottom_sheet_container_scss) > $@",
+)
diff --git a/src/lib/bottom-sheet/README.md b/src/lib/bottom-sheet/README.md
new file mode 100644
index 000000000000..ac1f4b38951a
--- /dev/null
+++ b/src/lib/bottom-sheet/README.md
@@ -0,0 +1 @@
+Please see the official documentation at https://material.angular.io/components/component/bottom-sheet
diff --git a/src/lib/bottom-sheet/_bottom-sheet-theme.scss b/src/lib/bottom-sheet/_bottom-sheet-theme.scss
new file mode 100644
index 000000000000..c141ca99747d
--- /dev/null
+++ b/src/lib/bottom-sheet/_bottom-sheet-theme.scss
@@ -0,0 +1,21 @@
+@import '../core/typography/typography-utils';
+@import '../core/theming/palette';
+
+@mixin mat-bottom-sheet-theme($theme) {
+ $background: map-get($theme, background);
+ $foreground: map-get($theme, foreground);
+
+ .mat-bottom-sheet-container {
+ background: mat-color($background, dialog);
+ color: mat-color($foreground, text);
+ }
+}
+
+@mixin mat-bottom-sheet-typography($config) {
+ .mat-bottom-sheet-container {
+ // Note: we don't use the line-height, because it's way too big.
+ font-family: mat-font-family($config);
+ font-size: mat-font-size($config, subheading-2);
+ font-weight: mat-font-weight($config, subheading-2);
+ }
+}
diff --git a/src/lib/bottom-sheet/bottom-sheet-animations.ts b/src/lib/bottom-sheet/bottom-sheet-animations.ts
new file mode 100644
index 000000000000..102283d31679
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-animations.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+import {
+ animate,
+ state,
+ style,
+ transition,
+ trigger,
+ AnimationTriggerMetadata,
+} from '@angular/animations';
+import {AnimationCurves, AnimationDurations} from '@angular/material/core';
+
+/** Animations used by the Material bottom sheet. */
+export const matBottomSheetAnimations: {
+ readonly bottomSheetState: AnimationTriggerMetadata;
+} = {
+ /** Animation that shows and hides a bottom sheet. */
+ bottomSheetState: trigger('state', [
+ state('void, hidden', style({transform: 'translateY(100%)'})),
+ state('visible', style({transform: 'translateY(0%)'})),
+ transition('visible => void, visible => hidden',
+ animate(`${AnimationDurations.COMPLEX} ${AnimationCurves.ACCELERATION_CURVE}`)),
+ transition('void => visible',
+ animate(`${AnimationDurations.EXITING} ${AnimationCurves.DECELERATION_CURVE}`)),
+ ])
+};
diff --git a/src/lib/bottom-sheet/bottom-sheet-config.ts b/src/lib/bottom-sheet/bottom-sheet-config.ts
new file mode 100644
index 000000000000..42cbbecffb05
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-config.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {ViewContainerRef, InjectionToken} from '@angular/core';
+import {Direction} from '@angular/cdk/bidi';
+
+/** Injection token that can be used to access the data that was passed in to a bottom sheet. */
+export const MAT_BOTTOM_SHEET_DATA = new InjectionToken('MatBottomSheetData');
+
+/**
+ * Configuration used when opening a bottom sheet.
+ */
+export class MatBottomSheetConfig {
+ /** The view container to place the overlay for the bottom sheet into. */
+ viewContainerRef?: ViewContainerRef;
+
+ /** Extra CSS classes to be added to the bottom sheet container. */
+ panelClass?: string | string[];
+
+ /** Text layout direction for the bottom sheet. */
+ direction?: Direction = 'ltr';
+
+ /** Data being injected into the child component. */
+ data?: D | null = null;
+
+ /** Whether the bottom sheet has a backdrop. */
+ hasBackdrop?: boolean = true;
+
+ /** Custom class for the backdrop. */
+ backdropClass?: string;
+
+ /** Whether the user can use escape or clicking outside to close the bottom sheet. */
+ disableClose?: boolean = false;
+
+ /** Aria label to assign to the bottom sheet element. */
+ ariaLabel?: string | null = null;
+}
diff --git a/src/lib/bottom-sheet/bottom-sheet-container.html b/src/lib/bottom-sheet/bottom-sheet-container.html
new file mode 100644
index 000000000000..180e3656473c
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-container.html
@@ -0,0 +1 @@
+
diff --git a/src/lib/bottom-sheet/bottom-sheet-container.scss b/src/lib/bottom-sheet/bottom-sheet-container.scss
new file mode 100644
index 000000000000..e124e96ced26
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-container.scss
@@ -0,0 +1,31 @@
+@import '../core/style/elevation';
+
+// The bottom sheet minimum width on larger screen sizes is based
+// on increments of the toolbar, according to the spec. See:
+// https://material.io/guidelines/components/bottom-sheets.html#bottom-sheets-specs
+$_mat-bottom-sheet-width-increment: 64px;
+$mat-bottom-sheet-container-vertical-padding: 8px !default;
+$mat-bottom-sheet-container-horizontal-padding: 16px !default;
+
+.mat-bottom-sheet-container {
+ @include mat-elevation(16);
+
+ padding: $mat-bottom-sheet-container-vertical-padding
+ $mat-bottom-sheet-container-horizontal-padding;
+ min-width: 100vw;
+ box-sizing: border-box;
+ display: block;
+ outline: 0;
+}
+
+.mat-bottom-sheet-container-medium {
+ min-width: $_mat-bottom-sheet-width-increment * 6;
+}
+
+.mat-bottom-sheet-container-large {
+ min-width: $_mat-bottom-sheet-width-increment * 8;
+}
+
+.mat-bottom-sheet-container-xlarge {
+ min-width: $_mat-bottom-sheet-width-increment * 9;
+}
diff --git a/src/lib/bottom-sheet/bottom-sheet-container.ts b/src/lib/bottom-sheet/bottom-sheet-container.ts
new file mode 100644
index 000000000000..d6a6bc4f8ad5
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-container.ts
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {
+ Component,
+ ComponentRef,
+ EmbeddedViewRef,
+ ViewChild,
+ OnDestroy,
+ ElementRef,
+ ChangeDetectionStrategy,
+ ViewEncapsulation,
+ ChangeDetectorRef,
+ EventEmitter,
+ Inject,
+ Optional,
+} from '@angular/core';
+import {AnimationEvent} from '@angular/animations';
+import {
+ BasePortalOutlet,
+ ComponentPortal,
+ TemplatePortal,
+ CdkPortalOutlet,
+} from '@angular/cdk/portal';
+import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
+import {MatBottomSheetConfig} from './bottom-sheet-config';
+import {matBottomSheetAnimations} from './bottom-sheet-animations';
+import {Subscription} from 'rxjs/Subscription';
+import {DOCUMENT} from '@angular/common';
+import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y';
+
+// TODO(crisbeto): consolidate some logic between this, MatDialog and MatSnackBar
+
+/**
+ * Internal component that wraps user-provided bottom sheet content.
+ * @docs-private
+ */
+@Component({
+ moduleId: module.id,
+ selector: 'mat-bottom-sheet-container',
+ templateUrl: 'bottom-sheet-container.html',
+ styleUrls: ['bottom-sheet-container.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ preserveWhitespaces: false,
+ animations: [matBottomSheetAnimations.bottomSheetState],
+ host: {
+ 'class': 'mat-bottom-sheet-container',
+ 'tabindex': '-1',
+ 'role': 'dialog',
+ '[attr.aria-label]': 'bottomSheetConfig?.ariaLabel',
+ '[@state]': '_animationState',
+ '(@state.start)': '_onAnimationStart($event)',
+ '(@state.done)': '_onAnimationDone($event)'
+ },
+})
+export class MatBottomSheetContainer extends BasePortalOutlet implements OnDestroy {
+ private _breakpointSubscription: Subscription;
+
+ /** The portal outlet inside of this container into which the content will be loaded. */
+ @ViewChild(CdkPortalOutlet) _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();
+
+ /** The bottom sheet configuration. */
+ bottomSheetConfig: MatBottomSheetConfig;
+
+ /** 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;
+
+ constructor(
+ private _elementRef: ElementRef,
+ private _changeDetectorRef: ChangeDetectorRef,
+ private _focusTrapFactory: FocusTrapFactory,
+ breakpointObserver: BreakpointObserver,
+ @Optional() @Inject(DOCUMENT) document: any) {
+ super();
+
+ this._document = document;
+ this._breakpointSubscription = breakpointObserver
+ .observe([Breakpoints.Medium, Breakpoints.Large, Breakpoints.XLarge])
+ .subscribe(() => {
+ this._toggleClass('mat-bottom-sheet-container-medium',
+ breakpointObserver.isMatched(Breakpoints.Medium));
+ this._toggleClass('mat-bottom-sheet-container-large',
+ breakpointObserver.isMatched(Breakpoints.Large));
+ this._toggleClass('mat-bottom-sheet-container-xlarge',
+ breakpointObserver.isMatched(Breakpoints.XLarge));
+ });
+ }
+
+ /** Attach a component portal as content to this bottom sheet container. */
+ attachComponentPortal(portal: ComponentPortal): ComponentRef {
+ this._validatePortalAttached();
+ this._setPanelClass();
+ this._savePreviouslyFocusedElement();
+ return this._portalOutlet.attachComponentPortal(portal);
+ }
+
+ /** Attach a template portal as content to this bottom sheet container. */
+ attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef {
+ this._validatePortalAttached();
+ this._setPanelClass();
+ this._savePreviouslyFocusedElement();
+ return this._portalOutlet.attachTemplatePortal(portal);
+ }
+
+ /** Begin animation of bottom sheet entrance into view. */
+ enter(): void {
+ this._animationState = 'visible';
+ this._changeDetectorRef.detectChanges();
+ }
+
+ /** Begin animation of the bottom sheet exiting from view. */
+ exit(): void {
+ this._animationState = 'hidden';
+ this._changeDetectorRef.markForCheck();
+ }
+
+ ngOnDestroy() {
+ this._breakpointSubscription.unsubscribe();
+ }
+
+ _onAnimationDone(event: AnimationEvent) {
+ if (event.toState === 'visible') {
+ this._trapFocus();
+ } else if (event.toState === 'hidden') {
+ this._restoreFocus();
+ }
+
+ this._animationStateChanged.emit(event);
+ }
+
+ _onAnimationStart(event: AnimationEvent) {
+ this._animationStateChanged.emit(event);
+ }
+
+ private _toggleClass(cssClass: string, add: boolean) {
+ const classList = this._elementRef.nativeElement.classList;
+ add ? classList.add(cssClass) : classList.remove(cssClass);
+ }
+
+ private _validatePortalAttached() {
+ if (this._portalOutlet.hasAttached()) {
+ throw Error('Attempting to attach bottom sheet content after content is already attached');
+ }
+ }
+
+ private _setPanelClass() {
+ const element: HTMLElement = this._elementRef.nativeElement;
+ const panelClass = this.bottomSheetConfig.panelClass;
+
+ if (Array.isArray(panelClass)) {
+ // Note that we can't use a spread here, because IE doesn't support multiple arguments.
+ panelClass.forEach(cssClass => element.classList.add(cssClass));
+ } else if (panelClass) {
+ element.classList.add(panelClass);
+ }
+ }
+
+
+ /** Moves the focus inside the focus trap. */
+ private _trapFocus() {
+ if (!this._focusTrap) {
+ this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
+ }
+
+ this._focusTrap.focusInitialElementWhenReady();
+ }
+
+ /** Restores focus to the element that was focused before the bottom sheet opened. */
+ private _restoreFocus() {
+ const toFocus = this._elementFocusedBeforeOpened;
+
+ // We need the extra check, because IE can set the `activeElement` to null in some cases.
+ if (toFocus && typeof toFocus.focus === 'function') {
+ 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 = this._document.activeElement as HTMLElement;
+ Promise.resolve().then(() => this._elementRef.nativeElement.focus());
+ }
+}
diff --git a/src/lib/bottom-sheet/bottom-sheet-module.ts b/src/lib/bottom-sheet/bottom-sheet-module.ts
new file mode 100644
index 000000000000..29c4ebdb18d9
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-module.ts
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {MatCommonModule} from '@angular/material/core';
+import {A11yModule} from '@angular/cdk/a11y';
+import {OverlayModule} from '@angular/cdk/overlay';
+import {PortalModule} from '@angular/cdk/portal';
+import {LayoutModule} from '@angular/cdk/layout';
+import {MatBottomSheetContainer} from './bottom-sheet-container';
+import {MatBottomSheet} from './bottom-sheet';
+
+
+@NgModule({
+ imports: [
+ A11yModule,
+ CommonModule,
+ OverlayModule,
+ MatCommonModule,
+ PortalModule,
+ LayoutModule,
+ ],
+ exports: [MatBottomSheetContainer, MatCommonModule],
+ declarations: [MatBottomSheetContainer],
+ entryComponents: [MatBottomSheetContainer],
+ providers: [MatBottomSheet],
+})
+export class MatBottomSheetModule {}
diff --git a/src/lib/bottom-sheet/bottom-sheet-ref.ts b/src/lib/bottom-sheet/bottom-sheet-ref.ts
new file mode 100644
index 000000000000..479977a8a1c0
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet-ref.ts
@@ -0,0 +1,105 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {OverlayRef} from '@angular/cdk/overlay';
+import {ESCAPE} from '@angular/cdk/keycodes';
+import {Observable} from 'rxjs/Observable';
+import {Subject} from 'rxjs/Subject';
+import {merge} from 'rxjs/observable/merge';
+import {filter} from 'rxjs/operators/filter';
+import {take} from 'rxjs/operators/take';
+import {MatBottomSheetContainer} from './bottom-sheet-container';
+
+/**
+ * Reference to a bottom sheet dispatched from the bottom sheet service.
+ */
+export class MatBottomSheetRef {
+ /** Instance of the component making up the content of the bottom sheet. */
+ instance: T;
+
+ /**
+ * Instance of the component into which the bottom sheet content is projected.
+ * @docs-private
+ */
+ containerInstance: MatBottomSheetContainer;
+
+ /** Subject for notifying the user that the bottom sheet has been dismissed. */
+ private readonly _afterDismissed = new Subject();
+
+ /** Subject for notifying the user that the bottom sheet has opened and appeared. */
+ private readonly _afterOpened = new Subject();
+
+ constructor(containerInstance: MatBottomSheetContainer, private _overlayRef: OverlayRef) {
+ this.containerInstance = containerInstance;
+
+ // Emit when opening animation completes
+ containerInstance._animationStateChanged.pipe(
+ filter(event => event.phaseName === 'done' && event.toState === 'visible'),
+ take(1)
+ )
+ .subscribe(() => {
+ this._afterOpened.next();
+ this._afterOpened.complete();
+ });
+
+ // Dispose overlay when closing animation is complete
+ containerInstance._animationStateChanged.pipe(
+ filter(event => event.phaseName === 'done' && event.toState === 'hidden'),
+ take(1)
+ )
+ .subscribe(() => {
+ this._overlayRef.dispose();
+ this._afterDismissed.next();
+ this._afterDismissed.complete();
+ });
+
+ if (!containerInstance.bottomSheetConfig.disableClose) {
+ merge(
+ _overlayRef.backdropClick(),
+ _overlayRef._keydownEvents.pipe(filter(event => event.keyCode === ESCAPE))
+ ).subscribe(() => this.dismiss());
+ }
+ }
+
+ /** Dismisses the bottom sheet. */
+ dismiss(): void {
+ if (!this._afterDismissed.closed) {
+ // Transition the backdrop in parallel to the bottom sheet.
+ this.containerInstance._animationStateChanged.pipe(
+ filter(event => event.phaseName === 'start'),
+ take(1)
+ ).subscribe(() => this._overlayRef.detachBackdrop());
+
+ this.containerInstance.exit();
+ }
+ }
+
+ /** Gets an observable that is notified when the bottom sheet is finished closing. */
+ afterDismissed(): Observable {
+ return this._afterDismissed.asObservable();
+ }
+
+ /** Gets an observable that is notified when the bottom sheet has opened and appeared. */
+ afterOpened(): Observable {
+ return this._afterOpened.asObservable();
+ }
+
+ /**
+ * Gets an observable that emits when the overlay's backdrop has been clicked.
+ */
+ backdropClick(): Observable {
+ return this._overlayRef.backdropClick();
+ }
+
+ /**
+ * Gets an observable that emits when keydown events are targeted on the overlay.
+ */
+ keydownEvents(): Observable {
+ return this._overlayRef.keydownEvents();
+ }
+}
diff --git a/src/lib/bottom-sheet/bottom-sheet.spec.ts b/src/lib/bottom-sheet/bottom-sheet.spec.ts
new file mode 100644
index 000000000000..0c23327deeee
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet.spec.ts
@@ -0,0 +1,587 @@
+import {
+ Directive,
+ Component,
+ NgModule,
+ ViewContainerRef,
+ ViewChild,
+ Inject,
+ Injector,
+ TemplateRef,
+} from '@angular/core';
+import {
+ ComponentFixture,
+ fakeAsync,
+ flushMicrotasks,
+ inject,
+ TestBed,
+ tick,
+ flush,
+} from '@angular/core/testing';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {Directionality} from '@angular/cdk/bidi';
+import {MatBottomSheetModule} from './bottom-sheet-module';
+import {MatBottomSheet} from './bottom-sheet';
+import {MatBottomSheetRef} from './bottom-sheet-ref';
+import {MAT_BOTTOM_SHEET_DATA} from './bottom-sheet-config';
+import {MatBottomSheetConfig} from './bottom-sheet-config';
+import {OverlayContainer, ViewportRuler} from '@angular/cdk/overlay';
+import {A, ESCAPE} from '@angular/cdk/keycodes';
+import {dispatchKeyboardEvent} from '@angular/cdk/testing';
+
+
+describe('MatBottomSheet', () => {
+ let bottomSheet: MatBottomSheet;
+ let overlayContainer: OverlayContainer;
+ let overlayContainerElement: HTMLElement;
+ let viewportRuler: ViewportRuler;
+
+ let testViewContainerRef: ViewContainerRef;
+ let viewContainerFixture: ComponentFixture;
+
+ beforeEach(fakeAsync(() => {
+ TestBed
+ .configureTestingModule({imports: [MatBottomSheetModule, BottomSheetTestModule]})
+ .compileComponents();
+ }));
+
+ beforeEach(inject([MatBottomSheet, OverlayContainer, ViewportRuler],
+ (bs: MatBottomSheet, oc: OverlayContainer, vr: ViewportRuler) => {
+ bottomSheet = bs;
+ overlayContainer = oc;
+ viewportRuler = vr;
+ overlayContainerElement = oc.getContainerElement();
+ }));
+
+ afterEach(() => {
+ overlayContainer.ngOnDestroy();
+ });
+
+ beforeEach(() => {
+ viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer);
+
+ viewContainerFixture.detectChanges();
+ testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
+ });
+
+ it('should open a bottom sheet with a component', () => {
+ const bottomSheetRef = bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toContain('Pizza');
+ expect(bottomSheetRef.instance instanceof PizzaMsg).toBe(true);
+ expect(bottomSheetRef.instance.bottomSheetRef).toBe(bottomSheetRef);
+ });
+
+ it('should open a bottom sheet with a template', () => {
+ const templateRefFixture = TestBed.createComponent(ComponentWithTemplateRef);
+ templateRefFixture.componentInstance.localValue = 'Bees';
+ templateRefFixture.detectChanges();
+
+ const bottomSheetRef = bottomSheet.open(templateRefFixture.componentInstance.templateRef, {
+ data: {value: 'Knees'}
+ });
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toContain('Cheese Bees Knees');
+ expect(templateRefFixture.componentInstance.bottomSheetRef).toBe(bottomSheetRef);
+ });
+
+ it('should position the bottom sheet at the bottom center of the screen', () => {
+ bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+
+ viewContainerFixture.detectChanges();
+
+ const containerElement = overlayContainerElement.querySelector('mat-bottom-sheet-container')!;
+ const containerRect = containerElement.getBoundingClientRect();
+ const viewportSize = viewportRuler.getViewportSize();
+
+ expect(Math.floor(containerRect.bottom)).toBe(Math.floor(viewportSize.height));
+ expect(Math.floor(containerRect.left + containerRect.width / 2))
+ .toBe(Math.floor(viewportSize.width / 2));
+ });
+
+ it('should emit when the bottom sheet opening animation is complete', fakeAsync(() => {
+ const bottomSheetRef = bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+ const spy = jasmine.createSpy('afterOpened spy');
+
+ bottomSheetRef.afterOpened().subscribe(spy);
+ viewContainerFixture.detectChanges();
+
+ // callback should not be called before animation is complete
+ expect(spy).not.toHaveBeenCalled();
+
+ flushMicrotasks();
+ expect(spy).toHaveBeenCalled();
+ }));
+
+ it('should use the correct injector', () => {
+ const bottomSheetRef = bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+ viewContainerFixture.detectChanges();
+ const injector = bottomSheetRef.instance.injector;
+
+ expect(bottomSheetRef.instance.bottomSheetRef).toBe(bottomSheetRef);
+ expect(injector.get(DirectiveWithViewContainer)).toBeTruthy();
+ });
+
+ it('should open a bottom sheet with a component and no ViewContainerRef', () => {
+ const bottomSheetRef = bottomSheet.open(PizzaMsg);
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toContain('Pizza');
+ expect(bottomSheetRef.instance instanceof PizzaMsg).toBe(true);
+ expect(bottomSheetRef.instance.bottomSheetRef).toBe(bottomSheetRef);
+ });
+
+ it('should apply the correct role to the container element', () => {
+ bottomSheet.open(PizzaMsg);
+
+ viewContainerFixture.detectChanges();
+
+ const containerElement = overlayContainerElement.querySelector('mat-bottom-sheet-container')!;
+ expect(containerElement.getAttribute('role')).toBe('dialog');
+ });
+
+ it('should close a bottom sheet via the escape key', fakeAsync(() => {
+ bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+
+ dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
+ viewContainerFixture.detectChanges();
+ flush();
+
+ expect(overlayContainerElement.querySelector('mat-bottom-sheet-container')).toBeNull();
+ }));
+
+ it('should close when clicking on the overlay backdrop', fakeAsync(() => {
+ bottomSheet.open(PizzaMsg, {
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
+
+ backdrop.click();
+ viewContainerFixture.detectChanges();
+ flush();
+
+ expect(overlayContainerElement.querySelector('mat-bottom-sheet-container')).toBeFalsy();
+ }));
+
+ it('should emit the backdropClick stream when clicking on the overlay backdrop', fakeAsync(() => {
+ const bottomSheetRef = bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+ const spy = jasmine.createSpy('backdropClick spy');
+
+ bottomSheetRef.backdropClick().subscribe(spy);
+ viewContainerFixture.detectChanges();
+
+ const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
+
+ backdrop.click();
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ viewContainerFixture.detectChanges();
+ flush();
+
+ // Additional clicks after the bottom sheet was closed should not be emitted
+ backdrop.click();
+ expect(spy).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should emit the keyboardEvent stream when key events target the overlay', fakeAsync(() => {
+ const bottomSheetRef = bottomSheet.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
+ const spy = jasmine.createSpy('keyboardEvent spy');
+
+ bottomSheetRef.keydownEvents().subscribe(spy);
+ viewContainerFixture.detectChanges();
+
+ const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
+ const container =
+ overlayContainerElement.querySelector('mat-bottom-sheet-container') as HTMLElement;
+ dispatchKeyboardEvent(document.body, 'keydown', A);
+ dispatchKeyboardEvent(document.body, 'keydown', A, backdrop);
+ dispatchKeyboardEvent(document.body, 'keydown', A, container);
+
+ expect(spy).toHaveBeenCalledTimes(3);
+ }));
+
+ it('should allow setting the layout direction', () => {
+ bottomSheet.open(PizzaMsg, { direction: 'rtl' });
+
+ viewContainerFixture.detectChanges();
+
+ let overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
+
+ expect(overlayPane.getAttribute('dir')).toBe('rtl');
+ });
+
+ it('should be able to set a custom panel class', () => {
+ bottomSheet.open(PizzaMsg, {
+ panelClass: 'custom-panel-class',
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.custom-panel-class')).toBeTruthy();
+ });
+
+ it('should be able to set a custom aria-label', () => {
+ bottomSheet.open(PizzaMsg, {
+ ariaLabel: 'Hello there',
+ viewContainerRef: testViewContainerRef
+ });
+ viewContainerFixture.detectChanges();
+
+ const container = overlayContainerElement.querySelector('mat-bottom-sheet-container')!;
+ expect(container.getAttribute('aria-label')).toBe('Hello there');
+ });
+
+ it('should be able to get dismissed through the service', fakeAsync(() => {
+ bottomSheet.open(PizzaMsg);
+ viewContainerFixture.detectChanges();
+ expect(overlayContainerElement.childElementCount).toBeGreaterThan(0);
+
+ bottomSheet.dismiss();
+ viewContainerFixture.detectChanges();
+ flush();
+
+ expect(overlayContainerElement.childElementCount).toBe(0);
+ }));
+
+ it('should open a new bottom sheet after dismissing a previous sheet', fakeAsync(() => {
+ let config: MatBottomSheetConfig = {viewContainerRef: testViewContainerRef};
+ let bottomSheetRef: MatBottomSheetRef = bottomSheet.open(PizzaMsg, config);
+
+ viewContainerFixture.detectChanges();
+
+ bottomSheetRef.dismiss();
+ viewContainerFixture.detectChanges();
+
+ // Wait for the dismiss animation to finish.
+ flush();
+ bottomSheetRef = bottomSheet.open(TacoMsg, config);
+ viewContainerFixture.detectChanges();
+
+ // Wait for the open animation to finish.
+ flush();
+ expect(bottomSheetRef.containerInstance._animationState)
+ .toBe('visible', `Expected the animation state would be 'visible'.`);
+ }));
+
+ it('should remove past bottom sheets when opening new ones', fakeAsync(() => {
+ bottomSheet.open(PizzaMsg);
+ viewContainerFixture.detectChanges();
+
+ bottomSheet.open(TacoMsg);
+ viewContainerFixture.detectChanges();
+ flush();
+
+ expect(overlayContainerElement.textContent).toContain('Taco');
+ }));
+
+ it('should remove bottom sheet if another is shown while its still animating open',
+ fakeAsync(() => {
+ bottomSheet.open(PizzaMsg);
+ viewContainerFixture.detectChanges();
+
+ bottomSheet.open(TacoMsg);
+ viewContainerFixture.detectChanges();
+
+ tick();
+ expect(overlayContainerElement.textContent).toContain('Taco');
+ tick(500);
+ }));
+
+ describe('passing in data', () => {
+ it('should be able to pass in data', () => {
+ const config = {
+ data: {
+ stringParam: 'hello',
+ dateParam: new Date()
+ }
+ };
+
+ const instance = bottomSheet.open(BottomSheetWithInjectedData, config).instance;
+
+ expect(instance.data.stringParam).toBe(config.data.stringParam);
+ expect(instance.data.dateParam).toBe(config.data.dateParam);
+ });
+
+ it('should default to null if no data is passed', () => {
+ expect(() => {
+ const bottomSheetRef = bottomSheet.open(BottomSheetWithInjectedData);
+ expect(bottomSheetRef.instance.data).toBeNull();
+ }).not.toThrow();
+ });
+ });
+
+ describe('disableClose option', () => {
+ it('should prevent closing via clicks on the backdrop', () => {
+ bottomSheet.open(PizzaMsg, {
+ disableClose: true,
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
+ backdrop.click();
+
+ expect(overlayContainerElement.querySelector('mat-bottom-sheet-container')).toBeTruthy();
+ });
+
+ it('should prevent closing via the escape key', () => {
+ bottomSheet.open(PizzaMsg, {
+ disableClose: true,
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+ dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
+
+ expect(overlayContainerElement.querySelector('mat-bottom-sheet-container')).toBeTruthy();
+ });
+
+ });
+
+ describe('hasBackdrop option', () => {
+ it('should have a backdrop', () => {
+ bottomSheet.open(PizzaMsg, {
+ hasBackdrop: true,
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy();
+ });
+
+ it('should not have a backdrop', () => {
+ bottomSheet.open(PizzaMsg, {
+ hasBackdrop: false,
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeFalsy();
+ });
+ });
+
+ describe('backdropClass option', () => {
+ it('should have default backdrop class', () => {
+ bottomSheet.open(PizzaMsg, {
+ backdropClass: '',
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.cdk-overlay-dark-backdrop')).toBeTruthy();
+ });
+
+ it('should have custom backdrop class', () => {
+ bottomSheet.open(PizzaMsg, {
+ backdropClass: 'custom-backdrop-class',
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.custom-backdrop-class')).toBeTruthy();
+ });
+ });
+
+ describe('focus management', () => {
+ // When testing focus, all of the elements must be in the DOM.
+ beforeEach(() => document.body.appendChild(overlayContainerElement));
+ afterEach(() => document.body.removeChild(overlayContainerElement));
+
+ it('should focus the first tabbable element of the bottom sheet on open', fakeAsync(() => {
+ bottomSheet.open(PizzaMsg, {
+ viewContainerRef: testViewContainerRef
+ });
+
+ viewContainerFixture.detectChanges();
+ flushMicrotasks();
+
+ expect(document.activeElement.tagName)
+ .toBe('INPUT', 'Expected first tabbable element (input) in the sheet to be focused.');
+ }));
+
+ it('should re-focus trigger element when bottom sheet closes', fakeAsync(() => {
+ const button = document.createElement('button');
+ button.id = 'bottom-sheet-trigger';
+ document.body.appendChild(button);
+ button.focus();
+
+ const bottomSheetRef = bottomSheet.open(PizzaMsg, { viewContainerRef: testViewContainerRef });
+
+ flushMicrotasks();
+ viewContainerFixture.detectChanges();
+ flushMicrotasks();
+
+ expect(document.activeElement.id)
+ .not.toBe('bottom-sheet-trigger', 'Expected the focus to change when sheet was opened.');
+
+ bottomSheetRef.dismiss();
+ expect(document.activeElement.id).not.toBe('bottom-sheet-trigger',
+ 'Expcted the focus not to have changed before the animation finishes.');
+
+ flushMicrotasks();
+ viewContainerFixture.detectChanges();
+ tick(500);
+
+ expect(document.activeElement.id).toBe('bottom-sheet-trigger',
+ 'Expected that the trigger was refocused after the sheet is closed.');
+
+ document.body.removeChild(button);
+ }));
+
+ });
+
+});
+
+describe('MatBottomSheet with parent MatBottomSheet', () => {
+ let parentBottomSheet: MatBottomSheet;
+ let childBottomSheet: MatBottomSheet;
+ let overlayContainer: OverlayContainer;
+ let overlayContainerElement: HTMLElement;
+ let fixture: ComponentFixture;
+
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [MatBottomSheetModule, BottomSheetTestModule, NoopAnimationsModule],
+ declarations: [ComponentThatProvidesMatBottomSheet],
+ }).compileComponents();
+ }));
+
+ beforeEach(inject([MatBottomSheet, OverlayContainer],
+ (bs: MatBottomSheet, oc: OverlayContainer) => {
+ parentBottomSheet = bs;
+ overlayContainer = oc;
+ overlayContainerElement = oc.getContainerElement();
+ fixture = TestBed.createComponent(ComponentThatProvidesMatBottomSheet);
+ childBottomSheet = fixture.componentInstance.bottomSheet;
+ fixture.detectChanges();
+ }));
+
+ afterEach(() => {
+ overlayContainer.ngOnDestroy();
+ });
+
+ it('should close bottom sheets opened by parent when opening from child', fakeAsync(() => {
+ parentBottomSheet.open(PizzaMsg);
+ fixture.detectChanges();
+ tick(1000);
+
+ expect(overlayContainerElement.textContent)
+ .toContain('Pizza', 'Expected a bottom sheet to be opened');
+
+ childBottomSheet.open(TacoMsg);
+ fixture.detectChanges();
+ tick(1000);
+
+ expect(overlayContainerElement.textContent)
+ .toContain('Taco', 'Expected parent bottom sheet to be dismissed by opening from child');
+ }));
+
+ it('should close bottom sheets opened by child when opening from parent', fakeAsync(() => {
+ childBottomSheet.open(PizzaMsg);
+ fixture.detectChanges();
+ tick(1000);
+
+ expect(overlayContainerElement.textContent)
+ .toContain('Pizza', 'Expected a bottom sheet to be opened');
+
+ parentBottomSheet.open(TacoMsg);
+ fixture.detectChanges();
+ tick(1000);
+
+ expect(overlayContainerElement.textContent)
+ .toContain('Taco', 'Expected child bottom sheet to be dismissed by opening from parent');
+ }));
+});
+
+
+@Directive({selector: 'dir-with-view-container'})
+class DirectiveWithViewContainer {
+ constructor(public viewContainerRef: ViewContainerRef) { }
+}
+
+@Component({template: ``})
+class ComponentWithChildViewContainer {
+ @ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer;
+
+ get childViewContainer() {
+ return this.childWithViewContainer.viewContainerRef;
+ }
+}
+
+@Component({
+ selector: 'arbitrary-component-with-template-ref',
+ template: `
+ Cheese {{localValue}} {{data?.value}}{{setRef(bottomSheetRef)}}`,
+})
+class ComponentWithTemplateRef {
+ localValue: string;
+ bottomSheetRef: MatBottomSheetRef;
+
+ @ViewChild(TemplateRef) templateRef: TemplateRef;
+
+ setRef(bottomSheetRef: MatBottomSheetRef): string {
+ this.bottomSheetRef = bottomSheetRef;
+ return '';
+ }
+}
+
+@Component({template: 'Pizza
'})
+class PizzaMsg {
+ constructor(public bottomSheetRef: MatBottomSheetRef,
+ public injector: Injector,
+ public directionality: Directionality) {}
+}
+
+@Component({template: 'Taco
'})
+class TacoMsg {}
+
+@Component({
+ template: '',
+ providers: [MatBottomSheet]
+})
+class ComponentThatProvidesMatBottomSheet {
+ constructor(public bottomSheet: MatBottomSheet) {}
+}
+
+@Component({template: ''})
+class BottomSheetWithInjectedData {
+ constructor(@Inject(MAT_BOTTOM_SHEET_DATA) public data: any) { }
+}
+
+// Create a real (non-test) NgModule as a workaround for
+// https://github.com/angular/angular/issues/10760
+const TEST_DIRECTIVES = [
+ ComponentWithChildViewContainer,
+ ComponentWithTemplateRef,
+ PizzaMsg,
+ TacoMsg,
+ DirectiveWithViewContainer,
+ BottomSheetWithInjectedData,
+];
+
+@NgModule({
+ imports: [MatBottomSheetModule, NoopAnimationsModule],
+ exports: TEST_DIRECTIVES,
+ declarations: TEST_DIRECTIVES,
+ entryComponents: [
+ ComponentWithChildViewContainer,
+ ComponentWithTemplateRef,
+ PizzaMsg,
+ TacoMsg,
+ BottomSheetWithInjectedData,
+ ],
+})
+class BottomSheetTestModule { }
diff --git a/src/lib/bottom-sheet/bottom-sheet.ts b/src/lib/bottom-sheet/bottom-sheet.ts
new file mode 100644
index 000000000000..e30f9974c6a0
--- /dev/null
+++ b/src/lib/bottom-sheet/bottom-sheet.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+import {Overlay, OverlayConfig, OverlayRef} from '@angular/cdk/overlay';
+import {ComponentPortal, TemplatePortal, ComponentType, PortalInjector} from '@angular/cdk/portal';
+import {ComponentRef, TemplateRef, Injectable, Injector, Optional, SkipSelf} from '@angular/core';
+import {MatBottomSheetConfig, MAT_BOTTOM_SHEET_DATA} from './bottom-sheet-config';
+import {MatBottomSheetRef} from './bottom-sheet-ref';
+import {MatBottomSheetContainer} from './bottom-sheet-container';
+
+/**
+ * Service to trigger Material Design bottom sheets.
+ */
+@Injectable()
+export class MatBottomSheet {
+ private _bottomSheetRefAtThisLevel: MatBottomSheetRef | null = null;
+
+ /** Reference to the currently opened bottom sheet. */
+ get _openedBottomSheetRef(): MatBottomSheetRef | null {
+ const parent = this._parentBottomSheet;
+ return parent ? parent._openedBottomSheetRef : this._bottomSheetRefAtThisLevel;
+ }
+
+ set _openedBottomSheetRef(value: MatBottomSheetRef | null) {
+ if (this._parentBottomSheet) {
+ this._parentBottomSheet._openedBottomSheetRef = value;
+ } else {
+ this._bottomSheetRefAtThisLevel = value;
+ }
+ }
+
+ constructor(
+ private _overlay: Overlay,
+ private _injector: Injector,
+ @Optional() @SkipSelf() private _parentBottomSheet: MatBottomSheet) {}
+
+ open(component: ComponentType,
+ config?: MatBottomSheetConfig): MatBottomSheetRef;
+ open(template: TemplateRef,
+ config?: MatBottomSheetConfig): MatBottomSheetRef;
+
+ open(componentOrTemplateRef: ComponentType | TemplateRef,
+ config?: MatBottomSheetConfig): MatBottomSheetRef {
+
+ const _config = _applyConfigDefaults(config);
+ const overlayRef = this._createOverlay(_config);
+ const container = this._attachContainer(overlayRef, _config);
+ const ref = new MatBottomSheetRef(container, overlayRef);
+
+ if (componentOrTemplateRef instanceof TemplateRef) {
+ container.attachTemplatePortal(new TemplatePortal(componentOrTemplateRef, null!, {
+ $implicit: _config.data,
+ bottomSheetRef: ref
+ } as any));
+ } else {
+ const portal = new ComponentPortal(componentOrTemplateRef, undefined,
+ this._createInjector(_config, ref));
+ const contentRef = container.attachComponentPortal(portal);
+ ref.instance = contentRef.instance;
+ }
+
+ // When the bottom sheet is dismissed, clear the reference to it.
+ ref.afterDismissed().subscribe(() => {
+ // Clear the bottom sheet ref if it hasn't already been replaced by a newer one.
+ if (this._openedBottomSheetRef == ref) {
+ this._openedBottomSheetRef = null;
+ }
+ });
+
+ if (this._openedBottomSheetRef) {
+ // If a bottom sheet is already in view, dismiss it and enter the
+ // new bottom sheet after exit animation is complete.
+ this._openedBottomSheetRef.afterDismissed().subscribe(() => ref.containerInstance.enter());
+ this._openedBottomSheetRef.dismiss();
+ } else {
+ // If no bottom sheet is in view, enter the new bottom sheet.
+ ref.containerInstance.enter();
+ }
+
+ this._openedBottomSheetRef = ref;
+
+ return ref;
+ }
+
+ /**
+ * Dismisses the currently-visible bottom sheet.
+ */
+ dismiss(): void {
+ if (this._openedBottomSheetRef) {
+ this._openedBottomSheetRef.dismiss();
+ }
+ }
+
+ /**
+ * Attaches the bottom sheet container component to the overlay.
+ */
+ private _attachContainer(overlayRef: OverlayRef,
+ config: MatBottomSheetConfig): MatBottomSheetContainer {
+ const containerPortal = new ComponentPortal(MatBottomSheetContainer, config.viewContainerRef);
+ const containerRef: ComponentRef = overlayRef.attach(containerPortal);
+ containerRef.instance.bottomSheetConfig = config;
+ return containerRef.instance;
+ }
+
+ /**
+ * Creates a new overlay and places it in the correct location.
+ * @param config The user-specified bottom sheet config.
+ */
+ private _createOverlay(config: MatBottomSheetConfig): OverlayRef {
+ const overlayConfig = new OverlayConfig({
+ direction: config.direction,
+ hasBackdrop: config.hasBackdrop,
+ maxWidth: '100%',
+ scrollStrategy: this._overlay.scrollStrategies.block(),
+ positionStrategy: this._overlay.position()
+ .global()
+ .centerHorizontally()
+ .bottom('0')
+ });
+
+ if (config.backdropClass) {
+ overlayConfig.backdropClass = config.backdropClass;
+ }
+
+ return this._overlay.create(overlayConfig);
+ }
+
+ /**
+ * Creates an injector to be used inside of a bottom sheet component.
+ * @param config Config that was used to create the bottom sheet.
+ * @param bottomSheetRef Reference to the bottom sheet.
+ */
+ private _createInjector(config: MatBottomSheetConfig,
+ bottomSheetRef: MatBottomSheetRef): PortalInjector {
+
+ const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector;
+ const injectionTokens = new WeakMap();
+
+ injectionTokens.set(MatBottomSheetRef, bottomSheetRef);
+ injectionTokens.set(MAT_BOTTOM_SHEET_DATA, config.data);
+
+ return new PortalInjector(userInjector || this._injector, injectionTokens);
+ }
+}
+
+/**
+ * Applies default options to the bottom sheet config.
+ * @param config The configuration to which the defaults will be applied.
+ * @returns The new configuration object with defaults applied.
+ */
+function _applyConfigDefaults(config?: MatBottomSheetConfig): MatBottomSheetConfig {
+ return {...new MatBottomSheetConfig(), ...config};
+}
diff --git a/src/lib/bottom-sheet/index.ts b/src/lib/bottom-sheet/index.ts
new file mode 100644
index 000000000000..676ca90f1ffa
--- /dev/null
+++ b/src/lib/bottom-sheet/index.ts
@@ -0,0 +1,9 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+export * from './public-api';
diff --git a/src/lib/bottom-sheet/public-api.ts b/src/lib/bottom-sheet/public-api.ts
new file mode 100644
index 000000000000..b7f80c6cb525
--- /dev/null
+++ b/src/lib/bottom-sheet/public-api.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+
+export * from './bottom-sheet-module';
+export * from './bottom-sheet';
+export * from './bottom-sheet-config';
+export * from './bottom-sheet-container';
+export * from './bottom-sheet-animations';
+export * from './bottom-sheet-ref';
diff --git a/src/lib/bottom-sheet/tsconfig-build.json b/src/lib/bottom-sheet/tsconfig-build.json
new file mode 100644
index 000000000000..359a4c74093c
--- /dev/null
+++ b/src/lib/bottom-sheet/tsconfig-build.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../tsconfig-build",
+ "files": [
+ "public-api.ts",
+ "../typings.d.ts"
+ ],
+ "angularCompilerOptions": {
+ "annotateForClosureCompiler": true,
+ "strictMetadataEmit": true,
+ "flatModuleOutFile": "index.js",
+ "flatModuleId": "@angular/material/bottom-sheet",
+ "skipTemplateCodegen": true,
+ "fullTemplateTypeCheck": true
+ }
+}
diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss
index 630c537068b5..744aa5f6a4f1 100644
--- a/src/lib/core/theming/_all-theme.scss
+++ b/src/lib/core/theming/_all-theme.scss
@@ -1,6 +1,7 @@
// Import all the theming functionality.
@import '../core';
@import '../../autocomplete/autocomplete-theme';
+@import '../../bottom-sheet/bottom-sheet-theme';
@import '../../button/button-theme';
@import '../../button-toggle/button-toggle-theme';
@import '../../card/card-theme';
@@ -36,6 +37,7 @@
@mixin angular-material-theme($theme) {
@include mat-core-theme($theme);
@include mat-autocomplete-theme($theme);
+ @include mat-bottom-sheet-theme($theme);
@include mat-button-theme($theme);
@include mat-button-toggle-theme($theme);
@include mat-card-theme($theme);
diff --git a/src/lib/core/typography/_all-typography.scss b/src/lib/core/typography/_all-typography.scss
index a0766383a781..fafa324df737 100644
--- a/src/lib/core/typography/_all-typography.scss
+++ b/src/lib/core/typography/_all-typography.scss
@@ -1,5 +1,6 @@
@import './typography';
@import '../../autocomplete/autocomplete-theme';
+@import '../../bottom-sheet/bottom-sheet-theme';
@import '../../button/button-theme';
@import '../../button-toggle/button-toggle-theme';
@import '../../card/card-theme';
@@ -40,6 +41,7 @@
@include mat-base-typography($config);
@include mat-autocomplete-typography($config);
+ @include mat-bottom-sheet-typography($config);
@include mat-button-typography($config);
@include mat-button-toggle-typography($config);
@include mat-card-typography($config);
diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts
index e802108c32a4..42ab730a5b31 100644
--- a/src/lib/public-api.ts
+++ b/src/lib/public-api.ts
@@ -8,6 +8,7 @@
export * from './version';
export * from '@angular/material/autocomplete';
+export * from '@angular/material/bottom-sheet';
export * from '@angular/material/button';
export * from '@angular/material/button-toggle';
export * from '@angular/material/card';
diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js
index 0bb2ecd4aa3f..8a69f544e98b 100644
--- a/test/karma-test-shim.js
+++ b/test/karma-test-shim.js
@@ -71,6 +71,7 @@ System.config({
'@angular/cdk/testing': 'dist/packages/cdk/testing/index.js',
'@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js',
+ '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js',
'@angular/material/button': 'dist/packages/material/button/index.js',
'@angular/material/button-toggle': 'dist/packages/material/button-toggle/index.js',
'@angular/material/card': 'dist/packages/material/card/index.js',