mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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.
This commit is contained in:
committed by
mmalerba
parent
4a5287cd1c
commit
bbf62cdd6c
@@ -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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<h1>Bottom sheet demo</h1>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="openComponent()">Open component sheet</button>
|
||||
<button mat-raised-button color="accent" (click)="openTemplate()">Open template sheet</button>
|
||||
|
||||
<mat-card class="demo-dialog-card">
|
||||
<mat-card-content>
|
||||
<h2>Options</h2>
|
||||
|
||||
<p>
|
||||
<mat-checkbox [(ngModel)]="config.hasBackdrop">Has backdrop</mat-checkbox>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<mat-checkbox [(ngModel)]="config.disableClose">Disable close</mat-checkbox>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<mat-form-field>
|
||||
<input matInput [(ngModel)]="config.backdropClass" placeholder="Backdrop class">
|
||||
</mat-form-field>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<mat-form-field>
|
||||
<mat-select placeholder="Direction" [(ngModel)]="config.direction">
|
||||
<mat-option value="ltr">LTR</mat-option>
|
||||
<mat-option value="rtl">RTL</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</p>
|
||||
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
|
||||
<ng-template let-bottomSheetRef="bottomSheetRef">
|
||||
<mat-nav-list>
|
||||
<mat-list-item (click)="bottomSheetRef.dismiss()" *ngFor="let action of [1, 2, 3]">
|
||||
<mat-icon mat-list-icon>folder</mat-icon>
|
||||
<span mat-line>Action {{ link }}</span>
|
||||
<span mat-line>Description</span>
|
||||
</mat-list-item>
|
||||
</mat-nav-list>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,8 @@
|
||||
.demo-dialog-card {
|
||||
max-width: 405px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.mat-raised-button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
@@ -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<any>;
|
||||
|
||||
constructor(private _bottomSheet: MatBottomSheet) {}
|
||||
|
||||
openComponent() {
|
||||
this._bottomSheet.open(ExampleBottomSheet, this.config);
|
||||
}
|
||||
|
||||
openTemplate() {
|
||||
this._bottomSheet.open(this.template, this.config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<mat-nav-list>
|
||||
<a href="#" mat-list-item (click)="handleClick($event)" *ngFor="let action of [1, 2, 3]">
|
||||
<mat-icon mat-list-icon>folder</mat-icon>
|
||||
<span mat-line>Action {{ link }}</span>
|
||||
<span mat-line>Description</span>
|
||||
</a>
|
||||
</mat-nav-list>
|
||||
`
|
||||
})
|
||||
export class ExampleBottomSheet {
|
||||
constructor(private sheet: MatBottomSheetRef) {}
|
||||
|
||||
handleClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
this.sheet.dismiss();
|
||||
}
|
||||
}
|
||||
@@ -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'},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) > $@",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
Please see the official documentation at https://material.angular.io/components/component/bottom-sheet
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`)),
|
||||
])
|
||||
};
|
||||
@@ -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<any>('MatBottomSheetData');
|
||||
|
||||
/**
|
||||
* Configuration used when opening a bottom sheet.
|
||||
*/
|
||||
export class MatBottomSheetConfig<D = any> {
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<ng-template cdkPortalOutlet></ng-template>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<AnimationEvent>();
|
||||
|
||||
/** 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<T>(portal: ComponentPortal<T>): ComponentRef<T> {
|
||||
this._validatePortalAttached();
|
||||
this._setPanelClass();
|
||||
this._savePreviouslyFocusedElement();
|
||||
return this._portalOutlet.attachComponentPortal(portal);
|
||||
}
|
||||
|
||||
/** Attach a template portal as content to this bottom sheet container. */
|
||||
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
|
||||
this._validatePortalAttached();
|
||||
this._setPanelClass();
|
||||
this._savePreviouslyFocusedElement();
|
||||
return this._portalOutlet.attachTemplatePortal(portal);
|
||||
}
|
||||
|
||||
/** 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());
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<T = any> {
|
||||
/** 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<void>();
|
||||
|
||||
/** Subject for notifying the user that the bottom sheet has opened and appeared. */
|
||||
private readonly _afterOpened = new Subject<void>();
|
||||
|
||||
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<void> {
|
||||
return this._afterDismissed.asObservable();
|
||||
}
|
||||
|
||||
/** Gets an observable that is notified when the bottom sheet has opened and appeared. */
|
||||
afterOpened(): Observable<void> {
|
||||
return this._afterOpened.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an observable that emits when the overlay's backdrop has been clicked.
|
||||
*/
|
||||
backdropClick(): Observable<MouseEvent> {
|
||||
return this._overlayRef.backdropClick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an observable that emits when keydown events are targeted on the overlay.
|
||||
*/
|
||||
keydownEvents(): Observable<KeyboardEvent> {
|
||||
return this._overlayRef.keydownEvents();
|
||||
}
|
||||
}
|
||||
@@ -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<ComponentWithChildViewContainer>;
|
||||
|
||||
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>(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<any> = 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<ComponentThatProvidesMatBottomSheet>;
|
||||
|
||||
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: `<dir-with-view-container></dir-with-view-container>`})
|
||||
class ComponentWithChildViewContainer {
|
||||
@ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer;
|
||||
|
||||
get childViewContainer() {
|
||||
return this.childWithViewContainer.viewContainerRef;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'arbitrary-component-with-template-ref',
|
||||
template: `<ng-template let-data let-bottomSheetRef="bottomSheetRef">
|
||||
Cheese {{localValue}} {{data?.value}}{{setRef(bottomSheetRef)}}</ng-template>`,
|
||||
})
|
||||
class ComponentWithTemplateRef {
|
||||
localValue: string;
|
||||
bottomSheetRef: MatBottomSheetRef<any>;
|
||||
|
||||
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
|
||||
|
||||
setRef(bottomSheetRef: MatBottomSheetRef<any>): string {
|
||||
this.bottomSheetRef = bottomSheetRef;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@Component({template: '<p>Pizza</p> <input> <button>Close</button>'})
|
||||
class PizzaMsg {
|
||||
constructor(public bottomSheetRef: MatBottomSheetRef<PizzaMsg>,
|
||||
public injector: Injector,
|
||||
public directionality: Directionality) {}
|
||||
}
|
||||
|
||||
@Component({template: '<p>Taco</p>'})
|
||||
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 { }
|
||||
@@ -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<any> | null = null;
|
||||
|
||||
/** Reference to the currently opened bottom sheet. */
|
||||
get _openedBottomSheetRef(): MatBottomSheetRef<any> | null {
|
||||
const parent = this._parentBottomSheet;
|
||||
return parent ? parent._openedBottomSheetRef : this._bottomSheetRefAtThisLevel;
|
||||
}
|
||||
|
||||
set _openedBottomSheetRef(value: MatBottomSheetRef<any> | 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<T, D = any>(component: ComponentType<T>,
|
||||
config?: MatBottomSheetConfig<D>): MatBottomSheetRef<T>;
|
||||
open<T, D = any>(template: TemplateRef<T>,
|
||||
config?: MatBottomSheetConfig<D>): MatBottomSheetRef<T>;
|
||||
|
||||
open<T, D = any>(componentOrTemplateRef: ComponentType<T> | TemplateRef<T>,
|
||||
config?: MatBottomSheetConfig<D>): MatBottomSheetRef<T> {
|
||||
|
||||
const _config = _applyConfigDefaults(config);
|
||||
const overlayRef = this._createOverlay(_config);
|
||||
const container = this._attachContainer(overlayRef, _config);
|
||||
const ref = new MatBottomSheetRef<T>(container, overlayRef);
|
||||
|
||||
if (componentOrTemplateRef instanceof TemplateRef) {
|
||||
container.attachTemplatePortal(new TemplatePortal<T>(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<MatBottomSheetContainer> = 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<T>(config: MatBottomSheetConfig,
|
||||
bottomSheetRef: MatBottomSheetRef<T>): 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};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user