diff --git a/apps/cookbook/src/app/examples/alert-experimental-example/alert-experimental-example.component.html b/apps/cookbook/src/app/examples/alert-experimental-example/alert-experimental-example.component.html new file mode 100644 index 0000000000..41d8ea934e --- /dev/null +++ b/apps/cookbook/src/app/examples/alert-experimental-example/alert-experimental-example.component.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/apps/cookbook/src/app/examples/alert-experimental-example/alert-experimental-example.component.ts b/apps/cookbook/src/app/examples/alert-experimental-example/alert-experimental-example.component.ts new file mode 100644 index 0000000000..f76dd42e66 --- /dev/null +++ b/apps/cookbook/src/app/examples/alert-experimental-example/alert-experimental-example.component.ts @@ -0,0 +1,193 @@ +import { Component } from '@angular/core'; +import { of, Subject, timer } from 'rxjs'; +import { map, takeUntil, takeWhile } from 'rxjs/operators'; + +import { + AlertExperimentalConfig, + AlertExperimentalController, +} from '@kirbydesign/designsystem/alert-experimental'; +import { ToastConfig, ToastController } from '@kirbydesign/designsystem'; + +const alertConfigWithIcon: AlertExperimentalConfig = { + title: 'Alert With Icon', + message: 'This is an alert with an icon.', + okButton: 'I agree', + cancelButton: 'Take me back', + icon: { name: 'warning', themeColor: 'warning' }, +}; + +export const observableCodeSnippet = `showAlert() { + const alert = this.alertController.showAlert(config); + alert.onWillDismiss.subscribe((response) => { + const { role, data } = response; + ... + }); + alert.onDidDismiss.subscribe((response) => { + const { role, data } = response; + ... + }); +}`; + +@Component({ + selector: 'cookbook-alert-experimental-example', + templateUrl: './alert-experimental-example.component.html', + styles: [':host { display: block; }'], +}) +export class AlertExperimentalExampleComponent { + static readonly alertConfigWithIcon = `const config: AlertExperimentalConfig = ${AlertExperimentalExampleComponent.stringify( + alertConfigWithIcon + )} + +this.alertController.showAlert(config);`; + + private static stringify(value: any): string { + return JSON.stringify(value, null, '\t') + .replace(/"(\w+)\":/g, '$1:') + .replace(/"/g, "'"); + } + + private alertClose$: Subject = new Subject(); + + static readonly alertConfigWithDynamicValues = `const title$ = of('Need more time?'); + const message$ = remainingSeconds$.pipe( + map((remainingSeconds) => \`Time remaining: \${remainingSeconds}\`) + ); + const config: AlertExperimentalConfig = { + title: title$, + icon: { + name: 'clock', + themeColor: 'warning', + }, + message: message$, + okBtn: 'Logout', + cancelBtn: 'Take me back', + }; + + this.alertController.showAlert(config);`; + constructor( + private alertController: AlertExperimentalController, + private toastController: ToastController + ) {} + + showAlert() { + const config: AlertExperimentalConfig = { + title: 'Default Alert', + message: 'The default alert is just a title, a message, an OK and (optional) cancel button', + okButton: 'I agree', + cancelButton: 'Take me back', + }; + + const alert = this.alertController.showAlert(config); + + alert.onDidDismiss.subscribe((result) => { + this.onAlertClosed(result.data); + }); + } + + showAlertWithIcon() { + const alert = this.alertController.showAlert(alertConfigWithIcon); + + alert.onDidDismiss.subscribe((result) => { + this.onAlertClosed(result.data); + }); + } + + showAlertWithoutCancel() { + const config: AlertExperimentalConfig = { + title: 'Alert Without Cancel', + message: 'This is an alert that can only be acknowledged (no cancel option)', + okButton: 'I understand', + }; + + const alert = this.alertController.showAlert(config); + + alert.onDidDismiss.subscribe((result) => { + this.onAlertClosed(result.data); + }); + } + + showDestructiveAlert() { + const config: AlertExperimentalConfig = { + title: 'Desctructive Alert', + message: + 'This is to indicate that something destructive will happen when clicking the OK button', + cancelButton: 'Get me out of here', + okButton: { text: 'Confirm', isDestructive: true }, + }; + + const alert = this.alertController.showAlert(config); + + alert.onDidDismiss.subscribe((result) => { + this.onAlertDestructiveClosed(result.data); + }); + } + + showAlertWithNewline() { + const config: AlertExperimentalConfig = { + title: 'Alert with newline', + message: 'This is message one.\n\nThis is message two.', + okButton: 'I agree', + cancelButton: 'Take me back', + }; + + const alert = this.alertController.showAlert(config); + + alert.onDidDismiss.subscribe((result) => { + this.onAlertClosed(result.data); + }); + } + + showAlertWithDynamicValues() { + const countdownTimeInSeconds = 60; + const countdownTimeInMs = countdownTimeInSeconds * 1000; + const intervalInMs = 1000; + const toRemainingTimeInMs = (count: number) => countdownTimeInMs - count * intervalInMs; + const toSeconds = (timeInMs: number) => Math.ceil(timeInMs / 1000); + + const remainingTime$ = timer(0, intervalInMs).pipe( + map(toRemainingTimeInMs), + takeUntil(this.alertClose$), + takeWhile((countdownTimeInMs) => countdownTimeInMs >= 0) + ); + + const title$ = of('Need more time?'); + const message$ = remainingTime$.pipe( + map((remainingTimeInMs) => `Time remaining: ${toSeconds(remainingTimeInMs)}`) + ); + const config: AlertExperimentalConfig = { + title: title$, + icon: { + name: 'clock', + themeColor: 'warning', + }, + message: message$, + okButton: 'Logout', + cancelButton: 'Take me back', + }; + + const alert = this.alertController.showAlert(config); + + alert.onDidDismiss.subscribe((result) => { + this.onAlertClosed(result.data); + }); + } + + private onAlertClosed(result?: boolean) { + const config: ToastConfig = { + message: `Alert selection: ${result}`, + messageType: result ? 'success' : 'warning', + durationInMs: 1500, + }; + this.toastController.showToast(config); + this.alertClose$.next(); + } + + private onAlertDestructiveClosed(result?: boolean) { + const config: ToastConfig = { + message: result ? 'Message deleted' : 'Nothing happened', + messageType: result ? 'warning' : 'success', + durationInMs: 1500, + }; + this.toastController.showToast(config); + } +} diff --git a/apps/cookbook/src/app/examples/examples.common.ts b/apps/cookbook/src/app/examples/examples.common.ts index ad96d7f92d..41fdf5e7fe 100644 --- a/apps/cookbook/src/app/examples/examples.common.ts +++ b/apps/cookbook/src/app/examples/examples.common.ts @@ -1,6 +1,7 @@ import { AccordionExampleComponent } from './accordion-example/accordion-example.component'; import { ActionSheetExampleComponent } from './action-sheet-example/action-sheet-example.component'; import { AlertExampleComponent } from './alert-example/alert-example.component'; +import { AlertExperimentalExampleComponent } from './alert-experimental-example/alert-experimental-example.component'; import { AvatarExampleComponent } from './avatar-example/avatar-example.component'; import { ButtonExampleComponent } from './button-example/button-example.component'; import { CalendarCardExampleComponent } from './calendar-example/calendar-card-example.component'; @@ -69,6 +70,7 @@ export const COMPONENT_DECLARATIONS: any[] = [ ActionSheetExampleComponent, CheckboxExampleComponent, AlertExampleComponent, + AlertExperimentalExampleComponent, ToastExampleComponent, ToggleExampleComponent, EmptyStateExampleComponent, diff --git a/apps/cookbook/src/app/examples/examples.module.ts b/apps/cookbook/src/app/examples/examples.module.ts index ef90fe77af..b37a88868a 100644 --- a/apps/cookbook/src/app/examples/examples.module.ts +++ b/apps/cookbook/src/app/examples/examples.module.ts @@ -9,6 +9,7 @@ import { } from '@kirbydesign/designsystem'; import { SlideModule } from '@kirbydesign/designsystem/slide'; +import { AlertExperimentalModule } from '@kirbydesign/designsystem/alert-experimental'; import { CodeViewerModule } from '../shared/code-viewer/code-viewer.module'; import { AccordionExampleModule } from './accordion-example/accordion-example.module'; import { AvatarExampleModule } from './avatar-example/avatar-example.module'; @@ -71,6 +72,7 @@ const IMPORTS = [ ExperimentalExamplesModule, DataTableExampleModule, SlideModule, + AlertExperimentalModule, ]; @NgModule({ diff --git a/apps/cookbook/src/app/showcase/alert-experimental-showcase/alert-experimental-showcase.component.html b/apps/cookbook/src/app/showcase/alert-experimental-showcase/alert-experimental-showcase.component.html new file mode 100644 index 0000000000..c88c2e0c6f --- /dev/null +++ b/apps/cookbook/src/app/showcase/alert-experimental-showcase/alert-experimental-showcase.component.html @@ -0,0 +1,44 @@ +
+ +

 

+

+ To show an alert, inject the Kirby + AlertExperimentalController + in your constructor, create an + AlertExperimentalConfig + and pass it to + alertController.showAlert + . + +

+

+ title + and + message + properties in + AlertExperimentalConfig + also accept an Observable for dynamic values: +
+ + (see "Example on github" for implementation details of the + remainingSeconds$ + timer) + + : + +

+

+ [Optional] + If you need to obtain data back from the alert, you can subscribe to the + onWillDismiss + and + onDidDismiss + events like this: + + +

+

Alert config properties:

+ +
diff --git a/apps/cookbook/src/app/showcase/alert-experimental-showcase/alert-experimental-showcase.component.ts b/apps/cookbook/src/app/showcase/alert-experimental-showcase/alert-experimental-showcase.component.ts new file mode 100644 index 0000000000..2c0695588c --- /dev/null +++ b/apps/cookbook/src/app/showcase/alert-experimental-showcase/alert-experimental-showcase.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { + AlertExperimentalExampleComponent, + observableCodeSnippet, +} from '../../examples/alert-experimental-example/alert-experimental-example.component'; +import { ApiDescriptionProperty } from '../../shared/api-description/api-description-properties/api-description-properties.component'; +@Component({ + selector: 'cookbook-alert-showcase', + templateUrl: './alert-experimental-showcase.component.html', + preserveWhitespaces: true, +}) +export class AlertExperimentalShowcaseComponent { + alertConfigWithIcon: string = AlertExperimentalExampleComponent.alertConfigWithIcon; + alertConfigWithDynamicValues: string = + AlertExperimentalExampleComponent.alertConfigWithDynamicValues; + observableCodeSnippet: string = observableCodeSnippet; + + properties: ApiDescriptionProperty[] = [ + { + name: 'title', + description: 'The title of the alert', + type: ['string | Observable'], + }, + { + name: 'message', + description: + "(Optional) The message shown under the title (or icon if specified). Use '\\n' for newline.", + type: ['string | Observable'], + }, + { + name: 'icon', + description: '(Optional) Icon to be shown below the title', + type: ['{ name: string }', '{ name: string, themeColor: string }'], + }, + { + name: 'okBtn', + description: + '(Optional) Defines the text that will appear on the OK button and if it should be destructive', + defaultValue: 'OK', + type: ['string', '{ text: string, isDestructive: boolean }'], + }, + { + name: 'cancelBtn', + description: + '(Optional) The text that will appear on the cancel button. If not defined the cancel button will not be shown.', + type: ['string'], + }, + ]; +} diff --git a/apps/cookbook/src/app/showcase/showcase.common.ts b/apps/cookbook/src/app/showcase/showcase.common.ts index 2ac1b10d1d..5d4431e830 100644 --- a/apps/cookbook/src/app/showcase/showcase.common.ts +++ b/apps/cookbook/src/app/showcase/showcase.common.ts @@ -8,6 +8,7 @@ import { ChartExampleConfigBaseBarComponent } from '../examples/charts-example/e import { AccordionShowcaseComponent } from './accordion-showcase/accordion-showcase.component'; import { ActionSheetShowcaseComponent } from './action-sheet-showcase/action-sheet-showcase.component'; import { AlertShowcaseComponent } from './alert-showcase/alert-showcase.component'; +import { AlertExperimentalShowcaseComponent } from './alert-experimental-showcase/alert-experimental-showcase.component'; import { AvatarShowcaseComponent } from './avatar-showcase/avatar-showcase.component'; import { BadgeShowcaseComponent } from './badge-showcase/badge-showcase.component'; import { ButtonShowcaseComponent } from './button-showcase/button-showcase.component'; @@ -83,6 +84,7 @@ export const COMPONENT_EXPORTS: any[] = [ ActionSheetShowcaseComponent, CheckboxShowcaseComponent, AlertShowcaseComponent, + AlertExperimentalShowcaseComponent, ToastShowcaseComponent, ToggleShowcaseComponent, ToggleButtonShowcaseComponent, diff --git a/apps/cookbook/src/app/showcase/showcase.routes.ts b/apps/cookbook/src/app/showcase/showcase.routes.ts index b79b83c710..3bee563519 100644 --- a/apps/cookbook/src/app/showcase/showcase.routes.ts +++ b/apps/cookbook/src/app/showcase/showcase.routes.ts @@ -10,6 +10,7 @@ import { ModalRoutingExperimentalExamplePage2Component } from '../examples/modal import { AccordionShowcaseComponent } from './accordion-showcase/accordion-showcase.component'; import { ActionSheetShowcaseComponent } from './action-sheet-showcase/action-sheet-showcase.component'; import { AlertShowcaseComponent } from './alert-showcase/alert-showcase.component'; +import { AlertExperimentalShowcaseComponent } from './alert-experimental-showcase/alert-experimental-showcase.component'; import { AvatarShowcaseComponent } from './avatar-showcase/avatar-showcase.component'; import { BadgeShowcaseComponent } from './badge-showcase/badge-showcase.component'; import { ButtonShowcaseComponent } from './button-showcase/button-showcase.component'; @@ -227,6 +228,10 @@ export const routes: Routes = [ path: 'alert', component: AlertShowcaseComponent, }, + { + path: 'alert-experimental', + component: AlertExperimentalShowcaseComponent, + }, { path: 'badge', component: BadgeShowcaseComponent, diff --git a/apps/cookbook/tsconfig.json b/apps/cookbook/tsconfig.json index 3435e6d4b5..58789a711c 100644 --- a/apps/cookbook/tsconfig.json +++ b/apps/cookbook/tsconfig.json @@ -56,7 +56,10 @@ "@kirbydesign/designsystem/data-table": ["libs/designsystem/data-table/index.ts"], "@kirbydesign/designsystem/reorder-list": ["libs/designsystem/reorder-list/index.ts"], "@kirbydesign/designsystem/toast": ["libs/designsystem/toast/index.ts"], - "@kirbydesign/designsystem/grid": ["libs/designsystem/grid/index.ts"], + "@kirbydesign/designsystem/grid": ["libs/designsystem/grid/index.ts"], + "@kirbydesign/designsystem/alert-experimental": [ + "libs/designsystem/alert-experimental/index.ts" + ] }, "target": "es2020" }, diff --git a/apps/flows/tsconfig.json b/apps/flows/tsconfig.json index 6eb0531d1d..93dd760165 100644 --- a/apps/flows/tsconfig.json +++ b/apps/flows/tsconfig.json @@ -74,7 +74,10 @@ "@kirbydesign/designsystem/data-table": ["libs/designsystem/data-table/index.ts"], "@kirbydesign/designsystem/reorder-list": ["libs/designsystem/reorder-list/index.ts"], "@kirbydesign/designsystem/toast": ["libs/designsystem/toast/index.ts"], - "@kirbydesign/designsystem/grid": ["libs/designsystem/grid/index.ts"], + "@kirbydesign/designsystem/grid": ["libs/designsystem/grid/index.ts"], + "@kirbydesign/designsystem/alert-experimental": [ + "libs/designsystem/alert-experimental/index.ts" + ] }, "target": "es2020" }, diff --git a/libs/designsystem/alert-experimental/index.ts b/libs/designsystem/alert-experimental/index.ts new file mode 100644 index 0000000000..cba1843545 --- /dev/null +++ b/libs/designsystem/alert-experimental/index.ts @@ -0,0 +1 @@ +export * from './src/index'; diff --git a/libs/designsystem/alert-experimental/ng-package.json b/libs/designsystem/alert-experimental/ng-package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/libs/designsystem/alert-experimental/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/libs/designsystem/alert-experimental/src/alert.component.html b/libs/designsystem/alert-experimental/src/alert.component.html new file mode 100644 index 0000000000..5ede8578ff --- /dev/null +++ b/libs/designsystem/alert-experimental/src/alert.component.html @@ -0,0 +1,29 @@ +
+ +
+ + +
+
diff --git a/libs/designsystem/alert-experimental/src/alert.component.scss b/libs/designsystem/alert-experimental/src/alert.component.scss new file mode 100644 index 0000000000..fae2f3123e --- /dev/null +++ b/libs/designsystem/alert-experimental/src/alert.component.scss @@ -0,0 +1,21 @@ +@use '@kirbydesign/core/src/scss/utils'; + +article { + overflow: hidden; + padding: utils.size('s'); + padding-top: utils.size('m'); + @include utils.media(' { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: AlertExperimentalComponent, + }); + + beforeEach(() => { + spectator = createHost(` + + + `); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + describe('ok button', () => { + let okButton: HTMLElement; + + beforeEach(() => { + okButton = spectator.query('.ok-btn'); + }); + it('should render', () => { + const expected = 'Test OK Button Text'; + + expect(spectator.component.okButton).toEqual(expected); + expect(okButton).toHaveText(expected); + }); + + it('should support isDestructive', () => { + spectator.setInput({ okButtonIsDestructive: true }); + + expect(okButton).toBeDefined(); + expect(okButton).toHaveClass('destructive'); + }); + + it('should default to not being destructive', () => { + expect(okButton).toBeDefined(); + expect(okButton).not.toHaveClass('destructive'); + }); + + it('should have large ok button when no cancel button', () => { + spectator.setInput({ cancelButton: null }); + + expect(okButton).toHaveClass('lg'); + }); + + it('should have success colors on button', () => { + expect(okButton).toHaveComputedStyle({ + 'background-color': getColor('success'), + color: getColor('success', 'contrast'), + }); + }); + + it('should have default size when cancel button', () => { + expect(okButton.attributes['ng-reflect-size']).toBeUndefined(); + }); + }); + + describe('cancel button', () => { + let cancelButton: HTMLElement; + + beforeEach(() => { + cancelButton = spectator.query('.cancel-btn'); + }); + it('should render', () => { + const expected = 'Test Cancel Button Text'; + + expect(spectator.component.cancelButton).toEqual(expected); + expect(cancelButton).toHaveText(expected); + }); + + it('should not render when cancelBtn not set', () => { + spectator.setInput({ cancelButton: null }); + + expect(spectator.query('.cancel-btn')).toBeNull(); + }); + }); + + describe('icon', () => { + it('should render', () => { + spectator.setInput({ iconName: 'warning' }); + const icon: HTMLElement = spectator.query('.icon-outline'); + + expect(icon).not.toBeNull(); + }); + }); +}); diff --git a/libs/designsystem/alert-experimental/src/alert.component.ts b/libs/designsystem/alert-experimental/src/alert.component.ts new file mode 100644 index 0000000000..2f43b03d87 --- /dev/null +++ b/libs/designsystem/alert-experimental/src/alert.component.ts @@ -0,0 +1,80 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + ViewChild, +} from '@angular/core'; +import { WindowRef } from '@kirbydesign/designsystem/types'; +import { Observable, of } from 'rxjs'; +import { IconModule } from '@kirbydesign/designsystem/icon'; +import { ThemeColorDirective } from '@kirbydesign/designsystem/shared'; +import { EmptyStateModule } from '@kirbydesign/designsystem/empty-state'; +import { ButtonComponent } from '@kirbydesign/designsystem/button'; + +@Component({ + standalone: true, + imports: [ + IconModule, + EmptyStateModule, + ButtonComponent, + CommonModule, + EmptyStateModule, + ThemeColorDirective, + ], + selector: 'kirby-alert-experimental', + templateUrl: './alert.component.html', + styleUrls: ['./alert.component.scss'], + // eslint-disable-next-line @angular-eslint/no-host-metadata-property + host: { '[class.ion-page]': 'false' }, //Ensure ion-page class doesn't get applied by Ionic Modal Controller + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlertExperimentalComponent implements AfterViewInit { + readonly BLUR_WRAPPER_DELAY_IN_MS = 50; + @ViewChild('alertWrapper', { static: true }) private alertWrapper: ElementRef; + private scrollY: number = Math.abs(this.windowRef.nativeWindow.scrollY); + + title$: Observable; + @Input() + set title(title: string | Observable) { + this.title$ = typeof title === 'string' ? of(title) : title; + } + + message$: Observable; + @Input() + set message(message: string & Observable) { + this.message$ = typeof message === 'string' ? of(message) : message; + } + + @Input() iconName: string; + @Input() iconThemeColor: string; + @Input() okButton: string; + @Input() okButtonIsDestructive: boolean; + @Input() cancelButton: string; + + constructor(private elementRef: ElementRef, private windowRef: WindowRef) {} + + ngAfterViewInit(): void { + setTimeout(() => { + this.alertWrapper.nativeElement.focus(); + this.alertWrapper.nativeElement.blur(); + }, this.BLUR_WRAPPER_DELAY_IN_MS); + } + + onFocusChange() { + // This fixes an undesired scroll behaviour occurring on keyboard-tabbing + this.windowRef.nativeWindow.scrollTo({ top: this.scrollY }); + } + + cancelAlert() { + const ionModalElement = this.elementRef.nativeElement.closest('ion-modal'); + ionModalElement && ionModalElement.dismiss(false); + } + + approveAlert() { + const ionModalElement = this.elementRef.nativeElement.closest('ion-modal'); + ionModalElement && ionModalElement.dismiss(true); + } +} diff --git a/libs/designsystem/alert-experimental/src/alert.module.ts b/libs/designsystem/alert-experimental/src/alert.module.ts new file mode 100644 index 0000000000..ed436548c8 --- /dev/null +++ b/libs/designsystem/alert-experimental/src/alert.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AlertExperimentalComponent } from './alert.component'; +import { AlertExperimentalController } from './services/alert.controller'; + +@NgModule({ + imports: [CommonModule, AlertExperimentalComponent], + providers: [AlertExperimentalController], +}) +export class AlertExperimentalModule {} diff --git a/libs/designsystem/alert-experimental/src/config/alert-config.ts b/libs/designsystem/alert-experimental/src/config/alert-config.ts new file mode 100644 index 0000000000..4490e7f342 --- /dev/null +++ b/libs/designsystem/alert-experimental/src/config/alert-config.ts @@ -0,0 +1,19 @@ +import { Observable } from 'rxjs'; + +export interface AlertExperimentalConfig { + title: string | Observable; + message?: string | Observable; + cancelButton?: string; + + icon?: { + name: string; + themeColor?: string; + }; + + okButton?: + | string + | { + text: string; + isDestructive: boolean; + }; +} diff --git a/libs/designsystem/alert-experimental/src/index.ts b/libs/designsystem/alert-experimental/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/designsystem/alert-experimental/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/designsystem/alert-experimental/src/public_api.ts b/libs/designsystem/alert-experimental/src/public_api.ts new file mode 100644 index 0000000000..91ab533ac7 --- /dev/null +++ b/libs/designsystem/alert-experimental/src/public_api.ts @@ -0,0 +1,4 @@ +export { AlertExperimentalController } from './services/alert.controller'; +export { AlertExperimentalComponent } from './alert.component'; +export { AlertExperimentalConfig } from './config/alert-config'; +export * from './alert.module'; diff --git a/libs/designsystem/alert-experimental/src/services/alert.controller.spec.ts b/libs/designsystem/alert-experimental/src/services/alert.controller.spec.ts new file mode 100644 index 0000000000..6399b118fc --- /dev/null +++ b/libs/designsystem/alert-experimental/src/services/alert.controller.spec.ts @@ -0,0 +1,72 @@ +import { Component } from '@angular/core'; +import { ModalController as IonicModalController } from '@ionic/angular'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; + +import { DesignTokenHelper } from '@kirbydesign/designsystem/helpers'; + +import { WindowRef } from '@kirbydesign/designsystem/types'; +import { TestHelper } from '@kirbydesign/designsystem/testing'; + +import { AlertExperimentalController } from './alert.controller'; + +@Component({ + template: ` +

Dummy Component

+ `, +}) +class EmbeddedDummyComponent {} + +describe('AlertExperimentalController', () => { + let spectator: SpectatorService; + let alertController: AlertExperimentalController; + const backdropOpacity = '0.4'; + + const createService = createServiceFactory({ + service: AlertExperimentalController, + imports: [TestHelper.ionicModuleForTest], + providers: [ + { + provide: WindowRef, + useValue: { nativeWindow: window }, + }, + ], + }); + + beforeEach(() => { + spectator = createService(); + alertController = spectator.service; + }); + + describe('showAlert', () => { + let ionModal: HTMLIonModalElement; + let backdrop: HTMLIonBackdropElement; + let ionModalController: IonicModalController; + + beforeEach(async () => { + ionModalController = spectator.inject(IonicModalController); + + await alertController.showAlert({ title: 'Alert' }); + await TestHelper.waitForTimeout(50); + ionModal = await ionModalController.getTop(); + backdrop = ionModal.shadowRoot.querySelector('ion-backdrop'); + + expect(ionModal).toBeTruthy(); + expect(backdrop).toBeTruthy(); + }); + + afterEach(async () => { + await ionModal.dismiss(); + }); + + it('alert should have correct backdrop style', async () => { + expect(ionModal).toHaveComputedStyle({ '--backdrop-opacity': backdropOpacity }); + }); + + it('modal wrapper should have correct max width', () => { + const modalWrapper = ionModal.shadowRoot.querySelector('.modal-wrapper'); + expect(modalWrapper).toHaveComputedStyle({ + 'max-width': DesignTokenHelper.compactModalMaxWidth(), + }); + }); + }); +}); diff --git a/libs/designsystem/alert-experimental/src/services/alert.controller.ts b/libs/designsystem/alert-experimental/src/services/alert.controller.ts new file mode 100644 index 0000000000..3d1d4b1ca9 --- /dev/null +++ b/libs/designsystem/alert-experimental/src/services/alert.controller.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; +import { ModalController } from '@ionic/angular'; +import { from, Observable, Subject, switchMap, tap } from 'rxjs'; +import { OverlayEventDetail } from '@ionic/core/components'; +import { AlertExperimentalComponent } from '../alert.component'; +import { AlertExperimentalConfig } from '../config/alert-config'; + +type AlertDismissObservables = { + onWillDismiss: Observable; + onDidDismiss: Observable; +}; + +@Injectable() +export class AlertExperimentalController { + constructor(private ionicModalController: ModalController) {} + + public showAlert(config: AlertExperimentalConfig): AlertDismissObservables { + const $onWillDismiss = new Subject(); + const onWillDismiss$ = $onWillDismiss.asObservable(); + + const $onDidDismiss = new Subject(); + const onDidDismiss$ = $onDidDismiss.asObservable(); + + const modal$ = from( + this.ionicModalController.create({ + component: AlertExperimentalComponent, + componentProps: this.getComponentProps(config), + cssClass: ['kirby-overlay', 'kirby-alert'], + mode: 'ios', + backdropDismiss: false, + }) + ); + + modal$ + .pipe( + tap((modal) => from(modal.present())), + switchMap((modal) => modal.onWillDismiss()) + ) + .subscribe((res) => { + $onWillDismiss.next(res); + $onWillDismiss.complete(); + + $onDidDismiss.next(res); + $onDidDismiss.complete(); + }); + + return { + onWillDismiss: onWillDismiss$, + onDidDismiss: onDidDismiss$, + }; + } + + private getComponentProps(config: AlertExperimentalConfig) { + return { + ...config, + okButton: this.getOkButton(config), + cancelButton: config.cancelButton, + okButtonIsDestructive: this.getOkButtonIsDestructive(config), + iconName: config.icon && config.icon.name, + iconThemeColor: config.icon && config.icon.themeColor, + }; + } + + private getOkButton(config: AlertExperimentalConfig) { + let text: string; + + if (config.okButton) { + if (typeof config.okButton === 'string') { + text = config.okButton; + } else { + text = config.okButton.text; + } + } + return text; + } + + getOkButtonIsDestructive(config) { + return typeof config.okButton === 'object' ? config.okButton.isDestructive : undefined; + } +} diff --git a/libs/designsystem/button/src/button.component.scss b/libs/designsystem/button/src/button.component.scss index a585738ed1..b172a66979 100644 --- a/libs/designsystem/button/src/button.component.scss +++ b/libs/designsystem/button/src/button.component.scss @@ -242,7 +242,8 @@ $button-width: ( margin-inline-start: utils.size('s'); } -:host-context(kirby-alert).ok-btn { +:host-context(kirby-alert).ok-btn, +:host-context(kirby-alert-experimental).ok-btn { --kirby-button-background-color: #{utils.get-color('success')}; --kirby-button-color: #{utils.get-color('success-contrast')}; } diff --git a/libs/designsystem/src/lib/index.ts b/libs/designsystem/src/lib/index.ts index 1ffb11f176..455b2222c0 100644 --- a/libs/designsystem/src/lib/index.ts +++ b/libs/designsystem/src/lib/index.ts @@ -44,6 +44,7 @@ export * from '@kirbydesign/designsystem/toggle'; export * from '@kirbydesign/designsystem/toggle-button'; export * from '@kirbydesign/designsystem/types'; export * from '@kirbydesign/designsystem/chart'; +export * from '@kirbydesign/designsystem/alert-experimental'; export { KirbyModule } from './kirby.module'; export { KirbyExperimentalModule } from './kirby-experimental.module'; diff --git a/libs/designsystem/tsconfig.json b/libs/designsystem/tsconfig.json index 7da4094683..e28ed96dd3 100644 --- a/libs/designsystem/tsconfig.json +++ b/libs/designsystem/tsconfig.json @@ -14,7 +14,7 @@ "@kirbydesign/designsystem/types": ["types/index.ts"], "@kirbydesign/designsystem/flag": ["flag/index.ts"], "@kirbydesign/designsystem/form-field": ["form-field/index.ts"], - "@kirbydesign/designsystem/grid": ["grid/index.ts"], + "@kirbydesign/designsystem/grid": ["grid/index.ts"], "@kirbydesign/designsystem/kirby-ionic-module": ["kirby-ionic-module/index.ts"], "@kirbydesign/designsystem/spinner": ["spinner/index.ts"], "@kirbydesign/designsystem/item": ["item/index.ts"], @@ -47,7 +47,8 @@ "@kirbydesign/designsystem/fab-sheet": ["fab-sheet/index.ts"], "@kirbydesign/designsystem/data-table": ["data-table/index.ts"], "@kirbydesign/designsystem/reorder-list": ["reorder-list/index.ts"], - "@kirbydesign/designsystem/toast": ["toast/index.ts"] + "@kirbydesign/designsystem/toast": ["toast/index.ts"], + "@kirbydesign/designsystem/alert-experimental": ["alert-experimental/index.ts"] }, "esModuleInterop": true, "target": "es2020" diff --git a/libs/designsystem/tsconfig.spec.json b/libs/designsystem/tsconfig.spec.json index 6d6bb26994..e9b6ae859f 100644 --- a/libs/designsystem/tsconfig.spec.json +++ b/libs/designsystem/tsconfig.spec.json @@ -51,7 +51,8 @@ "@kirbydesign/designsystem/fab-sheet": ["fab-sheet/index.ts"], "@kirbydesign/designsystem/data-table": ["data-table/index.ts"], "@kirbydesign/designsystem/reorder-list": ["reorder-list/index.ts"], - "@kirbydesign/designsystem/toast": ["toast/index.ts"] + "@kirbydesign/designsystem/toast": ["toast/index.ts"], + "@kirbydesign/designsystem/alert-experimental": ["alert-experimental/index.ts"] } }, "files": ["test.ts"],