From 019a4d269e16d517813dc206dee63dd34050644e Mon Sep 17 00:00:00 2001 From: k-genov Date: Wed, 17 Jan 2024 17:57:41 +0100 Subject: [PATCH] feat(workbench/dialog): support custom header and footer ## Dialog Header By default, the dialog displays the title and a close button in the header. Alternatively, the dialog supports the use of a custom header. To provide a custom header, add an Angular template to the HTML of the dialog component and decorate it with the `wbDialogHeader` directive. ```html ``` ## Dialog Footer A dialog has a default footer that displays actions defined in the HTML of the dialog component. An action is an Angular template decorated with the `wbDialogAction` directive. Multiple actions are supported, rendered in modeling order, and can be left- or right-aligned. ```html ``` Alternatively, the dialog supports the use of a custom footer. To provide a custom footer, add an Angular template to the HTML of the dialog component and decorate it with the `wbDialogFooter` directive. ```html ``` BREAKING CHANGE: Support for custom header and footer has introduced a breaking change. To migrate: - Type of `WorkbenchDialog.padding` is now `boolean`. Set to `false` to remove the padding, or set the CSS variable `--sci-workbench-dialog-padding` for a custom padding. - `--sci-workbench-dialog-header-divider-color` CSS variable has been removed (no replacement). --- .../dialog-opener-page.component.html | 2 +- .../dialog-page/dialog-page.component.html | 14 ++- .../dialog-page/dialog-page.component.scss | 16 ++-- .../app/dialog-page/dialog-page.component.ts | 17 ++-- docs/site/howto/how-to-open-dialog.md | 76 ++++++++++++++++- .../scion/e2e-testing/src/dialog-page.po.ts | 85 ++++++++----------- projects/scion/e2e-testing/src/dialog.po.ts | 22 +++-- .../src/workbench/dialog.e2e-spec.ts | 11 +-- .../_workbench-dark-theme-design-tokens.scss | 1 - .../_workbench-light-theme-design-tokens.scss | 1 - .../src/lib/common/workbench-mixins.scss | 12 +++ .../dialog-action-filter.pipe.ts | 23 +++++ .../dialog-footer.component.html | 17 ++++ .../dialog-footer.component.scss | 20 +++++ .../dialog-footer/dialog-footer.component.ts | 34 ++++++++ .../workbench-dialog-action.directive.ts | 47 ++++++++++ .../workbench-dialog-footer.directive.ts | 46 ++++++++++ .../dialog-header.component.html | 13 +++ .../dialog-header.component.scss | 40 +++++++++ .../dialog-header/dialog-header.component.ts | 41 +++++++++ .../workbench-dialog-header.directive.ts | 46 ++++++++++ .../workbench/src/lib/dialog/public_api.ts | 3 + .../dialog/workbench-dialog.component.html | 40 ++++++--- .../dialog/workbench-dialog.component.scss | 75 ++++++---------- .../lib/dialog/workbench-dialog.component.ts | 35 +++----- .../lib/dialog/workbench-dialog.options.ts | 4 +- .../lib/dialog/workbench-dialog.service.ts | 73 +++++++++++++++- .../src/lib/dialog/workbench-dialog.ts | 13 +-- .../lib/dialog/\311\265workbench-dialog.ts" | 67 +++++++++++---- .../view-menu.component.scss | 8 +- .../view-tab-content.component.scss | 11 +-- 31 files changed, 707 insertions(+), 206 deletions(-) create mode 100644 projects/scion/workbench/src/lib/common/workbench-mixins.scss create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-action-filter.pipe.ts create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.html create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.scss create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.ts create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-action.directive.ts create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-footer.directive.ts create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.html create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.scss create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.ts create mode 100644 projects/scion/workbench/src/lib/dialog/dialog-header/workbench-dialog-header.directive.ts diff --git a/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html index f0a99f64a..117145c1a 100644 --- a/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html +++ b/apps/workbench-testing-app/src/app/dialog-opener-page/dialog-opener-page.component.html @@ -11,7 +11,7 @@
- + diff --git a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html index 6e380a1a3..433ee8c67 100644 --- a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html +++ b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.html @@ -78,7 +78,7 @@ - + @@ -93,7 +93,13 @@ -
+ + + + + - -
+ diff --git a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss index 94a1c2ff1..7b0416c92 100644 --- a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss +++ b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.scss @@ -1,11 +1,9 @@ @use '@scion/components.internal/design' as sci-design; :host { + display: block; --ɵapp-dialog-page-height: initial; --ɵapp-dialog-page-width: initial; - - display: flex; - flex-direction: column; height: var(--ɵapp-dialog-page-height); width: var(--ɵapp-dialog-page-width); overflow: hidden; // crop overflow when changing minimum size in end-to-end test @@ -26,12 +24,10 @@ } } } +} - > div.buttons { - flex: none; - display: grid; - grid-template-columns: 1fr 1fr; - column-gap: .25em; - margin-top: 1em; - } +label { + display: flex; + align-items: center; + gap: .5em; } diff --git a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts index 7dd7d193c..3188fbeca 100644 --- a/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts +++ b/apps/workbench-testing-app/src/app/dialog-page/dialog-page.component.ts @@ -9,7 +9,7 @@ */ import {Component, HostBinding, Input} from '@angular/core'; -import {WorkbenchDialog} from '@scion/workbench'; +import {WorkbenchDialog, WorkbenchDialogActionDirective} from '@scion/workbench'; import {FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; import {UUID} from '@scion/toolkit/uuid'; import {NgIf} from '@angular/common'; @@ -30,6 +30,7 @@ import {SciCheckboxComponent} from '@scion/components.internal/checkbox'; SciAccordionComponent, SciAccordionItemDirective, SciCheckboxComponent, + WorkbenchDialogActionDirective, ], }) export class DialogPageComponent { @@ -54,8 +55,9 @@ export class DialogPageComponent { resizable: this._formBuilder.control(this.dialog.resizable), }), styles: new FormGroup({ - padding: this._formBuilder.control(''), + padding: this._formBuilder.control(this.dialog.padding), }), + closeWithError: this._formBuilder.control(false), result: this._formBuilder.control(''), }); @@ -77,11 +79,12 @@ export class DialogPageComponent { } public onClose(): void { - this.dialog.close(this.form.controls.result.value); - } - - public onCloseWithError(): void { - this.dialog.closeWithError(this.form.controls.result.value); + if (this.form.controls.closeWithError.value) { + this.dialog.closeWithError(this.form.controls.result.value); + } + else { + this.dialog.close(this.form.controls.result.value); + } } private installPropertyUpdater(): void { diff --git a/docs/site/howto/how-to-open-dialog.md b/docs/site/howto/how-to-open-dialog.md index 23a46a599..dd6b13661 100644 --- a/docs/site/howto/how-to-open-dialog.md +++ b/docs/site/howto/how-to-open-dialog.md @@ -18,7 +18,7 @@ const dialogService = inject(WorkbenchDialogService); dialogService.open(MyDialogComponent); ``` -### How to control the modality of a dialog +### How to set the modality of a dialog A dialog can be view-modal or application-modal. A view-modal dialog blocks only a specific view, allowing the user to interact with other views. An application-modal dialog blocks the workbench by default, or the browser's viewport, if set in the global workbench settings. By default, the calling context determines the modality of the dialog. If the dialog is opened from a view, only this view is blocked. To open the dialog with a different modality, specify the modality in the dialog options. @@ -80,6 +80,37 @@ The dialog component can inject the `WorkbenchDialog` handle and set the title. inject(WorkbenchDialog).title = 'My dialog title'; ``` +### How to contribute to the dialog footer +A dialog has a default footer that displays actions defined in the HTML of the dialog component. An action is an Angular template decorated with the `wbDialogAction` directive. Multiple actions are supported, rendered in modeling order, and can be left- or right-aligned. + +```html + + + + + + + + + + + + + + +``` + +Alternatively, the dialog supports the use of a custom footer. To provide a custom footer, add an Angular template to the HTML of the dialog component and decorate it with the `wbDialogFooter` directive. + +```html + + + +``` + ### How to close the dialog The dialog component can inject the `WorkbenchDialog` handle and close the dialog, optionally passing a result to the dialog opener. @@ -116,6 +147,49 @@ inject(WorkbenchDialog).size.maxHeight = '900px'; inject(WorkbenchDialog).size.maxWidth = '700px'; ``` +### How to use a custom dialog header +By default, the dialog displays the title and a close button in the header. Alternatively, the dialog supports the use of a custom header. To provide a custom header, add an Angular template to the HTML of the dialog component and decorate it with the `wbDialogHeader` directive. +```html + + + +``` + +### How to change default dialog settings +The dialog component can inject the dialog handle `WorkbenchDialog` to interact with the dialog and change its default settings, such as making it non-closable, non-resizable, removing padding, and more. + +```ts +const dialog = inject(WorkbenchDialog); +dialog.closable = false; +dialog.resizable = false; +dialog.padding = false; +``` + +### How to change the default look of a dialog +The following CSS variables can be set to customize the default look of a dialog. + +- `--sci-workbench-dialog-padding` +- `--sci-workbench-dialog-header-height` +- `--sci-workbench-dialog-header-background-color` +- `--sci-workbench-dialog-title-font-family` +- `--sci-workbench-dialog-title-font-weight` +- `--sci-workbench-dialog-title-font-size` +- `--sci-workbench-dialog-title-align` + +To style a specific dialog, associate the dialog with a CSS class that can be referenced in a CSS selector. + +```ts +inject(WorkbenchDialogService).open(MyDialogComponent, { + cssClass: 'fancy' +}); +``` + +```css +wb-dialog.fancy { + --sci-workbench-dialog-title-align: start; +} +``` + [menu-how-to]: /docs/site/howto/how-to.md [menu-home]: /README.md diff --git a/projects/scion/e2e-testing/src/dialog-page.po.ts b/projects/scion/e2e-testing/src/dialog-page.po.ts index 3f03bd592..27f5b5b95 100644 --- a/projects/scion/e2e-testing/src/dialog-page.po.ts +++ b/projects/scion/e2e-testing/src/dialog-page.po.ts @@ -21,26 +21,10 @@ export class DialogPagePO { public readonly locator: Locator; public readonly input: Locator; - private readonly _title: Locator; - private readonly _closeButton: Locator; - private readonly _closeWithErrorButton: Locator; - private readonly _dialogSizeAccordion: SciAccordionPO; - private readonly _contentSizeAccordion: SciAccordionPO; - private readonly _behaviorAccordion: SciAccordionPO; - private readonly _stylesAccordion: SciAccordionPO; - private readonly _returnValueAccordion: SciAccordionPO; - constructor(dialog: DialogPO) { - this.locator = dialog.locator.locator('app-dialog-page'); + constructor(private _dialog: DialogPO) { + this.locator = this._dialog.locator.locator('app-dialog-page'); this.input = this.locator.locator('input.e2e-input'); - this._title = this.locator.locator('input.e2e-title'); - this._dialogSizeAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-dialog-size')); - this._contentSizeAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-content-size')); - this._behaviorAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-behavior')); - this._stylesAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-styles')); - this._returnValueAccordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-return-value')); - this._closeButton = this.locator.locator('button.e2e-close'); - this._closeWithErrorButton = this.locator.locator('button.e2e-close-with-error'); } public getComponentInstanceId(): Promise { @@ -48,43 +32,48 @@ export class DialogPagePO { } public async enterTitle(title: string): Promise { - await this._title.fill(title); + await this.locator.locator('input.e2e-title').fill(title); } public async setClosable(closable: boolean): Promise { - await this._behaviorAccordion.expand(); - await new SciCheckboxPO(this._behaviorAccordion.itemLocator().locator('sci-checkbox.e2e-closable')).toggle(closable); - await this._behaviorAccordion.collapse(); + const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-behavior')); + await accordion.expand(); + await new SciCheckboxPO(accordion.itemLocator().locator('sci-checkbox.e2e-closable')).toggle(closable); + await accordion.collapse(); } public async setResizable(resizable: boolean): Promise { - await this._behaviorAccordion.expand(); - await new SciCheckboxPO(this._behaviorAccordion.itemLocator().locator('sci-checkbox.e2e-resizable')).toggle(resizable); - await this._behaviorAccordion.collapse(); + const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-behavior')); + await accordion.expand(); + await new SciCheckboxPO(accordion.itemLocator().locator('sci-checkbox.e2e-resizable')).toggle(resizable); + await accordion.collapse(); } public async enterDialogSize(size: WorkbenchDialogSize): Promise { - await this._dialogSizeAccordion.expand(); - await this._dialogSizeAccordion.itemLocator().locator('input.e2e-min-height').fill(size.minHeight ?? ''); - await this._dialogSizeAccordion.itemLocator().locator('input.e2e-height').fill(size.height ?? ''); - await this._dialogSizeAccordion.itemLocator().locator('input.e2e-max-height').fill(size.maxHeight ?? ''); - await this._dialogSizeAccordion.itemLocator().locator('input.e2e-min-width').fill(size.minWidth ?? ''); - await this._dialogSizeAccordion.itemLocator().locator('input.e2e-width').fill(size.width ?? ''); - await this._dialogSizeAccordion.itemLocator().locator('input.e2e-max-width').fill(size.maxWidth ?? ''); - await this._dialogSizeAccordion.collapse(); + const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-dialog-size')); + await accordion.expand(); + await accordion.itemLocator().locator('input.e2e-min-height').fill(size.minHeight ?? ''); + await accordion.itemLocator().locator('input.e2e-height').fill(size.height ?? ''); + await accordion.itemLocator().locator('input.e2e-max-height').fill(size.maxHeight ?? ''); + await accordion.itemLocator().locator('input.e2e-min-width').fill(size.minWidth ?? ''); + await accordion.itemLocator().locator('input.e2e-width').fill(size.width ?? ''); + await accordion.itemLocator().locator('input.e2e-max-width').fill(size.maxWidth ?? ''); + await accordion.collapse(); } public async enterContentSize(size: {width?: string; height?: string}): Promise { - await this._contentSizeAccordion.expand(); - await this._contentSizeAccordion.itemLocator().locator('input.e2e-height').fill(size.height ?? ''); - await this._contentSizeAccordion.itemLocator().locator('input.e2e-width').fill(size.width ?? ''); - await this._contentSizeAccordion.collapse(); + const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-content-size')); + await accordion.expand(); + await accordion.itemLocator().locator('input.e2e-height').fill(size.height ?? ''); + await accordion.itemLocator().locator('input.e2e-width').fill(size.width ?? ''); + await accordion.collapse(); } - public async setPadding(padding: string): Promise { - await this._stylesAccordion.expand(); - await this._stylesAccordion.itemLocator().locator('input.e2e-padding').fill(padding); - await this._stylesAccordion.collapse(); + public async setPadding(padding: boolean): Promise { + const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-styles')); + await accordion.expand(); + await new SciCheckboxPO(accordion.itemLocator().locator('sci-checkbox.e2e-padding')).toggle(padding); + await accordion.collapse(); } public async close(options?: {returnValue?: string; closeWithError?: boolean}): Promise { @@ -93,16 +82,16 @@ export class DialogPagePO { } if (options?.closeWithError) { - await this._closeWithErrorButton.click(); - } - else { - await this._closeButton.click(); + await new SciCheckboxPO(this._dialog.footer.locator('sci-checkbox.e2e-close-with-error')).toggle(true); } + + await this._dialog.footer.locator('button.e2e-close').click(); } private async enterReturnValue(returnValue: string): Promise { - await this._returnValueAccordion.expand(); - await this._returnValueAccordion.itemLocator().locator('input.e2e-return-value').fill(returnValue); - await this._returnValueAccordion.collapse(); + const accordion = new SciAccordionPO(this.locator.locator('sci-accordion.e2e-return-value')); + await accordion.expand(); + await accordion.itemLocator().locator('input.e2e-return-value').fill(returnValue); + await accordion.collapse(); } } diff --git a/projects/scion/e2e-testing/src/dialog.po.ts b/projects/scion/e2e-testing/src/dialog.po.ts index a6eb24fcc..afd151bf4 100644 --- a/projects/scion/e2e-testing/src/dialog.po.ts +++ b/projects/scion/e2e-testing/src/dialog.po.ts @@ -16,12 +16,13 @@ import {DomRect, fromRect} from './helper/testing.util'; */ export class DialogPO { - private readonly _dialogPane: Locator; + private readonly _dialog: Locator; public readonly header: Locator; public readonly title: Locator; public readonly closeButton: Locator; public readonly resizeHandles: Locator; + public readonly footer: Locator; public readonly contentScrollbars: { vertical: Locator; @@ -29,19 +30,20 @@ export class DialogPO { }; constructor(public readonly locator: Locator) { - this._dialogPane = this.locator.locator('div.e2e-dialog-pane'); - this.header = this._dialogPane.locator('header.e2e-dialog-header'); + this._dialog = this.locator.locator('div.e2e-dialog'); + this.header = this._dialog.locator('header.e2e-dialog-header'); this.title = this.header.locator('div.e2e-title > span'); this.closeButton = this.header.locator('button.e2e-close'); - this.resizeHandles = this._dialogPane.locator('div.e2e-resize-handle'); + this.footer = this._dialog.locator('footer.e2e-dialog-footer'); + this.resizeHandles = this._dialog.locator('div.e2e-resize-handle'); this.contentScrollbars = { - vertical: this._dialogPane.locator('sci-viewport.e2e-dialog-content sci-scrollbar.e2e-vertical'), - horizontal: this._dialogPane.locator('sci-viewport.e2e-dialog-content sci-scrollbar.e2e-horizontal'), + vertical: this._dialog.locator('sci-viewport.e2e-dialog-content sci-scrollbar.e2e-vertical'), + horizontal: this._dialog.locator('sci-viewport.e2e-dialog-content sci-scrollbar.e2e-horizontal'), }; } public async getDialogBoundingBox(): Promise { - return fromRect(await this._dialogPane.boundingBox()); + return fromRect(await this._dialog.boundingBox()); } public async getGlassPaneBoundingBox(): Promise { @@ -52,8 +54,12 @@ export class DialogPO { return fromRect(await this.header.boundingBox()); } + public async getFooterBoundingBox(): Promise { + return fromRect(await this.footer.boundingBox()); + } + public async getDialogBorderWidth(): Promise { - return this._dialogPane.evaluate((element: HTMLElement) => parseInt(getComputedStyle(element).borderWidth, 10)); + return this._dialog.locator('div.e2e-dialog-box').evaluate((element: HTMLElement) => parseInt(getComputedStyle(element).borderWidth, 10)); } public async close(options?: {timeout?: number}): Promise { diff --git a/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts b/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts index 760affa74..d83291cd3 100644 --- a/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts +++ b/projects/scion/e2e-testing/src/workbench/dialog.e2e-spec.ts @@ -765,11 +765,12 @@ test.describe('Workbench Dialog', () => { const dialogPage = new DialogPagePO(dialog); // Unset padding to facilitate size assertion. - await dialogPage.setPadding('0'); + await dialogPage.setPadding(false); - // Capture border and header width. + // Capture border, header and footer height. const dialogBorder = 2 * await dialog.getDialogBorderWidth(); const dialogHeaderHeight = (await dialog.getHeaderBoundingBox()).height; + const dialogFooterHeight = (await dialog.getFooterBoundingBox()).height; // Change the size of the content. await dialogPage.enterContentSize({ @@ -779,7 +780,7 @@ test.describe('Workbench Dialog', () => { // Expect the dialog to adapt to the content size. await expect.poll(() => dialog.getDialogBoundingBox()).toEqual(expect.objectContaining({ - height: 500 + dialogBorder + dialogHeaderHeight, + height: 500 + dialogBorder + dialogHeaderHeight + dialogFooterHeight, width: 500 + dialogBorder, })); @@ -816,7 +817,7 @@ test.describe('Workbench Dialog', () => { // Expect the dialog width to be max-width. await expect.poll(() => dialog.getDialogBoundingBox()).toEqual(expect.objectContaining({ - height: 500 + dialogBorder + dialogHeaderHeight, + height: 500 + dialogBorder + dialogHeaderHeight + dialogFooterHeight, width: 300, })); @@ -830,7 +831,7 @@ test.describe('Workbench Dialog', () => { // Expect the dialog width to be min-width. await expect.poll(() => dialog.getDialogBoundingBox()).toEqual(expect.objectContaining({ - height: 500 + dialogBorder + dialogHeaderHeight, + height: 500 + dialogBorder + dialogHeaderHeight + dialogFooterHeight, width: 600, })); diff --git a/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss b/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss index 7d95dbb65..a8ef84ab5 100644 --- a/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss +++ b/projects/scion/workbench/design/_workbench-dark-theme-design-tokens.scss @@ -40,7 +40,6 @@ $tokens: ( --sci-workbench-dialog-padding: .75em, --sci-workbench-dialog-header-height: 2.75rem, --sci-workbench-dialog-header-background-color: var(--sci-color-background-elevation), - --sci-workbench-dialog-header-divider-color: var(--sci-color-border), --sci-workbench-dialog-title-font-family: inherit, --sci-workbench-dialog-title-font-weight: normal, --sci-workbench-dialog-title-font-size: 1.1rem, diff --git a/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss b/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss index 429b7cb90..8e06d5cad 100644 --- a/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss +++ b/projects/scion/workbench/design/_workbench-light-theme-design-tokens.scss @@ -40,7 +40,6 @@ $tokens: ( --sci-workbench-dialog-padding: .75em, --sci-workbench-dialog-header-height: 2.75rem, --sci-workbench-dialog-header-background-color: var(--sci-color-background-elevation), - --sci-workbench-dialog-header-divider-color: var(--sci-color-border), --sci-workbench-dialog-title-font-family: inherit, --sci-workbench-dialog-title-font-weight: normal, --sci-workbench-dialog-title-font-size: 1.1rem, diff --git a/projects/scion/workbench/src/lib/common/workbench-mixins.scss b/projects/scion/workbench/src/lib/common/workbench-mixins.scss new file mode 100644 index 000000000..0b5d649ed --- /dev/null +++ b/projects/scion/workbench/src/lib/common/workbench-mixins.scss @@ -0,0 +1,12 @@ +/** + * SASS mixins used by the SCION Workbench. + */ + +/** + * Displays ellipsis when text overflows. + */ +@mixin ellipsis-on-overflow { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-action-filter.pipe.ts b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-action-filter.pipe.ts new file mode 100644 index 000000000..a9abff184 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-action-filter.pipe.ts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2023 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import {Pipe, PipeTransform} from '@angular/core'; +import {WorkbenchDialogActionDirective} from './workbench-dialog-action.directive'; + +/** + * Filters actions by specified alignment. + */ +@Pipe({name: 'wbDialogActionFilter', standalone: true}) +export class DialogActionFilterPipe implements PipeTransform { + + public transform(actions: WorkbenchDialogActionDirective[] | null | undefined, align: 'start' | 'end'): WorkbenchDialogActionDirective[] { + return actions?.filter(action => action.align === align) ?? []; + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.html b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.html new file mode 100644 index 000000000..82ecb684c --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.html @@ -0,0 +1,17 @@ + +@if (dialog.actions | wbDialogActionFilter:'start' | wbNullIfEmpty; as actions) { +
+ @for (action of actions; track action) { + + } +
+} + + +@if (dialog.actions | wbDialogActionFilter:'end' | wbNullIfEmpty; as actions) { +
+ @for (action of actions; track action) { + + } +
+} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.scss b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.scss new file mode 100644 index 000000000..25a479d00 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.scss @@ -0,0 +1,20 @@ +:host { + display: flex; + padding: calc(.75 * var(--sci-workbench-dialog-padding)) var(--sci-workbench-dialog-padding); + gap: calc(2 * var(--sci-workbench-dialog-padding)); + + > div.actions { + flex: auto; + display: flex; + align-items: center; + gap: calc(.5 * var(--sci-workbench-dialog-padding)); + + &.start { + place-content: flex-start; + } + + &.end { + place-content: flex-end; + } + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.ts b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.ts new file mode 100644 index 000000000..4767f4140 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-footer/dialog-footer.component.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2018-2023 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +import {Component} from '@angular/core'; +import {ɵWorkbenchDialog} from '../ɵworkbench-dialog'; +import {NgTemplateOutlet} from '@angular/common'; +import {NullIfEmptyPipe} from '../../common/null-if-empty.pipe'; +import {DialogActionFilterPipe} from './dialog-action-filter.pipe'; + +/** + * Renders the dialog footer with actions modeled as {@link WorkbenchDialogActionDirective} templates. + */ +@Component({ + selector: 'wb-dialog-footer', + templateUrl: './dialog-footer.component.html', + styleUrls: ['./dialog-footer.component.scss'], + standalone: true, + imports: [ + NgTemplateOutlet, + NullIfEmptyPipe, + DialogActionFilterPipe, + ], +}) +export class DialogFooterComponent { + + constructor(protected dialog: ɵWorkbenchDialog) { + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-action.directive.ts b/projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-action.directive.ts new file mode 100644 index 000000000..5aaa766de --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-action.directive.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2023 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import {Directive, Input, OnDestroy, TemplateRef} from '@angular/core'; +import {ɵWorkbenchDialog} from '../ɵworkbench-dialog'; +import {Disposable} from '../../common/disposable'; + +/** + * Use this directive to contribute an action to the dialog footer (only applicable if not using a custom dialog footer). + * + * Actions are displayed in the order as modeled in the template and can be placed either on the left or right. + * + * The host element of this modeling directive must be a . The action shares the lifecycle of the host element. + * + * **Example:** + * ```html + * + * + * + * ``` + */ +@Directive({selector: 'ng-template[wbDialogAction]', standalone: true}) +export class WorkbenchDialogActionDirective implements OnDestroy { + + private _action: Disposable; + + /** + * Specifies where to place this action in the dialog footer. Default is `end`. + */ + @Input() + public align: 'start' | 'end' = 'end'; + + constructor(public readonly template: TemplateRef, dialog: ɵWorkbenchDialog) { + this._action = dialog.registerAction(this); + } + + public ngOnDestroy(): void { + this._action.dispose(); + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-footer.directive.ts b/projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-footer.directive.ts new file mode 100644 index 000000000..44b2bdc4d --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-footer/workbench-dialog-footer.directive.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2023 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import {booleanAttribute, Directive, Input, OnDestroy, TemplateRef} from '@angular/core'; +import {ɵWorkbenchDialog} from '../ɵworkbench-dialog'; +import {Disposable} from '../../common/disposable'; + +/** + * Use this directive to replace the default dialog footer that renders actions contributed via the {@link WorkbenchDialogActionDirective} directive. + * + * The host element of this modeling directive must be a . The footer shares the lifecycle of the host element. + * + * **Example:** + * ```html + * + * + * + * ``` + */ +@Directive({selector: 'ng-template[wbDialogFooter]', standalone: true}) +export class WorkbenchDialogFooterDirective implements OnDestroy { + + private _footer: Disposable; + + /** + * Specifies if to display a visual separator between the dialog content and this footer. + * Default is `true`. + */ + @Input({transform: booleanAttribute}) + public divider?: boolean; + + constructor(public readonly template: TemplateRef, dialog: ɵWorkbenchDialog) { + this._footer = dialog.registerFooter(this); + } + + public ngOnDestroy(): void { + this._footer.dispose(); + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.html b/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.html new file mode 100644 index 000000000..d84341360 --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.html @@ -0,0 +1,13 @@ +@if (dialog.title | wbCoerceObservable$ | async; as title) { +
+ {{ title }} +
+} + +@if (dialog.closable) { + +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.scss b/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.scss new file mode 100644 index 000000000..ae5245e2a --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.scss @@ -0,0 +1,40 @@ +@use '../../common/workbench-mixins'; + +:host { + display: flex; + gap: var(--sci-workbench-dialog-padding); + place-content: flex-end; + padding-inline: var(--sci-workbench-dialog-padding); + background-color: var(--sci-workbench-dialog-header-background-color); + user-select: none; + height: var(--sci-workbench-dialog-header-height); + + > div.title { + flex: auto; + display: flex; + align-items: center; + position: relative; // positioning context for the title span + + > span { + position: absolute; // out of document flow to not contribute to the dialog width + left: 0; + right: 0; + @include workbench-mixins.ellipsis-on-overflow(); + font-family: var(--sci-workbench-dialog-title-font-family); + font-size: var(--sci-workbench-dialog-title-font-size); + font-weight: var(--sci-workbench-dialog-title-font-weight); + text-align: var(--sci-workbench-dialog-title-align); + } + } + + > button.close { + all: unset; + flex: none; + cursor: pointer; + align-self: center; + + &:not(:hover) { + opacity: .75; + } + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.ts b/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.ts new file mode 100644 index 000000000..555adef4e --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-header/dialog-header.component.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2023 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import {Component} from '@angular/core'; +import {AsyncPipe} from '@angular/common'; +import {CoerceObservablePipe} from '../../common/coerce-observable.pipe'; +import {ɵWorkbenchDialog} from '../ɵworkbench-dialog'; + +/** + * Renders the dialog header with a close button and optional title. + */ +@Component({ + selector: 'wb-dialog-header', + templateUrl: './dialog-header.component.html', + styleUrls: ['./dialog-header.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + CoerceObservablePipe, + ], +}) +export class DialogHeaderComponent { + + constructor(protected dialog: ɵWorkbenchDialog) { + } + + protected onCloseClick(): void { + this.dialog.close(); + } + + protected onCloseMouseDown(event: Event): void { + event.stopPropagation(); // Prevent dragging the dialog with the close button. + } +} diff --git a/projects/scion/workbench/src/lib/dialog/dialog-header/workbench-dialog-header.directive.ts b/projects/scion/workbench/src/lib/dialog/dialog-header/workbench-dialog-header.directive.ts new file mode 100644 index 000000000..46151bacd --- /dev/null +++ b/projects/scion/workbench/src/lib/dialog/dialog-header/workbench-dialog-header.directive.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2023 Swiss Federal Railways + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import {booleanAttribute, Directive, Input, OnDestroy, TemplateRef} from '@angular/core'; +import {ɵWorkbenchDialog} from '../ɵworkbench-dialog'; +import {Disposable} from '../../common/disposable'; + +/** + * Use this directive to replace the default dialog header that displays the title and a close button. + * + * The host element of this modeling directive must be a . The header shares the lifecycle of the host element. + * + * **Example:** + * ```html + * + * + * + * ``` + */ +@Directive({selector: 'ng-template[wbDialogHeader]', standalone: true}) +export class WorkbenchDialogHeaderDirective implements OnDestroy { + + private _header: Disposable; + + /** + * Specifies if to display a visual separator between this header and the dialog content. + * Default is `true`. + */ + @Input({transform: booleanAttribute}) + public divider?: boolean; + + constructor(public readonly template: TemplateRef, dialog: ɵWorkbenchDialog) { + this._header = dialog.registerHeader(this); + } + + public ngOnDestroy(): void { + this._header.dispose(); + } +} diff --git a/projects/scion/workbench/src/lib/dialog/public_api.ts b/projects/scion/workbench/src/lib/dialog/public_api.ts index ae059a86b..9d1c0ee2d 100644 --- a/projects/scion/workbench/src/lib/dialog/public_api.ts +++ b/projects/scion/workbench/src/lib/dialog/public_api.ts @@ -11,3 +11,6 @@ export {WorkbenchDialog, WorkbenchDialogSize} from './workbench-dialog'; export {WorkbenchDialogOptions} from './workbench-dialog.options'; export {WorkbenchDialogService} from './workbench-dialog.service'; +export {WorkbenchDialogActionDirective} from './dialog-footer/workbench-dialog-action.directive'; +export {WorkbenchDialogFooterDirective} from './dialog-footer/workbench-dialog-footer.directive'; +export {WorkbenchDialogHeaderDirective} from './dialog-header/workbench-dialog-header.directive'; diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html index cac99b5b6..aee340130 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.html @@ -1,22 +1,36 @@ -
-
-
-
- {{title}} -
- + #dialog_element> +
+
+
- - + + -
+ + @if (dialog.footer || dialog.actions.length) { +
+ +
+ } +
+ + + + + + + + diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss index bdc6293e5..0bd5a9b9e 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.scss @@ -1,9 +1,3 @@ -@mixin show-ellipsis-on-overflow { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} - :host { --ɵdialog-transform-translate-x: 0; --ɵdialog-transform-translate-y: 0; @@ -15,6 +9,10 @@ --ɵdialog-max-width: initial; --ɵdialog-padding: var(--sci-workbench-dialog-padding); + &.justified { + --ɵdialog-padding: 0; + } + display: flex; flex-direction: column; align-items: center; @@ -28,16 +26,12 @@ margin: 2px; } - > div.dialog-pane { + > div.dialog { display: flex; flex-direction: column; position: absolute; top: 3%; - border: 1px solid var(--sci-color-border); - border-radius: var(--sci-corner); color: var(--sci-color-text); - background-color: var(--sci-color-background-elevation); - box-shadow: var(--sci-elevation) var(--sci-static-color-black); transform: translateX(calc(1px * var(--ɵdialog-transform-translate-x))) translateY(calc(1px * var(--ɵdialog-transform-translate-y))); min-height: var(--ɵdialog-min-height); height: var(--ɵdialog-height); @@ -45,52 +39,27 @@ min-width: var(--ɵdialog-min-width); width: var(--ɵdialog-width); max-width: var(--ɵdialog-max-width); + outline: none; - > section { + > div.dialog-box { flex: auto; display: flex; flex-direction: column; gap: calc(1.25 * var(--ɵdialog-padding)); - border-radius: inherit; - overflow: hidden; // must not be set on dialog-pane to not crop resize handles + // An element with rounded corners must hide content overflow so that the content does not overlap the corners. + // However, the resize handles should still overlap for better accessibility. Therefore, we use this extra DIV to + // set the border and overflow properties. + border: 1px solid var(--sci-color-border); + border-radius: var(--sci-corner); + background-color: var(--sci-color-background-elevation); + box-shadow: var(--sci-elevation) var(--sci-static-color-black); + overflow: hidden; > header { flex: none; - display: flex; - place-content: flex-end; - gap: var(--ɵdialog-padding); - background-color: var(--sci-workbench-dialog-header-background-color); - border-bottom: 1px solid var(--sci-workbench-dialog-header-divider-color); - height: var(--sci-workbench-dialog-header-height); - padding-inline: var(--ɵdialog-padding); - > div.title { - flex: auto; - display: flex; - align-items: center; - position: relative; // positioning context for the title span - - > span { - position: absolute; // out of document flow to not contribute to the dialog width - left: 0; - right: 0; - @include show-ellipsis-on-overflow; - font-family: var(--sci-workbench-dialog-title-font-family); - font-size: var(--sci-workbench-dialog-title-font-size); - font-weight: var(--sci-workbench-dialog-title-font-weight); - text-align: var(--sci-workbench-dialog-title-align); - } - } - - > button.close { - all: unset; - flex: none; - cursor: pointer; - align-self: center; - - &:not(:hover) { - opacity: .75; - } + &.divider { + border-bottom: 1px solid var(--sci-color-border); } } @@ -98,7 +67,15 @@ flex: auto; &::part(content) { - padding: var(--ɵdialog-padding); + padding-inline: var(--ɵdialog-padding); + } + } + + > footer { + flex: none; + + &.divider { + border-top: 1px solid var(--sci-color-border); } } } diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts index 82d7f64d2..b9618a673 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.component.ts @@ -13,8 +13,7 @@ import {AfterViewInit, Component, DestroyRef, ElementRef, HostBinding, HostListe import {EMPTY, fromEvent, Subject, switchMap, timer} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {A11yModule, CdkTrapFocus} from '@angular/cdk/a11y'; -import {AsyncPipe, DOCUMENT, NgComponentOutlet, NgIf} from '@angular/common'; -import {CoerceObservablePipe} from '../common/coerce-observable.pipe'; +import {DOCUMENT, NgComponentOutlet, NgTemplateOutlet} from '@angular/common'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {ɵWorkbenchDialog} from './ɵworkbench-dialog'; import {SciViewportComponent} from '@scion/components/viewport'; @@ -24,6 +23,8 @@ import {WorkbenchDialogRegistry} from './workbench-dialog.registry'; import {MovableDirective, WbMoveEvent} from './movable.directive'; import {ResizableDirective, WbResizeEvent} from './resizable.directive'; import {SciDimension, SciDimensionDirective} from '@scion/components/dimension'; +import {DialogHeaderComponent} from './dialog-header/dialog-header.component'; +import {DialogFooterComponent} from './dialog-footer/dialog-footer.component'; /** * Renders the workbench dialog. @@ -39,15 +40,15 @@ import {SciDimension, SciDimensionDirective} from '@scion/components/dimension'; styleUrls: ['./workbench-dialog.component.scss'], standalone: true, imports: [ - NgIf, - AsyncPipe, NgComponentOutlet, + NgTemplateOutlet, A11yModule, MovableDirective, ResizableDirective, - CoerceObservablePipe, SciViewportComponent, SciDimensionDirective, + DialogHeaderComponent, + DialogFooterComponent, ], animations: [ trigger('enter', provideEnterAnimation()), @@ -63,8 +64,8 @@ export class WorkbenchDialogComponent implements OnInit, AfterViewInit { @ViewChild(CdkTrapFocus, {static: true}) private _cdkTrapFocus!: CdkTrapFocus; - @ViewChild('dialog_pane', {static: true}) - private _dialogPane!: ElementRef; + @ViewChild('dialog_element', {static: true}) + private _dialogElement!: ElementRef; @HostBinding('style.--ɵdialog-transform-translate-x') protected transformTranslateX = 0; @@ -102,9 +103,9 @@ export class WorkbenchDialogComponent implements OnInit, AfterViewInit { return this.dialog.size.maxWidth; } - @HostBinding('style.--ɵdialog-padding') - protected get padding(): string | undefined { - return this.dialog.padding; + @HostBinding('class.justified') + protected get justified(): boolean { + return !this.dialog.padding; } @HostBinding('attr.class') @@ -143,7 +144,7 @@ export class WorkbenchDialogComponent implements OnInit, AfterViewInit { this._activeElement.focus(); } else if (!this._cdkTrapFocus.focusTrap.focusFirstTabbableElement()) { - this._dialogPane.nativeElement.focus(); + this._dialogElement.nativeElement.focus(); } } @@ -157,7 +158,7 @@ export class WorkbenchDialogComponent implements OnInit, AfterViewInit { * Tracks the focus of the dialog. */ private trackFocus(): void { - fromEvent(this._dialogPane.nativeElement, 'focusin') + fromEvent(this._dialogElement.nativeElement, 'focusin') .pipe( subscribeInside(continueFn => this._zone.runOutsideAngular(continueFn)), takeUntilDestroyed(this._destroyRef), @@ -173,7 +174,7 @@ export class WorkbenchDialogComponent implements OnInit, AfterViewInit { private preventFocusIfBlocked(): void { this.dialog.blocked$ .pipe( - switchMap(blocked => blocked ? fromEvent(this._dialogPane.nativeElement, 'focusin') : EMPTY), + switchMap(blocked => blocked ? fromEvent(this._dialogElement.nativeElement, 'focusin') : EMPTY), takeUntilDestroyed(this._destroyRef), ) .subscribe(() => { @@ -232,14 +233,6 @@ export class WorkbenchDialogComponent implements OnInit, AfterViewInit { } } - protected onCloseClick(): void { - this.dialog.close(); - } - - protected onCloseMouseDown(event: Event): void { - event.stopPropagation(); // Prevent dragging with the close button. - } - protected onHeaderDimensionChange(dimension: SciDimension): void { this._headerHeight = `${dimension.offsetHeight}px`; } diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts index 033f03c35..3f74043d2 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.options.ts @@ -18,9 +18,10 @@ export interface WorkbenchDialogOptions { /** * Optional data to pass to the dialog component. Inputs are available as input properties in the dialog component. * + * **Example:** * ```ts * @Input() - * public myInputName: string; + * public someInput: string; * ``` */ inputs?: {[name: string]: unknown}; @@ -42,6 +43,7 @@ export interface WorkbenchDialogOptions { * for injection into the dialog component. If not specified, uses the application's root injector, or the view's * injector if opened in the context of a view. * + * **Example:** * ```ts * Injector.create({ * parent: ..., diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts index e7c21dbbe..b159e95b7 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.service.ts @@ -17,8 +17,9 @@ import {ɵWorkbenchDialogService} from './ɵworkbench-dialog.service'; * Enables the display of a component in a modal dialog. * * A dialog is a visual element for focused interaction with the user, such as prompting the user for input or confirming actions. - * Displayed on top of other content, a dialog blocks interaction with other parts of the application. The user can move or resize - * a dialog. + * The user can move or resize a dialog. + * + * Displayed on top of other content, a dialog blocks interaction with other parts of the application. * * ## Modality * A dialog can be view-modal or application-modal. @@ -32,6 +33,74 @@ import {ɵWorkbenchDialogService} from './ɵworkbench-dialog.service'; * ## Dialog Component * The dialog component can inject the {@link WorkbenchDialog} handle to interact with the dialog, such as setting the title or closing the dialog. * Inputs passed to the dialog are available as input properties in the dialog component. + * + * ## Dialog Header + * By default, the dialog displays the title and a close button in the header. Alternatively, the dialog supports the use of a custom header. + * To provide a custom header, add an Angular template to the HTML of the dialog component and decorate it with the `wbDialogHeader` directive. + * + * ```html + * + * + * + * ``` + * + * ## Dialog Footer + * A dialog has a default footer that displays actions defined in the HTML of the dialog component. An action is an Angular template decorated with + * the `wbDialogAction` directive. Multiple actions are supported, rendered in modeling order, and can be left- or right-aligned. + * + * ```html + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * ``` + * + * Alternatively, the dialog supports the use of a custom footer. To provide a custom footer, add an Angular template to the HTML of the dialog component and + * decorate it with the `wbDialogFooter` directive. + * + * ```html + * + * + * + * ``` + * + * ## Styling + * The following CSS variables can be set to customize the default look of a dialog. + * + * - `--sci-workbench-dialog-padding` + * - `--sci-workbench-dialog-header-height` + * - `--sci-workbench-dialog-header-background-color` + * - `--sci-workbench-dialog-title-font-family` + * - `--sci-workbench-dialog-title-font-weight` + * - `--sci-workbench-dialog-title-font-size` + * - `--sci-workbench-dialog-title-align` + * + * To style a specific dialog, associate the dialog with a CSS class that can be referenced in a CSS selector. + * + * ```ts + * inject(WorkbenchDialogService).open(MyDialogComponent, { + * cssClass: 'fancy' + * }); + * ``` + * + * ```css + * wb-dialog.fancy { + * --sci-workbench-dialog-title-align: start; + * } + * ``` */ @Injectable({providedIn: 'root', useExisting: ɵWorkbenchDialogService}) export abstract class WorkbenchDialogService { diff --git a/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts b/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts index 9635cb0b8..f55fbe4e7 100644 --- a/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts +++ b/projects/scion/workbench/src/lib/dialog/workbench-dialog.ts @@ -30,18 +30,21 @@ export abstract class WorkbenchDialog { public abstract readonly size: WorkbenchDialogSize; /** - * Specifies the padding of the dialog. - * By default, uses the padding as specified in `--sci-workbench-dialog-padding` CSS variable. + * Controls if to apply a padding to the content of the dialog. Default is `true`. + * + * This setting does not affect the padding of the dialog header and footer. + * + * The default padding can be changed via the CSS variable `--sci-workbench-dialog-padding`. */ - public abstract padding: string | undefined; + public abstract padding: boolean; /** - * Specifies if to display a close button in the dialog header. Defaults to `true`. + * Specifies if to display a close button in the dialog header. Default is `true`. */ public abstract closable: boolean; /** - * Specifies if the user can resize the dialog. Defaults to `true`. + * Specifies if the user can resize the dialog. Default is `true`. */ public abstract resizable: boolean; diff --git "a/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" index a7296fd07..6fa1d50ca 100644 --- "a/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" +++ "b/projects/scion/workbench/src/lib/dialog/\311\265workbench-dialog.ts" @@ -27,6 +27,10 @@ import {WORKBENCH_ELEMENT_REF} from '../content-projection/view-container.refere import {Arrays} from '@scion/toolkit/util'; import {WorkbenchModuleConfig} from '../workbench-module-config'; import {filter} from 'rxjs/operators'; +import {WorkbenchDialogActionDirective} from './dialog-footer/workbench-dialog-action.directive'; +import {WorkbenchDialogFooterDirective} from './dialog-footer/workbench-dialog-footer.directive'; +import {WorkbenchDialogHeaderDirective} from './dialog-header/workbench-dialog-header.directive'; +import {Disposable} from '../common/disposable'; /** @inheritDoc */ export class ɵWorkbenchDialog implements WorkbenchDialog { @@ -37,29 +41,27 @@ export class ɵWorkbenchDialog implements WorkbenchDialog { private readonly _zone = inject(NgZone); private readonly _workbenchModuleConfig = inject(WorkbenchModuleConfig); private readonly _destroyRef = new ɵDestroyRef(); - private _componentRef: ComponentRef | undefined; - private _cssClass: string; - - public readonly size: WorkbenchDialogSize = {}; - public title: string | Observable | undefined; - public padding: string | undefined; - public closable = true; - public resizable = true; + private readonly _attached$: Observable; /** - * Indicates whether this dialog is attached to the DOM. + * Contains the result to be passed to the dialog opener. */ - private readonly _attached$: Observable; + private _result: R | ɵDialogErrorResult | undefined; + private _componentRef: ComponentRef | undefined; + private _cssClass: string; /** * Indicates whether this dialog is blocked by other dialog(s) that overlay this dialog. */ public readonly blocked$ = new BehaviorSubject(false); - - /** - * Contains the result to be passed to the dialog opener. - */ - private _result: R | ɵDialogErrorResult | undefined; + public readonly size: WorkbenchDialogSize = {}; + public title: string | Observable | undefined; + public closable = true; + public resizable = true; + public padding = true; + public header: WorkbenchDialogHeaderDirective | undefined; + public footer: WorkbenchDialogFooterDirective | undefined; + public actions = new Array(); constructor(public component: ComponentType, public _options: WorkbenchDialogOptions, public context: {view?: ɵWorkbenchView}) { this._overlayRef = this.createOverlay(); @@ -138,6 +140,41 @@ export class ɵWorkbenchDialog implements WorkbenchDialog { } } + public registerHeader(header: WorkbenchDialogHeaderDirective): Disposable { + this.header = header; + return { + dispose: () => { + if (this.header === header) { + this.header = undefined; + } + }, + }; + } + + public registerFooter(footer: WorkbenchDialogFooterDirective): Disposable { + if (this.actions.length) { + throw Error('[DialogInitError] Custom dialog footer not supported if using dialog actions.'); + } + this.footer = footer; + return { + dispose: () => { + if (this.footer === footer) { + this.footer = undefined; + } + }, + }; + } + + public registerAction(action: WorkbenchDialogActionDirective): Disposable { + if (this.footer) { + throw Error('[DialogInitError] Dialog actions not supported if using a custom dialog footer.'); + } + this.actions = this.actions.concat(action); + return { + dispose: () => this.actions = this.actions.filter(candidate => candidate !== action), + }; + } + /** * Returns the position of the dialog in the dialog stack. */ diff --git a/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.component.scss b/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.component.scss index b98bbfe37..5b4596f05 100644 --- a/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.component.scss +++ b/projects/scion/workbench/src/lib/part/view-context-menu/view-menu.component.scss @@ -1,8 +1,4 @@ -@mixin show-ellipsis-on-overflow { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} +@use '../../common/workbench-mixins'; :host { display: flex; @@ -25,7 +21,7 @@ > div.text { flex: auto; - @include show-ellipsis-on-overflow; + @include workbench-mixins.ellipsis-on-overflow(); } > div.accelerator { diff --git a/projects/scion/workbench/src/lib/part/view-tab-content/view-tab-content.component.scss b/projects/scion/workbench/src/lib/part/view-tab-content/view-tab-content.component.scss index b39bcbf50..2a0d5295b 100644 --- a/projects/scion/workbench/src/lib/part/view-tab-content/view-tab-content.component.scss +++ b/projects/scion/workbench/src/lib/part/view-tab-content/view-tab-content.component.scss @@ -1,10 +1,5 @@ @use '../../../../design/workbench-constants'; - -@mixin show-ellipsis-on-overflow { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} +@use '../../common/workbench-mixins'; :host { display: flex; @@ -13,7 +8,7 @@ overflow: hidden; // to have ellipsis on overflow > span.title { - @include show-ellipsis-on-overflow; + @include workbench-mixins.ellipsis-on-overflow(); font-weight: 400; font-size: 1em; @@ -23,7 +18,7 @@ } > span.heading { - @include show-ellipsis-on-overflow; + @include workbench-mixins.ellipsis-on-overflow(); font-size: .9em; font-weight: 300; }