diff --git a/projects/angular-showcase/src/app/app.component.scss b/projects/angular-showcase/src/app/app.component.scss index f6f2441786..b38094193d 100644 --- a/projects/angular-showcase/src/app/app.component.scss +++ b/projects/angular-showcase/src/app/app.component.scss @@ -21,7 +21,7 @@ top: 0; height: 48px; border-bottom: solid 1px #cccccc; - z-index: 2000; + z-index: 980; a { height: 48px; @@ -45,7 +45,7 @@ width: 300px; height: 100%; top: 0; - z-index: 1000; + z-index: 980; transition: all 0.3s ease; overflow-y: auto; @@ -101,7 +101,7 @@ transform: rotate(0deg); transition: all 0.3s ease; cursor: pointer; - z-index: 3000; + z-index: 990; span { display: block; diff --git a/projects/angular-showcase/src/app/business/business-examples/business-examples.module.ts b/projects/angular-showcase/src/app/business/business-examples/business-examples.module.ts index 4d1a28346f..37e8696754 100644 --- a/projects/angular-showcase/src/app/business/business-examples/business-examples.module.ts +++ b/projects/angular-showcase/src/app/business/business-examples/business-examples.module.ts @@ -5,13 +5,23 @@ import { RouterModule } from '@angular/router'; import { ButtonModule } from '@sbb-esta/angular-business/button'; import { CheckboxModule } from '@sbb-esta/angular-business/checkbox'; import { ContextmenuModule } from '@sbb-esta/angular-business/contextmenu'; +import { DialogModule } from '@sbb-esta/angular-business/dialog'; import { FieldModule } from '@sbb-esta/angular-business/field'; import { HeaderModule } from '@sbb-esta/angular-business/header'; import { ProcessflowModule } from '@sbb-esta/angular-business/processflow'; +import { RadioButtonModule } from '@sbb-esta/angular-business/radio-button'; import { TooltipModule } from '@sbb-esta/angular-business/tooltip'; import { UserMenuModule } from '@sbb-esta/angular-business/usermenu'; import { IconCollectionModule } from '@sbb-esta/angular-icons'; +import { + DialogShowcaseComponent, + DialogShowcaseExample2Component, + DialogShowcaseExample2ContentComponent, + DialogShowcaseExample3Component, + DialogShowcaseExampleComponent, + DialogShowcaseExampleContentComponent +} from './dialog-showcase/dialog-showcase.component'; import { SimpleContextmenuComponent } from './simple-contextmenu/simple-contextmenu.component'; import { SkippableProcessflowComponent } from './skippable-processflow/skippable-processflow.component'; import { TooltipShowcaseComponent } from './tooltip-showcase/tooltip-showcase.component'; @@ -21,7 +31,13 @@ const exampleComponents = [ SimpleContextmenuComponent, SkippableProcessflowComponent, TooltipShowcaseComponent, - UsermenuShowcaseComponent + UsermenuShowcaseComponent, + DialogShowcaseComponent, + DialogShowcaseExampleComponent, + DialogShowcaseExampleContentComponent, + DialogShowcaseExample2Component, + DialogShowcaseExample2ContentComponent, + DialogShowcaseExample3Component ]; @NgModule({ @@ -41,7 +57,9 @@ const exampleComponents = [ HeaderModule, ProcessflowModule, TooltipModule, - UserMenuModule + UserMenuModule, + DialogModule, + RadioButtonModule ] }) export class BusinessExamplesModule {} diff --git a/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-1.component.html b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-1.component.html new file mode 100644 index 0000000000..47770731d1 --- /dev/null +++ b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-1.component.html @@ -0,0 +1,19 @@ +
+

Hi {{ data.name }}

+
+
+
+ What's your favorite animal? + + + +
+
+
+ + +
diff --git a/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-2.component.html b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-2.component.html new file mode 100644 index 0000000000..cb5e1616f6 --- /dev/null +++ b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-2.component.html @@ -0,0 +1,97 @@ +
+

Install Angular

+
+
+
+

+ Learn one way to build applications with Angular and reuse your code and abilities to build + apps for any deployment target. For web, mobile web, native mobile and native desktop. +

+ +

Speed & Performance

+

+ Achieve the maximum speed possible on the Web Platform today, and take it further, via Web + Workers and server-side rendering. Angular puts you in control over scalability. Meet huge + data requirements by building data models on RxJS, Immutable.js or another push-model. +

+ +

Incredible tooling

+

+ Build features quickly with simple, declarative templates. Extend the template language with + your own components and use a wide array of existing components. Get immediate + Angular-specific help and feedback with nearly every IDE and editor. All this comes together + so you can focus on building amazing apps rather than trying to make the code work. +

+ +

Loved by millions

+

+ From prototype through global deployment, Angular delivers the productivity and scalable + infrastructure that supports Google's largest applications. +

+ +

What is Angular?

+ +

+ Angular is a platform that makes it easy to build applications with the web. Angular combines + declarative templates, dependency injection, end to end tooling, and integrated best practices + to solve development challenges. Angular empowers developers to build applications that live + on the web, mobile, or the desktop +

+ +

Architecture overview

+ +

+ Angular is a platform and framework for building client applications in HTML and TypeScript. + Angular is itself written in TypeScript. It implements core and optional functionality as a + set of TypeScript libraries that you import into your apps. +

+ +

+ The basic building blocks of an Angular application are NgModules, which provide a compilation + context for components. NgModules collect related code into functional sets; an Angular app is + defined by a set of NgModules. An app always has at least a root module that enables + bootstrapping, and typically has many more feature modules. +

+ +

+ Components define views, which are sets of screen elements that Angular can choose among and + modify according to your program logic and data. Every app has at least a root component. +

+ +

+ Components use services, which provide specific functionality not directly related to views. + Service providers can be injected into components as dependencies, making your code modular, + reusable, and efficient. +

+ +

+ Both components and services are simply classes, with decorators that mark their type and + provide metadata that tells Angular how to use them. +

+ +

+ The metadata for a component class associates it with a template that defines a view. A + template combines ordinary HTML with Angular directives and binding markup that allow Angular + to modify the HTML before rendering it for display. +

+ +

+ The metadata for a service class provides the information Angular needs to make it available + to components through Dependency Injection (DI). +

+ +

+ An app's components typically define many views, arranged hierarchically. Angular provides the + Router service to help you define navigation paths among views. The router provides + sophisticated in-browser navigational capabilities. END +

+
+
+
+ + +
diff --git a/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-3.component.html b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-3.component.html new file mode 100644 index 0000000000..8535ecbaa9 --- /dev/null +++ b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase-content-3.component.html @@ -0,0 +1,33 @@ +
+ +
+ + +
+
+

Lorem Ipsum

+
+
+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation + ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +

+
+
+
+ + +
+
+
diff --git a/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.html b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.html new file mode 100644 index 0000000000..bc17c8d8e9 --- /dev/null +++ b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.html @@ -0,0 +1,42 @@ +
+
+
+

Dialog sharing data

+ +
+
+

Description

+

+ This implementation shows how to share data between parent component and dialog component. +

+
+
+ +
+
+

Dialog with content loaded from Component

+ +
+
+

Description

+

+ This dialog loads its content from a component and shows custom content inside the header, + scrollable content and footer. Additionally it makes use of the configuration options to set + a fixed width, height and top offset. +

+
+
+ +
+
+

Dialog with content loaded from Template

+ +
+
+

Description

+

+ Dialog with content loaded from a TemplateRef. +

+
+
+
diff --git a/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.scss b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.scss new file mode 100644 index 0000000000..9bddd46b39 --- /dev/null +++ b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.scss @@ -0,0 +1,31 @@ +.sbbsc-dialog-example-icon-1, +.sbbsc-dialog-example-icon-2 { + height: 20px; + vertical-align: middle; +} + +.sbbsc-dialog-example-icon-1 { + width: 20px; +} + +.sbbsc-dialog-example-icon-2 { + width: 60px; + fill: #fff; + background: #eb0000; +} + +.sbbsc-lb-disableclose-c-1 { + text-align: center; +} + +.sbbsc-lb-disableclose-c-2 { + text-align: center; + + h3 { + margin-bottom: 1em; + } + + button[mode='ghost'] { + margin-right: 8px; + } +} diff --git a/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.ts b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.ts new file mode 100644 index 0000000000..19cd223fbb --- /dev/null +++ b/projects/angular-showcase/src/app/business/business-examples/dialog-showcase/dialog-showcase.component.ts @@ -0,0 +1,128 @@ +import { Component, Inject, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { Dialog, DIALOG_DATA, DialogRef } from '@sbb-esta/angular-business/dialog'; + +export interface DialogData { + animal: string; + name: string; +} + +/** + * Dialog sharing data + */ +@Component({ + selector: 'sbb-dialog-showcase-content-1', + templateUrl: 'dialog-showcase-content-1.component.html' +}) +export class DialogShowcaseExampleContentComponent { + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: DialogData + ) {} + + noThanks(): void { + this.dialogRef.close(); + } +} + +@Component({ + selector: 'sbb-dialog-showcase-example', + template: ` +
    +
  1. + +
  2. +
  3. + +
  4. +
  5. + You chose: {{ animal }} +
  6. +
+ ` +}) +export class DialogShowcaseExampleComponent { + animal: string; + name: string; + + constructor(public dialog: Dialog) {} + + openDialog(): void { + const dialogRef = this.dialog.open(DialogShowcaseExampleContentComponent, { + data: { name: this.name, animal: this.animal } + }); + + dialogRef.afterClosed().subscribe(result => { + console.log('Dialog sharing data was closed'); + this.animal = result; + }); + } +} + +/** + * Dialog with content loaded from component, footer button bar + */ +@Component({ + selector: 'sbb-dialog-showcase-content-2', + templateUrl: 'dialog-showcase-content-2.component.html' +}) +export class DialogShowcaseExample2ContentComponent {} + +/** + * @title Dialog with header, scrollable content and actions + */ +@Component({ + selector: 'sbb-dialog-showcase-example-2', + template: ` +
+ +
+ ` +}) +export class DialogShowcaseExample2Component { + constructor(public dialog: Dialog) {} + + openDialog() { + const dialogRef = this.dialog.open(DialogShowcaseExample2ContentComponent, { + width: '40rem', + height: '40rem', + position: { top: '10px' } + }); + + dialogRef.afterClosed().subscribe(result => { + console.log(`Dialog result: ${result}`); + }); + } +} + +/** + * Dialog with content loaded from Template + */ +@Component({ + selector: 'sbb-dialog-showcase-example-3', + templateUrl: 'dialog-showcase-content-3.component.html' +}) +export class DialogShowcaseExample3Component { + @ViewChild('sampleDialogTemplate', { static: true }) sampleDialogTemplate: TemplateRef; + + constructor(public dialog: Dialog) {} + + openDialog() { + const dialogRef = this.dialog.open(this.sampleDialogTemplate); + + dialogRef.afterClosed().subscribe(result => { + console.log(`Dialog result: ${result}`); + }); + } +} + +@Component({ + selector: 'sbb-dialog-showcase', + templateUrl: 'dialog-showcase.component.html', + styleUrls: ['dialog-showcase.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class DialogShowcaseComponent {} diff --git a/projects/angular-showcase/src/app/business/business/business.component.ts b/projects/angular-showcase/src/app/business/business/business.component.ts index 038f3effa8..645db01b9c 100644 --- a/projects/angular-showcase/src/app/business/business/business.component.ts +++ b/projects/angular-showcase/src/app/business/business/business.component.ts @@ -2,6 +2,7 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { Component } from '@angular/core'; import { ExampleProvider } from '../../shared/example-provider'; +import { DialogShowcaseComponent } from '../business-examples/dialog-showcase/dialog-showcase.component'; import { SimpleContextmenuComponent } from '../business-examples/simple-contextmenu/simple-contextmenu.component'; import { SkippableProcessflowComponent } from '../business-examples/skippable-processflow/skippable-processflow.component'; import { TooltipShowcaseComponent } from '../business-examples/tooltip-showcase/tooltip-showcase.component'; @@ -37,7 +38,8 @@ export class BusinessComponent implements ExampleProvider { contextmenu: 'Contextmenu' }; popupsAndModals = { - tooltip: 'Tooltip' + tooltip: 'Tooltip', + dialog: 'Dialog' }; private _examples: { [component: string]: { [name: string]: ComponentPortal } } = { processflow: { @@ -51,7 +53,8 @@ export class BusinessComponent implements ExampleProvider { }, usermenu: { 'usermenu-showcase': new ComponentPortal(UsermenuShowcaseComponent) - } + }, + dialog: { 'dialog-showcase': new ComponentPortal(DialogShowcaseComponent) } }; resolveExample( diff --git a/projects/sbb-esta/angular-business/dialog/dialog.md b/projects/sbb-esta/angular-business/dialog/dialog.md new file mode 100644 index 0000000000..dace12f91a --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/dialog.md @@ -0,0 +1,166 @@ +The dialog can be used to seek confirmation as see below + +```html +
+
+

Hi {{ data.name }}

+
+
+
+ What's your favorite animal? + + + +
+
+
+ + +
+
+``` + +### Sharing data with the Dialog component + +A dialog is opened by calling the `open` method and if you want to share data with your dialog, +you can use the `data` option to pass information to the dialog component. + +```ts +const dialogRef = this.dialog.open(DialogShowcaseExampleContentComponent, { + data: { name: this.name, animal: this.animal } +}); +``` + +Components created via `Dialog` can use `DialogRef` to close the dialog in which they are +contained. To access data in your dialog component, you have to use the `DIALOG_DATA` injection +token. When closing, the data result value is provided. This result value is forwarded as the +result of the `afterClosed` promise. + +```ts +@Component({ + selector: 'sbb-dialog-showcase-content-1', + templateUrl: 'dialog-showcase-content-1.component.html' +}) +export class DialogShowcaseExampleContentComponent { + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: DialogData + ) {} + + close(): void { + this.dialogRef.close(); + } +} +``` + +```ts +dialogRef.afterClosed().subscribe(result => { + console.log('Dialog sharing data was closed'); + this.animal = result; +}); +``` + +### Dialog with content loaded from Template + +You can use `Dialog` to load content from a TemplateRef by calling `open` method and +passing it the template reference: + +```ts +@Component({ + selector: 'sbb-dialog-showcase-example-3', + templateUrl: 'dialog-showcase-content-3.component.html' +}) +export class DialogShowcaseExample3Component { + @ViewChild('sampleDialogTemplate', { static: true }) sampleDialogTemplate: TemplateRef; + constructor(public dialog: Dialog) {} + + openDialog() { + const dialogRef = this.dialog.open(this.sampleDialogTemplate); + + dialogRef.afterClosed().subscribe(result => { + console.log(`Dialog result: ${result}`); + }); + } +} +``` + +```html + +
+
+

Terms and conditions

+
+
+
+

Lorem ipsum dolor sit amet...

+
+
+
+ + +
+
+
+``` + +- You can also use the disableClose property on `Dialog` to close the dialog manually and + listening changes with `manualCloseAction` method of DialogRef istance: + +```ts +@Component({ + selector: 'sbb-dialog-showcase-example-5', + template: ` +
+ +
+ ` +}) +export class DialogShowcaseExample5Component { + constructor(public dialog: Dialog) {} + openDialog() { + const dialogRef = this.dialog.open(DialogShowcaseExample5ContentComponent, { + disableClose: true + }); + dialogRef.afterClosed().subscribe(() => { + console.log(`Dialog confirmed`); + }); + } +} +``` + +```ts +export class DialogShowcaseExample5ContentComponent implements OnInit { + constructor( + private _dialogRef: DialogRef, + public dialog: Dialog + ) {} + ngOnInit() { + this._lightBoxRef.manualCloseAction.subscribe(() => { + this.dialog.open(DialogShowcaseExample6ContentComponent); + }); + } +} + +export class DialogShowcaseExample6ContentComponent { + constructor( + private _lightBoxRef: DialogRef, + public dialog: Dialog + ) {} + closeThisDialog() { + this._lightBoxRef.close(); + } + closeAllDialog() { + this.dialog.closeAll(); + } +} +``` diff --git a/projects/sbb-esta/angular-business/dialog/index.ts b/projects/sbb-esta/angular-business/dialog/index.ts new file mode 100644 index 0000000000..decc72d85b --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/sbb-esta/angular-business/dialog/package.json b/projects/sbb-esta/angular-business/dialog/package.json new file mode 100644 index 0000000000..28982fec69 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/package.json @@ -0,0 +1,19 @@ +{ + "ngPackage": { + "lib": { + "umdModuleIds": { + "@sbb-esta/angular-core": "sbbAngularCore", + "@sbb-esta/angular-core/base": "sbbAngularCoreBase", + "@sbb-esta/angular-core/breakpoints": "sbbAngularCoreBreakpoints", + "@sbb-esta/angular-core/common-behaviors": "sbbAngularCoreCommonBehaviors", + "@sbb-esta/angular-core/datetime": "sbbAngularCoreDatetime", + "@sbb-esta/angular-core/error": "sbbAngularCoreError", + "@sbb-esta/angular-core/forms": "sbbAngularCoreForms", + "@sbb-esta/angular-core/icon-directive": "sbbAngularCoreIconDirective", + "@sbb-esta/angular-core/models": "sbbAngularCoreModels", + "@sbb-esta/angular-icons": "sbbAngularIcons", + "ngx-perfect-scrollbar": "ngxPerfectScrollbar" + } + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/_dialog.scss b/projects/sbb-esta/angular-business/dialog/src/_dialog.scss new file mode 100644 index 0000000000..bdbb83c199 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/_dialog.scss @@ -0,0 +1,44 @@ +@import '../../../angular-core/styles/common/colors'; +@import '../../../angular-core/styles/common/mediaqueries'; + +$dialog-bgcolor: $sbbColorWhite; +$dialog-border-color: $sbbColorCloud; +$dialog-x-padding-mobile: 16; +$dialog-x-padding-tablet: 16; +$dialog-x-padding: 16; +$dialog-y-padding-mobile: 24; +$dialog-y-padding: 24; + +$dialog-footer-height-mobile: 52; +$dialog-footer-height-tablet: 52; +$dialog-header-height: 52; + +@mixin dialogContentHeight($mode: default) { + perfect-scrollbar { + @if ($mode == default) { + max-height: 100vh; + } @else if ($mode == withHeader) { + max-height: calc(100vh - #{pxToEm(getContentMaxHeightOffset(withHeader))}); + } @else { + max-height: calc(100vh - #{pxToEm(getContentMaxHeightOffset($mode, mobile))}); + + @include mq($from: tabletPortrait) { + max-height: calc(100vh - #{pxToEm(getContentMaxHeightOffset($mode, tabletPortrait))}); + } + } + } +} + +@function getContentMaxHeightOffset($mode, $viewport: mobile) { + @if ($viewport == mobile) and ($mode == withHeaderAndFooter) { + @return $dialog-header-height + $dialog-footer-height-mobile; + } @else if ($viewport == tabletPortrait) and ($mode == withHeaderAndFooter) { + @return $dialog-header-height + $dialog-footer-height-tablet; + } @else if ($viewport == mobile) and ($mode == withHeader) { + @return $dialog-header-height; + } @else if ($viewport == mobile) and ($mode == withFooter) { + @return $dialog-footer-height-mobile; + } @else if ($viewport == tabletPortrait) and ($mode == withFooter) { + @return $dialog-footer-height-tablet; + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog.module.ts b/projects/sbb-esta/angular-business/dialog/src/dialog.module.ts new file mode 100644 index 0000000000..bb740a76c6 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog.module.ts @@ -0,0 +1,34 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ScrollingModule } from '@sbb-esta/angular-core/scrolling'; +import { IconCrossModule } from '@sbb-esta/angular-icons'; + +import { DialogCloseDirective } from './dialog/dialog-close.directive'; +import { DialogContainerComponent } from './dialog/dialog-container/dialog-container.component'; +import { DialogContentComponent } from './dialog/dialog-content/dialog-content.component'; +import { DialogFooterComponent } from './dialog/dialog-footer/dialog-footer.component'; +import { DialogHeaderComponent } from './dialog/dialog-header/dialog-header.component'; +import { Dialog, DIALOG_SCROLL_STRATEGY_PROVIDER } from './dialog/dialog.service'; + +@NgModule({ + imports: [CommonModule, IconCrossModule, OverlayModule, PortalModule, ScrollingModule], + exports: [ + DialogContainerComponent, + DialogCloseDirective, + DialogHeaderComponent, + DialogContentComponent, + DialogFooterComponent + ], + declarations: [ + DialogContainerComponent, + DialogCloseDirective, + DialogHeaderComponent, + DialogFooterComponent, + DialogContentComponent + ], + providers: [Dialog, DIALOG_SCROLL_STRATEGY_PROVIDER], + entryComponents: [DialogContainerComponent] +}) +export class DialogModule {} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-animations.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-animations.ts new file mode 100644 index 0000000000..f0cb511937 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-animations.ts @@ -0,0 +1,25 @@ +import { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger +} from '@angular/animations'; + +/** Animations used by Dialog. */ +export const DIALOG_ANIMATIONS: { + readonly slideDialog: AnimationTriggerMetadata; +} = { + /** Animation that slides the Dialog in and out of view and fades the opacity. */ + slideDialog: trigger('slideDialog', [ + // Note: The `enter` animation doesn't transition to something like `translate3d(0, 0, 0) + // scale(1)`, because for some reason specifying the transform explicitly, causes IE both + // to blur the dialog content and decimate the animation performance. Leaving it as `none` + // solves both issues. + state('enter', style({ transform: 'none', opacity: 1 })), + state('void', style({ transform: 'translate3d(0, 25%, 0) scale(0.9)', opacity: 0 })), + state('exit', style({ transform: 'translate3d(0, 25%, 0)', opacity: 0 })), + transition('* => *', animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)')) + ]) +}; diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-close.directive.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-close.directive.ts new file mode 100644 index 0000000000..cdc262b3e3 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-close.directive.ts @@ -0,0 +1,62 @@ +import { + Directive, + ElementRef, + HostBinding, + HostListener, + Input, + OnInit, + Optional +} from '@angular/core'; + +import { DialogHelperService } from './dialog-helper.service'; +import { DialogRef } from './dialog-ref'; +import { Dialog } from './dialog.service'; + +/** + * Button that will close the current dialog. + */ +@Directive({ + selector: `button[sbbDialogClose]`, + exportAs: 'sbbDialogClose' +}) +export class DialogCloseDirective implements OnInit { + /** Screenreader label for the button. */ + @HostBinding('attr.aria-label') + ariaLabel = 'Close dialog'; + + /** Prevents accidental form submits. **/ + @HostBinding('attr.type') + btnType = 'button'; + + /** dialog close input **/ + // tslint:disable-next-line:no-input-rename + @Input('sbbDialogClose') + dialogResult: any; + + constructor( + /** Reference of dialog. */ + @Optional() public dialogRef: DialogRef, + private _elementRef: ElementRef, + private _dialog: Dialog, + private _dialogHelperService: DialogHelperService + ) {} + + ngOnInit() { + if (!this.dialogRef) { + // When this directive is included in a dialog via TemplateRef (rather than being + // in a Component), the dialogRef isn't available via injection because embedded + // views cannot be given a custom injector. Instead, we look up the dialogRef by + // ID. This must occur in `onInit`, as the ID binding for the dialog container won't + // be resolved at constructor time. + this.dialogRef = this._dialogHelperService.getClosestDialog( + this._elementRef, + this._dialog.openDialogs + ); + } + } + + @HostListener('click') + onCloseClick() { + this.dialogRef.close(this.dialogResult); + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-config.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-config.ts new file mode 100644 index 0000000000..65810df183 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-config.ts @@ -0,0 +1,84 @@ +import { ScrollStrategy } from '@angular/cdk/overlay'; +import { ViewContainerRef } from '@angular/core'; + +/** Valid ARIA roles for a Dialog element. */ +export type DialogRole = 'dialog' | 'alertdialog'; + +/** Possible overrides for a dialog's position. */ +export interface DialogPosition { + /** Override for the Dialog's top position. */ + top?: string; + + /** Override for the Dialog's bottom position. */ + bottom?: string; + + /** Override for the Dialog's left position. */ + left?: string; + + /** Override for the Dialog's right position. */ + right?: string; +} + +/** + * Configuration for opening a modal dialog with the Dialog service. + */ +export class DialogConfig { + /** + * Where the attached component should live in Angular's *logical* component tree. + * This affects what is available for injection and the change detection order for the + * component instantiated inside of the Dialog. This does not affect where the Dialog + * content will be rendered. + */ + viewContainerRef?: ViewContainerRef; + + /** ID for the Dialog. If omitted, a unique one will be generated. */ + id?: string; + + /** The ARIA role of the Dialog element. */ + role?: DialogRole = 'dialog'; + + /** Custom class for the overlay pane. */ + panelClass?: string | string[] = ''; + + /** Whether the user can use escape or clicking on the backdrop to close the modal. */ + disableClose? = false; + + /** Width of the Dialog overlay. */ + width?; + + /** Height of the Dialog overlay. */ + height?; + + /** Min-width of the Dialog. If a number is provided, assumes pixel units. */ + minWidth?: number | string; + + /** Min-height of the Dialog. If a number is provided, assumes pixel units. */ + minHeight?: number | string; + + /** Max-width of the Dialog. If a number is provided, assumes pixel units. Defaults to 80vw. */ + maxWidth?: number | string = '80vw'; + + /** Max-height of the Dialog. If a number is provided, assumes pixel units. */ + maxHeight?: number | string = '96vh'; + + /** Position overrides. */ + position?: DialogPosition; + + /** Data being injected into the child component. */ + data?: D | null = null; + + /** ID of the element that describes the Dialog. */ + ariaDescribedBy?: string | null = null; + + /** Aria label to assign to the Dialog element */ + ariaLabel?: string | null = null; + + /** Whether the Dialog should focus the first focusable element on open. */ + autoFocus? = true; + + /** Scroll strategy to be used for the dialog. */ + scrollStrategy?: ScrollStrategy; + + /** Whether the Dialog should close when the user goes backwards/forwards in history. */ + closeOnNavigation? = true; +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.html b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.html new file mode 100644 index 0000000000..215f0d9c55 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.html @@ -0,0 +1 @@ + diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.scss b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.scss new file mode 100644 index 0000000000..2fd3fe261b --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.scss @@ -0,0 +1,55 @@ +@import '../../../../../angular-core/styles/common/mediaqueries'; +@import '../dialog-footer/dialog-footer.component'; +@import '../dialog-header/dialog-header.component'; + +.sbb-overlay-background { + background-color: rgba(255, 255, 255, 0.7); + align-items: center; +} + +sbb-dialog-container { + display: flex; + align-items: center; + justify-content: center; + background-color: $sbbColorWhite; + position: relative; + outline: 0; + width: 100%; + + & > *:only-child { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + } + + .sbb-dialog-content { + display: block; + border-left: 1px solid $sbbColorGranite; + border-right: 1px solid $sbbColorGranite; + flex: 1 1 auto; + overflow: auto; + + perfect-scrollbar { + max-height: 100vh; + } + } + + &.sbb-dialog-with-header { + .sbb-dialog-content { + @include dialogContentHeight(withHeader); + } + } + + &.sbb-dialog-with-footer { + .sbb-dialog-content { + @include dialogContentHeight(withFooter); + } + } + + &.sbb-dialog-with-header.sbb-dialog-with-footer { + .sbb-dialog-content { + @include dialogContentHeight(withHeaderAndFooter); + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.ts new file mode 100644 index 0000000000..b339b35852 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-container/dialog-container.component.ts @@ -0,0 +1,231 @@ +import { AnimationEvent } from '@angular/animations'; +import { FocusTrap, FocusTrapFactory } from '@angular/cdk/a11y'; +import { + BasePortalOutlet, + CdkPortalOutlet, + ComponentPortal, + TemplatePortal +} from '@angular/cdk/portal'; +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ComponentRef, + ElementRef, + EmbeddedViewRef, + EventEmitter, + HostBinding, + HostListener, + Inject, + Optional, + ViewChild, + ViewEncapsulation +} from '@angular/core'; + +import { DIALOG_ANIMATIONS } from '../dialog-animations'; +import { DialogConfig } from '../dialog-config'; + +/** + * Throws an exception for the case when a ComponentPortal is + * attached to a DomPortalOutlet without an origin. + * @docs-private + */ +export function throwDialogContentAlreadyAttachedError() { + throw Error('Attempting to attach dialog content after content is already attached'); +} + +/** + * Internal component that wraps user-provided dialog content. + * @docs-private + */ +@Component({ + selector: 'sbb-dialog-container', + templateUrl: 'dialog-container.component.html', + styleUrls: ['dialog-container.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [DIALOG_ANIMATIONS.slideDialog] +}) +export class DialogContainerComponent extends BasePortalOutlet { + /** The portal outlet inside of this container into which the dialog content will be loaded. */ + @ViewChild(CdkPortalOutlet, { static: true }) portalOutlet: CdkPortalOutlet; + + @HostBinding('class.sbb-dialog-container') + containerClass = true; + + @HostBinding('attr.tabindex') + tabIndex = '-1'; + + @HostBinding('attr.aria-modal') + arialModal = 'true'; + + @HostBinding('attr.id') + get dialogContainerID() { + return this.id; + } + + @HostBinding('attr.role') + get role() { + return this.config.role; + } + + @HostBinding('attr.aria-labelledby') + get ariaLabelledbyAttr() { + return this.config.ariaLabel ? null : this.ariaLabelledBy; + } + + @HostBinding('attr.aria-label') + get ariaLabel() { + return this.config.ariaLabel; + } + + @HostBinding('attr.aria-describedby') + get describeDby() { + return this.config.ariaDescribedBy || null; + } + + @HostBinding('@slideDialog') + get slideDialogAnimation() { + return this.state; + } + + @HostBinding('class.sbb-dialog-with-header') + get hasHeaderClass() { + return this.hasHeader; + } + + @HostBinding('class.sbb-dialog-with-footer') + get hasFooterClass() { + return this.hasFooter; + } + + /** The class that traps and manages focus within the dialog. */ + private _focusTrap: FocusTrap; + + /** Element that was focused before the dialog was opened. Save this to restore upon close. */ + private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null; + + /** State of the dialog animation. */ + state: 'void' | 'enter' | 'exit' = 'enter'; + + /** Emits when an animation state changes. */ + animationStateChanged = new EventEmitter(); + + /** ID of the element that should be considered as the dialog's label. */ + ariaLabelledBy: string | null = null; + + /** ID for the container DOM element. */ + id: string; + + hasHeader: boolean | null = null; + + hasFooter: boolean | null = null; + + constructor( + private _elementRef: ElementRef, + private _focusTrapFactory: FocusTrapFactory, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(DOCUMENT) private _document: any, + /** The dialog configuration. */ + public config: DialogConfig + ) { + super(); + } + + /** + * Attach a ComponentPortal as content to this dialog container. + * @param portal Portal to be attached as the dialog content. + */ + attachComponentPortal(portal: ComponentPortal): ComponentRef { + if (this.portalOutlet.hasAttached()) { + throwDialogContentAlreadyAttachedError(); + } + + this._savePreviouslyFocusedElement(); + return this.portalOutlet.attachComponentPortal(portal); + } + + /** + * Attach a TemplatePortal as content to this dialog container. + * @param portal Portal to be attached as the dialog content. + */ + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + if (this.portalOutlet.hasAttached()) { + throwDialogContentAlreadyAttachedError(); + } + + this._savePreviouslyFocusedElement(); + return this.portalOutlet.attachTemplatePortal(portal); + } + + /** Moves the focus inside the focus trap. */ + private _trapFocus() { + if (!this._focusTrap) { + this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); + } + + // If were to attempt to focus immediately, then the content of the dialog would not yet be + // ready in instances where change detection has to run first. To deal with this, we simply + // wait for the microtask queue to be empty. + if (this.config.autoFocus) { + this._focusTrap.focusInitialElementWhenReady(); + } + } + + /** Restores focus to the element that was focused before the dialog opened. */ + private _restoreFocus() { + const toFocus = this._elementFocusedBeforeDialogWasOpened; + + // 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 dialog was opened. */ + private _savePreviouslyFocusedElement() { + if (this._document) { + this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement; + + // Note that there is no focus method when rendering on the server. + if (this._elementRef.nativeElement.focus) { + // Move focus onto the dialog immediately in order to prevent the user from accidentally + // opening multiple dialoges at the same time. Needs to be async, because the element + // may not be focusable immediately. + Promise.resolve().then(() => this._elementRef.nativeElement.focus()); + } + } + } + + /** Callback, invoked whenever an animation on the host completes. */ + @HostListener('@slideDialog.done', ['$event']) + onAnimationDone(event: AnimationEvent) { + if (event.toState === 'enter') { + this._trapFocus(); + } else if (event.toState === 'exit') { + this._restoreFocus(); + } + + this.animationStateChanged.emit(event); + } + + /** Callback, invoked when an animation on the host starts. */ + @HostListener('@slideDialog.start', ['$event']) + onAnimationStart(event: AnimationEvent) { + this.animationStateChanged.emit(event); + } + + /** Starts the dialog exit animation. */ + startExitAnimation(): void { + this.state = 'exit'; + + // Mark the container for check so it can react if the + // view container is using OnPush change detection. + this._changeDetectorRef.markForCheck(); + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-content/dialog-content.component.scss b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-content/dialog-content.component.scss new file mode 100644 index 0000000000..30452eac95 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-content/dialog-content.component.scss @@ -0,0 +1,20 @@ +@import '../../dialog'; +@import '../../../../../angular-core/styles/common/functions'; + +$dialog-content-x-padding-tablet: 16; +$dialog-content-x-padding-mobile: 16; +$dialog-content-x-padding: 16; + +.sbb-dialog-content-scrollbar { + .ps-content { + padding: pxToEm($dialog-y-padding-mobile) pxToEm($dialog-content-x-padding-mobile); + + @include mq($from: tabletPortrait) { + padding: pxToEm($dialog-y-padding) pxToEm($dialog-content-x-padding-tablet); + } + + @include mq($from: desktop) { + padding: pxToEm($dialog-y-padding) pxToEm($dialog-content-x-padding); + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-content/dialog-content.component.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-content/dialog-content.component.ts new file mode 100644 index 0000000000..dbbb95fec5 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-content/dialog-content.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, HostBinding, ViewEncapsulation } from '@angular/core'; + +/** + * Scrollable content container of a dialog. + */ +@Component({ + selector: `sbb-dialog-content, [sbbDialogContent]`, + styleUrls: ['./dialog-content.component.scss'], + encapsulation: ViewEncapsulation.None, + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DialogContentComponent { + /** Class attribute for dialog content */ + @HostBinding('class.sbb-dialog-content') + dialogContentClass = true; +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-footer/dialog-footer.component.scss b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-footer/dialog-footer.component.scss new file mode 100644 index 0000000000..0ff0e5d23a --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-footer/dialog-footer.component.scss @@ -0,0 +1,53 @@ +@import '../../../../../angular-core/styles/common/functions'; +@import '../../../../../angular-core/styles/common/mixins'; +@import '../../dialog'; + +$dialog-footer-padding: 7; +$dialog-footer-button-bottom-padding: 10; +$dialog-footer-button-left-padding: 8; + +.sbb-dialog-footer { + bottom: 0; + left: 0; + width: 100%; + background-color: $dialog-bgcolor; + + border-left: 1px solid $sbbColorGranite; + border-right: 1px solid $sbbColorGranite; + border-bottom: 1px solid $sbbColorGranite; + border-top: 1px solid $dialog-border-color; + + display: flex; + flex-direction: row; + justify-content: flex-end; + + max-height: pxToEm($dialog-footer-height-mobile + 2); + min-height: pxToEm($dialog-footer-height-mobile); + + padding: pxToEm($dialog-footer-padding) pxToEm($dialog-x-padding-mobile) + pxToEm($dialog-footer-padding); + + @include mq($from: tabletPortrait) { + max-height: pxToEm($dialog-footer-height-tablet + 2); + } + + @include mq($from: tabletPortrait) { + flex-direction: row; + min-height: pxToEm($dialog-footer-height-tablet); + padding: pxToEm($dialog-footer-padding) pxToEm($dialog-x-padding-tablet) + pxToEm($dialog-footer-padding); + } + + @include mq($from: desktop) { + padding: pxToEm($dialog-footer-padding) pxToEm($dialog-x-padding) pxToEm($dialog-footer-padding); + } + + button { + margin-bottom: pxToEm($dialog-footer-button-bottom-padding); + margin-left: pxToEm($dialog-footer-button-left-padding); + + @include mq($from: tabletPortrait) { + margin-bottom: 0; + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-footer/dialog-footer.component.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-footer/dialog-footer.component.ts new file mode 100644 index 0000000000..0c7738584f --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-footer/dialog-footer.component.ts @@ -0,0 +1,80 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostBinding, + Input, + OnInit, + Optional, + ViewEncapsulation +} from '@angular/core'; + +import { DialogHelperService } from '../dialog-helper.service'; +import { DialogRef } from '../dialog-ref'; +import { Dialog } from '../dialog.service'; + +/** + * Container for the bottom action buttons in a dialog. + * Stays fixed to the bottom when scrolling. + */ +@Component({ + selector: `sbb-dialog-footer, [sbbDialogFooter]`, + styleUrls: ['./dialog-footer.component.scss'], + encapsulation: ViewEncapsulation.None, + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DialogFooterComponent implements OnInit { + /** Class attribute for the footer. */ + @HostBinding('class.sbb-dialog-footer') + dialogFooterClass = true; + + /** Types of alignment. */ + @Input() alignment: 'left' | 'center' | 'right' = 'right'; + + /** Alignment to left position. */ + @HostBinding('class.sbb-dialog-footer-align-start') + get alignmentStartClass() { + return this.alignment === 'left'; + } + + /** Alignment to center position. */ + @HostBinding('class.sbb-dialog-footer-align-center') + get alignmentCenterClass() { + return this.alignment === 'center'; + } + + /** Alignment to right position. */ + @HostBinding('class.sbb-dialog-footer-align-end') + get alignmentEndClass() { + return this.alignment === 'right'; + } + + constructor( + @Optional() private _dialogRef: DialogRef, + private _elementRef: ElementRef, + private _dialog: Dialog, + private _dialogHelperService: DialogHelperService + ) {} + + ngOnInit() { + if (!this._dialogRef) { + this._dialogRef = this._dialogHelperService.getClosestDialog( + this._elementRef, + this._dialog.openDialogs + ); + } + + if (this._dialogRef) { + Promise.resolve().then(() => { + const container = this._dialogRef.containerInstance; + + if (container) { + container.hasFooter = true; + } + }); + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-header/dialog-header.component.scss b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-header/dialog-header.component.scss new file mode 100644 index 0000000000..9127f4ee78 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-header/dialog-header.component.scss @@ -0,0 +1,69 @@ +@import '../../_dialog.scss'; +@import '../../../../../angular-core/styles/common/functions'; +@import '../../../../../angular-core/styles/common/button'; +@import '../../../../../angular-core/styles/common/mixins'; + +$dialog-header-y-padding: 15; +$dialog-close-button-size: 24; +$dialog-close-icon-size: 24; +$dialog-close-button-color: $sbbColorStorm; +$dialog-close-button-color-hover: $sbbColorRed125; +$dialog-close-icon-color: $sbbColorGrey; + +:host { + display: flex; + align-items: center; + height: pxToEm($dialog-header-height); + padding: pxToEm($dialog-header-y-padding) pxToEm($dialog-x-padding-mobile); + border-top: 1px solid $sbbColorGranite; + border-left: 1px solid $sbbColorGranite; + border-right: 1px solid $sbbColorGranite; + border-bottom: 1px solid $dialog-border-color; + overflow: hidden; + + @include mq($from: tabletPortrait) { + padding: pxToEm($dialog-header-y-padding) pxToEm($dialog-x-padding-tablet); + } + + @include mq($from: desktop) { + padding: pxToEm($dialog-header-y-padding) pxToEm($dialog-x-padding); + } + + .sbb-dialog-close-btn { + @include buttonResetFrameless(); + @include svgIconColor($dialog-close-icon-color); + + margin-left: auto; + position: relative; + width: pxToEm($dialog-close-button-size); + height: pxToEm($dialog-close-button-size); + + &::before { + content: ''; + position: absolute; + display: block; + width: 100%; + height: 100%; + top: 0; + left: 0; + transition: border 0.3s; + } + + svg { + @include absoluteCenterXY(); + + width: pxToEm($dialog-close-icon-size); + height: pxToEm($dialog-close-icon-size); + } + + &:hover, + &:focus { + @include svgIconColor($dialog-close-button-color-hover); + cursor: pointer; + + &::before { + border-color: $dialog-close-button-color-hover; + } + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-header/dialog-header.component.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-header/dialog-header.component.ts new file mode 100644 index 0000000000..bd483aad51 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-header/dialog-header.component.ts @@ -0,0 +1,79 @@ +/** + * Header of a dialog element. Stays fixed to the top of the dialog when scrolling. + */ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostBinding, + OnInit, + Optional +} from '@angular/core'; + +import { DialogHelperService } from '../dialog-helper.service'; +import { DialogRef } from '../dialog-ref'; +import { Dialog } from '../dialog.service'; + +@Component({ + selector: 'sbb-dialog-header, [sbbDialogHeader]', + styleUrls: ['dialog-header.component.scss'], + template: ` + + + + `, + exportAs: 'sbbDialogHeader', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DialogHeaderComponent implements OnInit { + /** Disables dialog header when dialog is closed. */ + isCloseDisabled: boolean; + /** Class attribute on dialog header. */ + @HostBinding('class.sbb-dialog-header') + dialogHeaderClass = true; + + constructor( + @Optional() private _dialogRef: DialogRef, + private _elementRef: ElementRef, + private _dialog: Dialog, + private _changeDetectorRef: ChangeDetectorRef, + private _dialogHelperService: DialogHelperService + ) {} + + ngOnInit() { + if (!this._dialogRef) { + this._dialogRef = this._dialogHelperService.getClosestDialog( + this._elementRef, + this._dialog.openDialogs + ); + } + + if (this._dialogRef) { + Promise.resolve().then(() => { + const container = this._dialogRef.containerInstance; + + if (container) { + container.hasHeader = true; + this.isCloseDisabled = container.config.disableClose; + this._changeDetectorRef.markForCheck(); + } + }); + } + } + + emitManualCloseAction() { + if (this._dialogRef) { + this._dialogRef.manualCloseAction.next(null); + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-helper.service.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-helper.service.ts new file mode 100644 index 0000000000..3e537764ea --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-helper.service.ts @@ -0,0 +1,21 @@ +import { ElementRef, Injectable } from '@angular/core'; + +import { DialogRef } from './dialog-ref'; + +@Injectable({ providedIn: 'root' }) +export class DialogHelperService { + /** + * Finds the closest DialogRef to an element by looking at the DOM. + * @param element Element relative to which to look for a dialog. + * @param openDialogs References to the currently-open dialogs. + */ + getClosestDialog(element: ElementRef, openDialogs: DialogRef[]) { + let parent: HTMLElement | null = element.nativeElement.parentElement; + + while (parent && !parent.classList.contains('sbb-dialog-container')) { + parent = parent.parentElement; + } + + return parent ? openDialogs.find(dialog => dialog.id === parent.id) : null; + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-ref.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-ref.ts new file mode 100644 index 0000000000..7fb798449c --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog-ref.ts @@ -0,0 +1,180 @@ +import { ESCAPE } from '@angular/cdk/keycodes'; +import { GlobalPositionStrategy, OverlayRef } from '@angular/cdk/overlay'; +import { Location } from '@angular/common'; +import { Observable, Subject, Subscription, SubscriptionLike } from 'rxjs'; +import { filter, first } from 'rxjs/operators'; + +import { DialogPosition } from './dialog-config'; +import { DialogContainerComponent } from './dialog-container/dialog-container.component'; + +// Counter for unique dialog ids. +let uniqueId = 0; + +/** + * Reference to a dialog opened via the Dialog service. + */ +export class DialogRef { + /** The instance of component opened into the dialog. */ + componentInstance: T; + + /** Whether the user is allowed to close the dialog. */ + disableClose: boolean | undefined = this.containerInstance.config.disableClose; + /** Observable to close manually a dialog. */ + manualCloseAction = new Subject(); + + /** Subject for notifying the user that the dialog has finished opening. */ + private readonly _afterOpen = new Subject(); + + /** Subject for notifying the user that the dialog has finished closing. */ + private readonly _afterClosed = new Subject(); + + /** Subject for notifying the user that the dialog has started closing. */ + private readonly _beforeClose = new Subject(); + + /** Result to be passed to afterClosed. */ + private _result: R | undefined; + + /** Subscription to changes in the user's location. */ + private _locationChanges: SubscriptionLike = Subscription.EMPTY; + + constructor( + /** The instance of the container component. */ + public containerInstance: DialogContainerComponent, + /** Identifier of dialog. */ + readonly id: string = `sbb-dialog-${uniqueId++}`, + private _overlayRef: OverlayRef, + location?: Location + ) { + // Pass the id along to the container. + containerInstance.id = id; + + // Emit when opening animation completes + containerInstance.animationStateChanged + .pipe( + filter(event => event.phaseName === 'done' && event.toState === 'enter'), + first() + ) + .subscribe(() => { + this._afterOpen.next(); + this._afterOpen.complete(); + }); + + // Dispose overlay when closing animation is complete + containerInstance.animationStateChanged + .pipe( + filter(event => event.phaseName === 'done' && event.toState === 'exit'), + first() + ) + .subscribe(() => this._overlayRef.dispose()); + + _overlayRef.detachments().subscribe(() => { + this._beforeClose.next(this._result); + this._beforeClose.complete(); + this._locationChanges.unsubscribe(); + this._afterClosed.next(this._result); + this._afterClosed.complete(); + this.componentInstance = null; + this._overlayRef.dispose(); + }); + + _overlayRef + .keydownEvents() + .pipe(filter(event => event.keyCode === ESCAPE && !this.disableClose)) + .subscribe(() => this.close()); + + _overlayRef + .keydownEvents() + .pipe(filter(event => event.keyCode === ESCAPE && this.disableClose)) + .subscribe(() => this.manualCloseAction.next(null)); + + if (location) { + // Close the dialog when the user goes forwards/backwards in history or when the location + // hash changes. Note that this usually doesn't include clicking on links (unless the user + // is using the `HashLocationStrategy`). + this._locationChanges = location.subscribe(() => { + if (this.containerInstance.config.closeOnNavigation) { + this.close(); + } + }); + } + } + + /** + * Close the dialog. + * @param dialogResult Optional result to return to the dialog opener. + */ + close(dialogResult?: R): void { + this._result = dialogResult; + + // Transition the backdrop in parallel to the dialog. + this.containerInstance.animationStateChanged + .pipe( + filter(event => event.phaseName === 'start'), + first() + ) + .subscribe(() => { + this._beforeClose.next(dialogResult); + this._beforeClose.complete(); + this._overlayRef.detachBackdrop(); + }); + + this.containerInstance.startExitAnimation(); + } + + /** + * Updates the dialog's position. + * @param position New dialog position. + */ + updatePosition(position?: DialogPosition): this { + const strategy = this._getPositionStrategy(); + + if (position && (position.left || position.right)) { + position.left ? strategy.left(position.left) : strategy.right(position.right); + } else { + strategy.centerHorizontally(); + } + + if (position && (position.top || position.bottom)) { + position.top ? strategy.top(position.top) : strategy.bottom(position.bottom); + } else { + strategy.centerVertically(); + } + + this._overlayRef.updatePosition(); + + return this; + } + + /** + * Gets an observable that is notified when the dialog is finished opening. + */ + afterOpen(): Observable { + return this._afterOpen.asObservable(); + } + + /** + * Gets an observable that is notified when the dialog is finished closing. + */ + afterClosed(): Observable { + return this._afterClosed.asObservable(); + } + + /** + * Gets an observable that is notified when the dialog has started closing. + */ + beforeClose(): Observable { + return this._beforeClose.asObservable(); + } + + /** + * Gets an observable that emits when keydown events are targeted on the overlay. + */ + keydownEvents(): Observable { + return this._overlayRef.keydownEvents(); + } + + /** Fetches the position strategy object from the overlay ref. */ + private _getPositionStrategy(): GlobalPositionStrategy { + return this._overlayRef.getConfig().positionStrategy as GlobalPositionStrategy; + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/dialog/dialog.service.ts b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog.service.ts new file mode 100644 index 0000000000..82df89e5ba --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/dialog/dialog.service.ts @@ -0,0 +1,288 @@ +import { Overlay, OverlayConfig, OverlayRef, ScrollStrategy } from '@angular/cdk/overlay'; +import { + ComponentPortal, + ComponentType, + PortalInjector, + TemplatePortal +} from '@angular/cdk/portal'; +import { Location } from '@angular/common'; +import { + Inject, + Injectable, + InjectionToken, + Injector, + Optional, + SkipSelf, + TemplateRef +} from '@angular/core'; +import { defer, Observable, Subject } from 'rxjs'; +import { startWith } from 'rxjs/operators'; + +import { DialogConfig } from './dialog-config'; +import { DialogContainerComponent } from './dialog-container/dialog-container.component'; +import { DialogRef } from './dialog-ref'; + +/** Injection token that can be used to access the data that was passed in to a dialog. */ +export const DIALOG_DATA = new InjectionToken('DialogData'); + +/** Injection token that can be used to specify default dialog options. */ +export const DIALOG_DEFAULT_OPTIONS = new InjectionToken('DialogDefaultOptions'); + +/** Injection token that determines the scroll handling while the dialog is open. */ +export const DIALOG_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( + 'DialogScrollStrategy' +); + +/** @docs-private */ +export function SBB_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY( + overlay: Overlay +): () => ScrollStrategy { + return () => overlay.scrollStrategies.block(); +} + +/** @docs-private */ +export const DIALOG_SCROLL_STRATEGY_PROVIDER = { + provide: DIALOG_SCROLL_STRATEGY, + deps: [Overlay], + useFactory: SBB_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY +}; + +/** + * Service to open SBB Design modal dialogs. + */ +@Injectable({ + providedIn: 'root' +}) +export class Dialog { + private _openDialogsAtThisLevel: DialogRef[] = []; + private readonly _afterAllClosedAtThisLevel = new Subject(); + private readonly _afterOpenAtThisLevel = new Subject>(); + + /** Keeps track of the currently-open dialogs. */ + get openDialogs(): DialogRef[] { + return this._parentDialog ? this._parentDialog.openDialogs : this._openDialogsAtThisLevel; + } + + /** Stream that emits when a dialog has been opened. */ + get afterOpen(): Subject> { + return this._parentDialog ? this._parentDialog.afterOpen : this._afterOpenAtThisLevel; + } + + get _afterAllClosed(): Subject { + const parent = this._parentDialog; + return parent ? parent._afterAllClosed : this._afterAllClosedAtThisLevel; + } + + /** + * Stream that emits when all open dialog have finished closing. + * Will emit on subscribe if there are no open dialogs to begin with. + */ + readonly afterAllClosed: Observable = defer(() => + this.openDialogs.length ? this._afterAllClosed : this._afterAllClosed.pipe(startWith(undefined)) + ); + + constructor( + private _overlay: Overlay, + private _injector: Injector, + @Optional() private _location: Location, + @Optional() @Inject(DIALOG_DEFAULT_OPTIONS) private _defaultOptions, + @Inject(DIALOG_SCROLL_STRATEGY) private _scrollStrategy, + @Optional() @SkipSelf() private _parentDialog: Dialog + ) {} + + /** + * Opens a modal dialog containing the given component. + * @param componentOrTemplateRef Type of the component to load into the dialog, + * or a TemplateRef to instantiate as the dialog content. + * @param config Extra configuration options. + * @returns Reference to the newly-opened dialog. + */ + open( + componentOrTemplateRef: ComponentType | TemplateRef, + config?: DialogConfig + ): DialogRef { + config = { ...(this._defaultOptions || new DialogConfig()), ...config }; + if (config.id && this.getDialogById(config.id)) { + throw Error(`dialog with id "${config.id}" exists already. The dialog id must be unique.`); + } + + const overlayRef = this._createOverlay(config); + const dialogContainer = this._attachDialogContainer(overlayRef, config); + const dialogRef = this._attachDialogContent( + componentOrTemplateRef, + dialogContainer, + overlayRef, + config + ); + + this.openDialogs.push(dialogRef); + dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef)); + this.afterOpen.next(dialogRef); + + return dialogRef; + } + + /** + * Closes all of the currently-open dialogs. + */ + closeAll(): void { + let i = this.openDialogs.length; + + while (i--) { + // The `openDialogs` property isn't updated after close until the rxjs subscription + // runs on the next microtask, in addition to modifying the array as we're going + // through it. We loop through all of them and call close without assuming that + // they'll be removed from the list instantaneously. + this.openDialogs[i].close(); + } + } + + /** + * Finds an open dialog by its id. + * @param id ID to use when looking up the dialog. + * @returns Dialog reference associated to the input id. + */ + getDialogById(id: string): DialogRef | undefined { + return this.openDialogs.find(dialog => dialog.id === id); + } + + /** + * Creates the overlay into which the dialog will be loaded. + * @param config The dialog configuration. + * @returns A promise resolving to the OverlayRef for the created overlay. + */ + private _createOverlay(config: DialogConfig): OverlayRef { + const overlayConfig = this._getOverlayConfig(config); + return this._overlay.create(overlayConfig); + } + + /** + * Creates an overlay config from a dialog config. + * @param dialogConfig The dialog configuration. + * @returns The overlay configuration. + */ + private _getOverlayConfig(dialogConfig: DialogConfig): OverlayConfig { + return new OverlayConfig({ + positionStrategy: this._overlay.position().global(), + scrollStrategy: dialogConfig.scrollStrategy || this._scrollStrategy(), + panelClass: dialogConfig.panelClass, + hasBackdrop: true, + backdropClass: 'sbb-overlay-background', + minWidth: dialogConfig.minWidth, + maxWidth: dialogConfig.maxWidth, + minHeight: dialogConfig.minHeight, + maxHeight: dialogConfig.maxHeight, + width: dialogConfig.width, + height: dialogConfig.height + }); + } + + /** + * Attaches an DialogContainer to a dialog's already-created overlay. + * @param overlay Reference to the dialog's underlying overlay. + * @param config The dialog configuration. + * @returns A promise resolving to a ComponentRef for the attached container. + */ + private _attachDialogContainer( + overlay: OverlayRef, + config: DialogConfig + ): DialogContainerComponent { + const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; + const injector = new PortalInjector( + userInjector || this._injector, + new WeakMap([[DialogConfig, config]]) + ); + const containerPortal = new ComponentPortal( + DialogContainerComponent, + config.viewContainerRef, + injector + ); + const containerRef = overlay.attach(containerPortal); + + return containerRef.instance; + } + + /** + * Attaches the user-provided component to the already-created DialogContainer. + * @param componentOrTemplateRef The type of component being loaded into the dialog, + * or a TemplateRef to instantiate as the content. + * @param dialogContainer Reference to the wrapping DialogContainer. + * @param overlayRef Reference to the overlay in which the dialog resides. + * @param config The dialog configuration. + * @returns A promise resolving to the DialogRef that should be returned to the user. + */ + private _attachDialogContent( + componentOrTemplateRef: ComponentType | TemplateRef, + dialogContainer: DialogContainerComponent, + overlayRef: OverlayRef, + config: DialogConfig + ): DialogRef { + // Create a reference to the dialog we're creating in order to give the user a handle + // to modify and close it. + const dialogRef = new DialogRef(dialogContainer, config.id, overlayRef, this._location); + + if (componentOrTemplateRef instanceof TemplateRef) { + dialogContainer.attachTemplatePortal( + new TemplatePortal(componentOrTemplateRef, null, { + $implicit: config.data, + dialogRef + }) + ); + } else { + const injector = this._createInjector(config, dialogRef, dialogContainer); + const contentRef = dialogContainer.attachComponentPortal( + new ComponentPortal(componentOrTemplateRef, undefined, injector) + ); + dialogRef.componentInstance = contentRef.instance; + } + + dialogRef.updatePosition(config.position); + + return dialogRef; + } + + /** + * Creates a custom injector to be used inside the dialog. This allows a component loaded inside + * of a dialog to close itself and, optionally, to return a value. + * @param config Config object that is used to construct the dialog. + * @param dialogRef Reference to the dialog. + * @param dialogContainer dialog container element that wraps all of the contents. + * @returns The custom injector that can be used inside the dialog. + */ + private _createInjector( + config: DialogConfig, + dialogRef: DialogRef, + dialogContainer: DialogContainerComponent + ): PortalInjector { + const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector; + + // The DialogContainer is injected in the portal as the Dialog and the dialog's + // content are created out of the same ViewContainerRef and as such, are siblings for injector + // purposes. To allow the hierarchy that is expected, the DialogContainer is explicitly + // added to the injection tokens. + const injectionTokens = new WeakMap([ + [DialogContainerComponent, dialogContainer], + [DIALOG_DATA, config.data], + [DialogRef, dialogRef] + ]); + + return new PortalInjector(userInjector || this._injector, injectionTokens); + } + + /** + * Removes a dialog from the array of open dialogs. + * @param dialogRef dialog to be removed. + */ + private _removeOpenDialog(dialogRef: DialogRef) { + const index = this.openDialogs.indexOf(dialogRef); + + if (index > -1) { + this.openDialogs.splice(index, 1); + + // emit to the `afterAllClosed` stream. + if (!this.openDialogs.length) { + this._afterAllClosed.next(); + } + } + } +} diff --git a/projects/sbb-esta/angular-business/dialog/src/public_api.ts b/projects/sbb-esta/angular-business/dialog/src/public_api.ts new file mode 100644 index 0000000000..8d7bf71a76 --- /dev/null +++ b/projects/sbb-esta/angular-business/dialog/src/public_api.ts @@ -0,0 +1,9 @@ +export * from './dialog.module'; +export * from './dialog/dialog.service'; +export * from './dialog/dialog-container/dialog-container.component'; +export * from './dialog/dialog-header/dialog-header.component'; +export * from './dialog/dialog-content/dialog-content.component'; +export * from './dialog/dialog-footer/dialog-footer.component'; +export * from './dialog/dialog-config'; +export * from './dialog/dialog-ref'; +export * from './dialog/dialog-animations'; diff --git a/projects/sbb-esta/angular-business/public-api.ts b/projects/sbb-esta/angular-business/public-api.ts index 30c77358b7..c164fc58e6 100644 --- a/projects/sbb-esta/angular-business/public-api.ts +++ b/projects/sbb-esta/angular-business/public-api.ts @@ -7,6 +7,7 @@ export * from '@sbb-esta/angular-business/button'; export * from '@sbb-esta/angular-business/checkbox'; export * from '@sbb-esta/angular-business/contextmenu'; export * from '@sbb-esta/angular-business/datepicker'; +export * from '@sbb-esta/angular-business/dialog'; export * from '@sbb-esta/angular-business/field'; export * from '@sbb-esta/angular-business/header'; export * from '@sbb-esta/angular-business/option';