From bbf62cdd6c1b600e1269ed84351dfc1a08659b42 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 6 Feb 2018 18:31:11 +0100 Subject: [PATCH] feat: add bottom sheet component (#9764) * Adds an initial implementation of the new `MatBottomSheet` service that allows users to display component-based or template-based bottom sheets. * Sets up the various boilerplate and infrastructure necessary for a new component. Note: this is an initial implementation that has the necessary functionality, styles and accessibility. More docs, examples and touch gestures will be added in a follow-up. --- .github/CODEOWNERS | 2 + .../bottom-sheet/bottom-sheet-demo.html | 45 ++ .../bottom-sheet/bottom-sheet-demo.scss | 8 + .../bottom-sheet/bottom-sheet-demo.ts | 66 ++ src/demo-app/demo-app/demo-app.ts | 1 + src/demo-app/demo-app/demo-module.ts | 4 + src/demo-app/demo-app/routes.ts | 2 + src/demo-app/demo-material-module.ts | 2 + src/demo-app/system-config.ts | 1 + src/e2e-app/system-config.ts | 1 + src/lib/bottom-sheet/BUILD.bazel | 38 ++ src/lib/bottom-sheet/README.md | 1 + src/lib/bottom-sheet/_bottom-sheet-theme.scss | 21 + .../bottom-sheet/bottom-sheet-animations.ts | 31 + src/lib/bottom-sheet/bottom-sheet-config.ts | 42 ++ .../bottom-sheet/bottom-sheet-container.html | 1 + .../bottom-sheet/bottom-sheet-container.scss | 31 + .../bottom-sheet/bottom-sheet-container.ts | 205 ++++++ src/lib/bottom-sheet/bottom-sheet-module.ts | 34 + src/lib/bottom-sheet/bottom-sheet-ref.ts | 105 ++++ src/lib/bottom-sheet/bottom-sheet.spec.ts | 587 ++++++++++++++++++ src/lib/bottom-sheet/bottom-sheet.ts | 158 +++++ src/lib/bottom-sheet/index.ts | 9 + src/lib/bottom-sheet/public-api.ts | 14 + src/lib/bottom-sheet/tsconfig-build.json | 15 + src/lib/core/theming/_all-theme.scss | 2 + src/lib/core/typography/_all-typography.scss | 2 + src/lib/public-api.ts | 1 + test/karma-test-shim.js | 1 + 29 files changed, 1430 insertions(+) create mode 100644 src/demo-app/bottom-sheet/bottom-sheet-demo.html create mode 100644 src/demo-app/bottom-sheet/bottom-sheet-demo.scss create mode 100644 src/demo-app/bottom-sheet/bottom-sheet-demo.ts create mode 100644 src/lib/bottom-sheet/BUILD.bazel create mode 100644 src/lib/bottom-sheet/README.md create mode 100644 src/lib/bottom-sheet/_bottom-sheet-theme.scss create mode 100644 src/lib/bottom-sheet/bottom-sheet-animations.ts create mode 100644 src/lib/bottom-sheet/bottom-sheet-config.ts create mode 100644 src/lib/bottom-sheet/bottom-sheet-container.html create mode 100644 src/lib/bottom-sheet/bottom-sheet-container.scss create mode 100644 src/lib/bottom-sheet/bottom-sheet-container.ts create mode 100644 src/lib/bottom-sheet/bottom-sheet-module.ts create mode 100644 src/lib/bottom-sheet/bottom-sheet-ref.ts create mode 100644 src/lib/bottom-sheet/bottom-sheet.spec.ts create mode 100644 src/lib/bottom-sheet/bottom-sheet.ts create mode 100644 src/lib/bottom-sheet/index.ts create mode 100644 src/lib/bottom-sheet/public-api.ts create mode 100644 src/lib/bottom-sheet/tsconfig-build.json 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 7e4ffac77e73..eec0b7e6cc21 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -17,6 +17,7 @@ import {CdkTableModule} from '@angular/cdk/table'; import {NgModule} from '@angular/core'; import { MatAutocompleteModule, + MatBottomSheetModule, MatButtonModule, MatButtonToggleModule, MatCardModule, @@ -57,6 +58,7 @@ import { @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',