diff --git a/apps/cookbook/src/app/app.module.ts b/apps/cookbook/src/app/app.module.ts index a2e7089e52..f476cf3cb1 100644 --- a/apps/cookbook/src/app/app.module.ts +++ b/apps/cookbook/src/app/app.module.ts @@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { KirbyExperimentalModule, KirbyModule } from '@kirbydesign/designsystem'; +import { KirbyExperimentalModule, KirbyModalModule, KirbyModule } from '@kirbydesign/designsystem'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @@ -41,6 +41,7 @@ registerLocaleData(localeData); FormsModule, KirbyModule, KirbyExperimentalModule, + KirbyModalModule, ], providers: [ diff --git a/apps/cookbook/src/app/examples/examples.module.ts b/apps/cookbook/src/app/examples/examples.module.ts index 60620e0eab..ef90fe77af 100644 --- a/apps/cookbook/src/app/examples/examples.module.ts +++ b/apps/cookbook/src/app/examples/examples.module.ts @@ -31,6 +31,7 @@ import { LinkExampleModule } from './link-example/link-example.module'; import { ListExamplesModule } from './list-example/list-example.module'; import { ListExperimentalExampleModule } from './list-experimental-example/list-experimental-example.module'; import { ModalExampleModule } from './modal-example/modal-example.module'; +import { ModalExperimentalExampleModule } from './modal-experimental-example/modal-experimental-example.module'; import { ProgressCircleExampleModule } from './progress-circle-example/progress-circle-example.module'; import { RadioExampleModule } from './radio-example/radio-example.module'; import { RangeExampleModule } from './range-example/range-example.module'; @@ -61,6 +62,7 @@ const IMPORTS = [ RangeExampleModule, LinkExampleModule, ModalExampleModule, + ModalExperimentalExampleModule, GridLayoutExamplesModule, SectionHeaderExampleModule, ItemGroupExampleModule, diff --git a/apps/cookbook/src/app/examples/examples.routes.ts b/apps/cookbook/src/app/examples/examples.routes.ts index 837132e482..eee83e5f7b 100644 --- a/apps/cookbook/src/app/examples/examples.routes.ts +++ b/apps/cookbook/src/app/examples/examples.routes.ts @@ -44,6 +44,7 @@ import { ListNoShapeExampleComponent } from './list-no-shape-example/list-no-sha import { ListSwipeExampleComponent } from './list-swipe-example/list-swipe-example.component'; import { LoadingOverlayExampleComponent } from './loading-overlay-example/loading-overlay-example.component'; import { ModalExampleComponent } from './modal-example/modal-example.component'; +import { FullscreenModalExperimentalExampleComponent } from './modal-experimental-example/fullscreen/fullscreen-experimental-example.component'; import { ModalRoutePage1ExampleComponent } from './modal-example/modal-route-example/modal-route-page1-example.component'; import { ModalRoutePage2ExampleComponent } from './modal-example/modal-route-example/modal-route-page2-example.component'; import { PageAdvancedExampleComponent } from './page-example/advanced/page-advanced-example.component'; @@ -247,6 +248,10 @@ export const routes: Routes = [ }, ], }, + { + path: 'modal-experimental-fullscreen', + component: FullscreenModalExperimentalExampleComponent, + }, { path: 'form-field', children: [ diff --git a/apps/cookbook/src/app/examples/modal-example/modal-example.module.ts b/apps/cookbook/src/app/examples/modal-example/modal-example.module.ts index 0f6ecf7583..5b5ea4cb8b 100644 --- a/apps/cookbook/src/app/examples/modal-example/modal-example.module.ts +++ b/apps/cookbook/src/app/examples/modal-example/modal-example.module.ts @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { KirbyModule } from '@kirbydesign/designsystem'; +import { KirbyModalModule, KirbyModule } from '@kirbydesign/designsystem'; import { ExamplesSharedModule } from '../examples.shared.module'; @@ -27,7 +27,7 @@ const COMPONENT_DECLARATIONS = [ ]; @NgModule({ - imports: [CommonModule, RouterModule, KirbyModule, ExamplesSharedModule], + imports: [CommonModule, RouterModule, KirbyModule, KirbyModalModule, ExamplesSharedModule], declarations: COMPONENT_DECLARATIONS, exports: COMPONENT_DECLARATIONS, }) diff --git a/apps/cookbook/src/app/examples/modal-experimental-example/controller/modal-controller-experimental-example.component.html b/apps/cookbook/src/app/examples/modal-experimental-example/controller/modal-controller-experimental-example.component.html new file mode 100644 index 0000000000..b0c3eba597 --- /dev/null +++ b/apps/cookbook/src/app/examples/modal-experimental-example/controller/modal-controller-experimental-example.component.html @@ -0,0 +1,45 @@ + +

+ Edit the title below and see the output in the console, when clicking "cancel" or "submit". The + output comes from subscribing to onWillDismiss and + onDidDismiss events. +

+ + +
+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse non justo mollis, gravida + nisl sit amet, blandit nulla. Proin eget posuere nibh. Pellentesque hendrerit sapien id + venenatis porttitor. Donec laoreet laoreet dui, nec luctus elit accumsan eu. Nullam placerat sem + libero, quis laoreet magna egestas lobortis. Vivamus condimentum suscipit nisl a elementum. + Aliquam in mi mauris. Etiam finibus, felis non venenatis volutpat, metus leo vulputate enim, id + porttitor mi arcu vel urna. Quisque a sollicitudin turpis, non egestas urna. Aliquam ac leo a + quam rhoncus efficitur. Aliquam maximus varius tellus malesuada vehicula. Cras bibendum elit + tempor tellus pellentesque finibus. Maecenas iaculis enim vitae ex varius, id commodo lectus + porta. Donec quis dui aliquet, elementum magna sed, mollis mi. Ut sed nisi ut mauris maximus + gravida at eu purus. Mauris a diam nulla. Morbi sit amet lobortis risus. Ut maximus rutrum + turpis tempor finibus. Nam et pretium arcu, ac placerat arcu. Fusce eu leo vel enim bibendum + porttitor. Suspendisse potenti. Etiam maximus erat id magna vulputate, id eleifend neque + aliquam. Quisque ut nunc suscipit, semper lectus at, suscipit urna. Nulla mollis tellus nunc, + finibus iaculis lectus fringilla et. Vestibulum ante ipsum primis in faucibus orci luctus et + ultrices posuere cubilia curae; Suspendisse dictum accumsan ipsum, dictum consectetur sapien + dapibus eget. Aenean mattis turpis eu dolor tincidunt, sit amet mollis arcu fermentum. Maecenas + sagittis et mauris sit amet condimentum. Maecenas pulvinar non turpis sed pellentesque. + Vestibulum ac ornare ipsum. Pellentesque ac ante nec ex ornare pharetra. Nulla non neque eget + est convallis vulputate. Quisque imperdiet suscipit ligula, sit amet egestas diam semper + tristique. Etiam ut quam enim. Quisque nec faucibus tortor. Morbi consectetur ultricies lorem, + tempus posuere est tempor vitae. Aenean eget magna quam. Praesent volutpat arcu a efficitur + faucibus. Nulla non mauris sed mauris gravida rutrum. Ut eu ullamcorper lacus, ac varius tellus. + Proin convallis interdum nulla vitae pharetra. Fusce enim ex, cursus eget est sed, fringilla + fringilla diam. Maecenas aliquam aliquam libero, ut auctor eros rhoncus quis. Sed auctor vitae + libero id posuere. Integer mollis mollis arcu ac vestibulum. Suspendisse at viverra leo. Nulla + ac semper augue. Aenean consequat purus eget convallis cursus. Aliquam facilisis nisl massa, vel + imperdiet ante dictum eleifend. Morbi ut nibh in elit venenatis semper. +

+ + + + + +
diff --git a/apps/cookbook/src/app/examples/modal-experimental-example/controller/modal-controller-experimental-example.component.ts b/apps/cookbook/src/app/examples/modal-experimental-example/controller/modal-controller-experimental-example.component.ts new file mode 100644 index 0000000000..147a175f20 --- /dev/null +++ b/apps/cookbook/src/app/examples/modal-experimental-example/controller/modal-controller-experimental-example.component.ts @@ -0,0 +1,67 @@ +import { Component, Input } from '@angular/core'; +import { ModalExperimentalController } from '@kirbydesign/designsystem/components/modal-experimental/services/modal.controller'; + +export const showModalCodeSnippet = `constructor(private modalController: ModalExperimentalController) {} + +showModal() { + const config: ModalExperimentalConfig = { + flavor: 'modal', + component: YourEmbeddedModalComponent, + }; + this.modalController.showModal(config); +}`; + +export const observableCodeSnippet = `constructor(private modalController: ModalExperimentalController) {} + +showModal() { + const config: ModalExperimentalConfig = { + flavor: 'modal', + component: YourEmbeddedModalComponent, + }; + + const modal = this.modalController.showModal(config); + + modal?.onWillDismiss.subscribe((response) => { + const { role, data } = response; + + // role is: 'confirm' + // data is: { + // title: 'myTitle', + // items: [{id: 1}, {id: 2}] + } + }); + + modal?.onDidDismiss.subscribe((response) => { + const { role, data } = response; + + // role is: 'confirm' + // data is: { + // title: 'myTitle', + // items: [{id: 1}, {id: 2}] + } + }); + + + // Inside the embedded component + + constructor(private modalController: ModalExperimentalController) {} + + close() { + this.modalController.closeModal({ + title: 'myTitle', + items: [{id: 1}, {id: 2}] + }, 'confirm'); + } +}`; +@Component({ + templateUrl: './modal-controller-experimental-example.component.html', +}) +export class ModalControllerExperimentalExampleComponent { + constructor(private modalController: ModalExperimentalController) {} + + @Input() title = ''; + + close(role: string) { + this.modalController.closeModal({ title: this.title }, role); + } +} diff --git a/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.html b/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.html new file mode 100644 index 0000000000..07b6c7945e --- /dev/null +++ b/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.html @@ -0,0 +1,120 @@ +
+

Fullscreen modal

+ +
+

Config

+ + + + + + + + + + +
+ + + + + + + + 1/4 + + + + +
+ + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris nunc eros, fermentum sed + ligula ac, placerat porta est. Cras luctus mi et aliquam semper. Sed urna leo, gravida + accumsan vulputate in, viverra at neque. Cras in nisi ac orci venenatis semper scelerisque + ullamcorper libero. Donec ornare accumsan massa, at eleifend lorem sollicitudin ut. Donec + vitae malesuada neque. Morbi ultricies urna ac dignissim blandit. Duis sollicitudin tellus + ligula, sit amet congue augue blandit et. Lorem ipsum dolor sit amet, consectetur adipiscing + elit. Aenean cursus laoreet diam sed elementum. In justo est, aliquet sed placerat quis, + tincidunt rhoncus augue. Donec vulputate suscipit nulla, quis consectetur dolor congue + vitae. Fusce felis ex, ornare id finibus vel, tempus vel tellus. Vestibulum cursus nulla et + sem fermentum pulvinar. Curabitur ac odio vulputate, condimentum nulla non, vestibulum sem. + Praesent semper sodales nisi, quis ornare nisi vehicula pellentesque. Duis nulla felis, + sollicitudin vitae eleifend a, mollis eu elit. Nulla aliquam, diam eget cursus pellentesque, + nulla dui elementum risus, in sodales dolor mauris sit amet magna. Praesent nec metus a + massa elementum eleifend nec vulputate est. Nullam blandit lorem et tortor semper, tempus + tempor ipsum varius. Etiam et laoreet massa. Nam lacinia purus a lorem pellentesque varius. + Donec vel dui tincidunt, efficitur dui a, rhoncus nisi. Sed id lectus libero. Duis turpis + lorem, placerat eu tortor vel, tempor luctus enim. Donec pulvinar odio at fringilla + ultricies. Aliquam erat volutpat. Cras eget elit eleifend, lacinia sapien sit amet, mattis + orci. Fusce ac scelerisque est. Nulla orci dui, placerat fringilla felis vitae, cursus + facilisis est. Aenean consectetur, dui ut scelerisque lacinia, ante arcu laoreet ligula, + vitae congue ex nulla consequat purus. Maecenas sit amet velit eget dui sodales iaculis nec + nec ante. Morbi bibendum sapien et pulvinar ornare. Nullam commodo varius mi, ac tincidunt + lectus vulputate nec. Orci varius natoque penatibus et magnis dis parturient montes, + nascetur ridiculus mus. Quisque ac orci libero. Nulla facilisi. Nullam quis lacinia elit. + Morbi tempus, lacus ac vehicula finibus, urna mi viverra nulla, quis malesuada mi magna eu + libero. Aliquam consectetur neque sed nisi vestibulum posuere. Nam viverra nisl varius + placerat sagittis. Pellentesque velit nisl, blandit nec laoreet at, tincidunt quis augue. + Nunc ultrices lacinia est, eu lacinia felis fermentum vel. Sed a quam lobortis, facilisis + nunc vitae, faucibus sapien. Sed et orci vitae leo consequat consectetur non eu mauris. Nunc + non vulputate diam. Sed ultricies arcu et mattis ullamcorper. Curabitur vel massa enim. In + venenatis augue eu lectus scelerisque egestas. Duis dignissim at augue non semper. Integer + lectus mi, pulvinar porta velit ac, interdum consequat felis. Vestibulum vitae ipsum + viverra, egestas neque et, efficitur velit. Aenean ac facilisis velit. Ut imperdiet, lacus + nec tempor ornare, sem odio pulvinar justo, in pellentesque orci lacus non mauris. Sed + aliquam neque vel mauris consequat, elementum lacinia ex convallis. In nec dui vitae quam + sodales venenatis. Aenean lacus purus, iaculis sed sollicitudin vitae, consequat ut magna. + Suspendisse malesuada justo quis ante sollicitudin blandit elementum sit amet dolor. +

+
+ + + + + + +
+
diff --git a/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.scss b/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.scss new file mode 100644 index 0000000000..e513eee79d --- /dev/null +++ b/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.scss @@ -0,0 +1,15 @@ +.fullscreen-example { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; + padding: 20px 0; + + .config { + display: flex; + flex-direction: column; + align-items: flex-start; + } +} diff --git a/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.ts b/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.ts new file mode 100644 index 0000000000..993bc3f95d --- /dev/null +++ b/apps/cookbook/src/app/examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component.ts @@ -0,0 +1,98 @@ +import { Component, ViewChild } from '@angular/core'; +import { FullscreenModalExperimentalComponent } from '@kirbydesign/designsystem/components/modal-experimental/fullscreen/fullscreen.component'; +import { KirbyAnimation } from '@kirbydesign/designsystem'; + +export const fullscreenModalExampleTemplateHTML = ` +
+ + + +

+ Lorem ipsum dolor sit amet... +

+
+
+ + +`; + +export const fullscreenModalExampleTemplateTS = `openModal() { this.open = true; }`; + +export const headerStartSlotExampleTemplate = ` + + 1/4 + + +`; + +export const footerSlotExampleTemplate = ` + + + +`; +@Component({ + templateUrl: './fullscreen-experimental-example.component.html', + styleUrls: ['./fullscreen-experimental-example.component.scss'], +}) +export class FullscreenModalExperimentalExampleComponent { + @ViewChild(FullscreenModalExperimentalComponent) modal: FullscreenModalExperimentalComponent; + + open = false; + canDismiss = true; + showFooter = true; + showPageProgress = true; + isInlineFooter = false; + collapseTitle = false; + scrollDisabled = false; + + openModal() { + this.open = true; + } + + closeModal() { + this.open = false; + } + + scrollToTop() { + this.modal.scrollToTop(KirbyAnimation.Duration.LONG); + } + + scrollToBottom() { + this.modal.scrollToBottom(KirbyAnimation.Duration.LONG); + } + + toggleCanDismiss() { + this.canDismiss = !this.canDismiss; + } + + toggleFooter() { + this.showFooter = !this.showFooter; + } + + togglePageProgress() { + this.showPageProgress = !this.showPageProgress; + } + + toggleIsInlineFooter() { + this.isInlineFooter = !this.isInlineFooter; + } + + toggleCollapseTitle() { + this.collapseTitle = !this.collapseTitle; + } + + toggleScrollDisabled() { + this.scrollDisabled = !this.scrollDisabled; + } +} diff --git a/apps/cookbook/src/app/examples/modal-experimental-example/modal-experimental-example.module.ts b/apps/cookbook/src/app/examples/modal-experimental-example/modal-experimental-example.module.ts new file mode 100644 index 0000000000..34a1d4a03e --- /dev/null +++ b/apps/cookbook/src/app/examples/modal-experimental-example/modal-experimental-example.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; + +import { KirbyModalModule, KirbyModule } from '@kirbydesign/designsystem'; + +import { ExamplesSharedModule } from '../examples.shared.module'; + +import { FullscreenModalExperimentalExampleComponent } from './fullscreen/fullscreen-experimental-example.component'; +import { ModalControllerExperimentalExampleComponent } from './controller/modal-controller-experimental-example.component'; + +const COMPONENT_DECLARATIONS = [ + FullscreenModalExperimentalExampleComponent, + ModalControllerExperimentalExampleComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + KirbyModule, + KirbyModalModule, + ExamplesSharedModule, + FormsModule, + ], + declarations: COMPONENT_DECLARATIONS, + exports: COMPONENT_DECLARATIONS, +}) +export class ModalExperimentalExampleModule {} diff --git a/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.html b/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.html new file mode 100644 index 0000000000..e92439facb --- /dev/null +++ b/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.html @@ -0,0 +1,189 @@ +Experimental component - subject to change + + +

+ A Modal is a dialog that appears on top of the app's content, and must be dismissed by the app + before interaction can resume. It is useful as a select component when there are a lot of options + to choose from, or when filtering items in a list, as well as many other use cases. +

+ +

+ If the modal is used on a page with scroll, then it requires that the container responsible for + the overflow is another element than the body, fx. kirby-page or a + custom container. Otherwise it will jump back to the top of the page, where the modal is rendered, + as is evident in the showcase for the controller-based modal on this page, when pressing its Open modal button. +

+ +

Modals can be used as components or generated through the modal controller.

+ +

Components

+ + + +

ModalController

+ + + +
+ +

Components

+ +
+
+

Fullscreen modal

+ + +
+ + +
+ +

Slots

+

+ The modal component accepts 3 slots: header-start, default & + footer. +

+ +

header-start

+ +

+ The header-start slot places content in the upper left corner of the modal header. In + the example above a kirby-page-progress component is slotted in like this: +

+ + + +

default

+ +

The default slot places the provided markup or component in the modals' content.

+ +

footer

+ +

+ The footer slot places content in the bottom of the modal underneath the scrollable + container, so it will always stick to the bottom. We recommend using the + kirby-modal-footer-experimental component, which is documented right + here. In the example above the + kirby-modal-footer-experimental component is slotted in like this: +

+ + + +
+

Fullscreen modal properties:

+ + +

Events:

+ + +

Methods:

+ +
+ +

Footer component

+ +

+ The kirby-modal-footer-experimental component only accepts a default slot as shown + below: +

+ + + +

Modal footer properties:

+ + + +

Controller

+ +

+ Modals can also be opened programmatically by using the ModalExperimentalController. + To show the modal, create a ModalExperimentalConfig and pass it to the + modalController.showModal method: +

+ +

+ We recommend wrapping your component in the + kirby-modal-wrapper-experimental component to get the default Kirby styling. It + accepts the same two slots as described above - header-start & footer. +

+ + + + + + +
+ +

+ Subscribe to onWillDismiss or onDidDismiss +

+ +

+ The showModal method returns observables for the onWillDismiss and + onDidDismiss events, so it's possible to subscribe to either one or both at the same + time. The subscriptions both return an object that contains data and + role as described in the closeModal method described + here. +

+ + + +

Controller methods:

+ + + +

Modal config:

+ + +

Modal wrapper properties:

+ + diff --git a/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.scss b/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.scss new file mode 100644 index 0000000000..be377096b1 --- /dev/null +++ b/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.scss @@ -0,0 +1,12 @@ +@use '@kirbydesign/core/src/scss/utils'; + +.page-example { + display: flex; + justify-content: space-between; + margin-top: utils.size('xl'); + margin-bottom: utils.size('xl'); +} + +.subscriptions { + margin-top: utils.size('s'); +} diff --git a/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.ts b/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.ts new file mode 100644 index 0000000000..c05e2276b8 --- /dev/null +++ b/apps/cookbook/src/app/showcase/modal-experimental-showcase/modal-experimental-showcase.component.ts @@ -0,0 +1,272 @@ +import { Component } from '@angular/core'; +import { + ModalExperimentalConfig, + ModalExperimentalController, +} from '@kirbydesign/designsystem/components/modal-experimental/services/modal.controller'; +import { ActionSheetController } from '@ionic/angular'; +import { + ModalControllerExperimentalExampleComponent, + observableCodeSnippet, + showModalCodeSnippet, +} from '../../examples/modal-experimental-example/controller/modal-controller-experimental-example.component'; +import { + footerSlotExampleTemplate, + fullscreenModalExampleTemplateHTML, + fullscreenModalExampleTemplateTS, + headerStartSlotExampleTemplate, +} from '../../examples/modal-experimental-example/fullscreen/fullscreen-experimental-example.component'; +import { ApiDescriptionEvent } from '../../shared/api-description/api-description-events/api-description-events.component'; +import { ApiDescriptionProperty } from '../../shared/api-description/api-description-properties/api-description-properties.component'; +import { ApiDescriptionMethod } from '../../shared/api-description/api-description-methods/api-description-methods.component'; + +@Component({ + selector: 'cookbook-modal-experimental-showcase', + templateUrl: './modal-experimental-showcase.component.html', + styleUrls: ['./modal-experimental-showcase.component.scss'], +}) +export class ModalExperimentalShowcaseComponent { + constructor( + private modalController: ModalExperimentalController, + private actionSheetCtrl: ActionSheetController + ) {} + + fullscreenModalExampleTemplateHTML: string = fullscreenModalExampleTemplateHTML; + fullscreenModalExampleTemplateTS: string = fullscreenModalExampleTemplateTS; + headerStartSlotExampleTemplate: string = headerStartSlotExampleTemplate; + footerSlotExampleTemplate: string = footerSlotExampleTemplate; + showModalCodeSnippet: string = showModalCodeSnippet; + observableCodeSnippet: string = observableCodeSnippet; + + componentProperties: ApiDescriptionProperty[] = [ + { + name: 'open', + description: `Determines if the modal should be shown or not.`, + defaultValue: 'false', + type: ['boolean'], + }, + { + name: 'canDismiss', + description: `(Optional) Determines whether or not a modal can dismiss when calling the dismiss method. + + If the value is true or the value's function returns true, the modal will close when trying to dismiss. If the value is false or the value's function returns false, the modal will not close when trying to dismiss. + + This can be used to show an alert or action sheet, that prompts the user and asks if they are sure that they want to close the modal. + `, + defaultValue: 'true', + type: ['(() => Promise)', 'boolean'], + }, + { + name: 'title', + description: `The title of the modal.`, + defaultValue: '', + type: ['string'], + }, + { + name: 'collapseTitle', + description: `(Optional) If \`true\` will cause the title to initially be rendered as part of the content; Once scrolled out of view it collapses and appears in the header area. + \n Useful for long titles that would otherwise truncate.`, + defaultValue: 'false', + type: ['boolean'], + }, + { + name: 'scrollDisabled', + description: 'Disable scrolling in the modal.', + type: ['boolean'], + defaultValue: 'false', + }, + ]; + + events: ApiDescriptionEvent[] = [ + { + name: 'willPresent', + description: 'Emitted before the modal has presented', + signature: 'Promise', + }, + { + name: 'didPresent', + description: 'Emitted after the modal has presented.', + signature: 'Promise', + }, + { + name: 'willDismiss', + description: 'Emitted before the modal is dismissed.', + signature: 'Promise', + }, + { + name: 'didDismiss', + description: 'Emitted after the modal is dismissed.', + signature: 'Promise', + }, + ]; + + methods: ApiDescriptionMethod[] = [ + { + name: 'scrollToTop', + description: + 'Scrolls to the top of the modal body. It takes an optional input of "KirbyAnimation.Duration", fx. KirbyAnimation.Duration.SHORT. This method will have no effect if "scrollDisabled" is set to "true"', + signature: 'void', + }, + { + name: 'scrollToBottom', + description: + 'Scrolls to the bottom of the modal body. It takes an optional input of "KirbyAnimation.Duration", fx. KirbyAnimation.Duration.SHORT. This method will have no effect if "scrollDisabled" is set to "true"', + signature: 'void', + }, + ]; + + footerProperties: ApiDescriptionProperty[] = [ + { + name: 'type', + description: + 'Sets the type of the footer. When inline the footer will have a transparent background and no shadow.', + type: ['fixed', 'inline'], + defaultValue: 'fixed', + }, + ]; + + controllerProperties: ApiDescriptionProperty[] = [ + { + name: 'flavor', + description: `(Optional) The flavor of the modal. + + Modals with \`modal\` flavor have a close button placed in the top right corner and are full-screen on small screens.`, + defaultValue: 'modal', + type: ['modal'], + }, + { + name: 'component', + description: 'The component which will be rendered inside the modal.', + defaultValue: '', + type: ['Component'], + }, + { + name: 'componentProps', + description: '(Optional) The data to pass to the modal component.', + defaultValue: 'undefined', + type: ['undefined | { [key: string]: any; }'], + }, + { + name: 'cssClass', + description: `(Optional) Adds custom css classes to the modal. This allows for custom styling of the modal (see 'CSS Custom Properties' section).`, + defaultValue: 'undefined', + type: ['string | string[]'], + }, + { + name: 'canDismiss', + description: `(Optional) If canDismiss is true, then the modal will close when users attempt to dismiss the modal. If canDismiss is false, then the modal will not close when users attempt to dismiss the modal. + + canDismiss can also be a function, which must return a Promise that resolves to either true or false. If the promise resolves to true, then the modal will dismiss. If the promise resolves to false, then the modal will not dismiss. + `, + defaultValue: 'undefined', + type: ['boolean | (() => Promise)'], + }, + { + name: 'backdropDismiss', + description: `(Optional) If true, the modal will be dismissed when the backdrop is clicked. It is still possible to use this property even if 'showBackdrop' is set to false, because the backdrop is still rendered, but have a transparent background.`, + defaultValue: 'true', + type: ['boolean'], + }, + { + name: 'showBackdrop', + description: `(Optional) If true, a backdrop will be displayed behind the modal. This property controls whether or not the backdrop darkens the screen when the modal is presented. It does not control whether or not the backdrop is active or present in the DOM.`, + defaultValue: 'true', + type: ['boolean'], + }, + ]; + + wrapperComponentProperties: ApiDescriptionProperty[] = [ + { + name: 'title', + description: `The title of the modal`, + defaultValue: '', + type: ['string'], + }, + { + name: 'hasCollapsibleTitle', + description: `(Optional) If \`true\` will cause the title to initially be rendered as part of the content; Once scrolled out of view it collapses and appears in the header area. + \n Useful for long titles that would otherwise truncate. `, + defaultValue: 'true', + type: ['boolean'], + }, + { + name: 'scrollDisabled', + description: 'Disable scrolling in the modal', + type: ['boolean'], + defaultValue: 'false', + }, + ]; + + controllerMethods: ApiDescriptionMethod[] = [ + { + name: 'showModal', + description: + 'Generates and presents a modal. It takes a required argument of "ModalExperimentalConfig".', + signature: `{ + onWillDismiss: Observable; + onDidDismiss: Observable; + }`, + }, + { + name: 'closeModal', + description: `Closes the top-most modal. It takes two optional arguments: data & role. + + Data is the data you want to get out of the modal by subscribing to either "onWillDismiss" or "onDidDismiss" as described below. + + Role is a string that is being returned as the second key in the response object from "onWillDismiss" and "onDidDismiss". This can be used to check if role === 'cancel' or 'confirm' (or other) and execute logic based on the result. + `, + signature: 'void', + }, + ]; + + scrollTo(target: Element) { + target.scrollIntoView({ behavior: 'smooth' }); + return false; + } + + canDismiss = async () => { + const actionSheet = await this.actionSheetCtrl.create({ + header: 'Are you sure?', + buttons: [ + { + text: 'Yes', + role: 'confirm', + }, + { + text: 'No', + role: 'cancel', + }, + ], + }); + + actionSheet.present(); + + const { role } = await actionSheet.onWillDismiss(); + + return role === 'confirm'; + }; + + openModal(enableCanDismiss?: boolean) { + const config: ModalExperimentalConfig = { + flavor: 'modal', + component: ModalControllerExperimentalExampleComponent, + componentProps: { + title: 'Modal title', + }, + canDismiss: enableCanDismiss ? this.canDismiss : undefined, + }; + + const modal = this.modalController.showModal(config); + + modal?.onWillDismiss.subscribe((response) => { + const { role, data } = response; + console.log('This is the role from the onWillDismiss subscription:', role); + console.log('This is the data from the onWillDismiss subscription:', data); + }); + + modal?.onDidDismiss.subscribe((response) => { + const { role, data } = response; + console.log('This is the role from the onDidDismiss subscription:', role); + console.log('This is the data from the onDidDismiss subscription:', data); + }); + } +} diff --git a/apps/cookbook/src/app/showcase/showcase.common.ts b/apps/cookbook/src/app/showcase/showcase.common.ts index 78a342ac5e..2ac1b10d1d 100644 --- a/apps/cookbook/src/app/showcase/showcase.common.ts +++ b/apps/cookbook/src/app/showcase/showcase.common.ts @@ -32,6 +32,7 @@ import { ListNoShapeShowcaseComponent } from './list-no-shape-showcase/list-no-s import { ListShowcaseComponent } from './list-showcase/list-showcase.component'; import { ListSwipeShowcaseComponent } from './list-swipe-showcase/list-swipe-showcase.component'; import { LoadingOverlayShowcaseComponent } from './loading-overlay-showcase/loading-overlay-showcase.component'; +import { ModalExperimentalShowcaseComponent } from './modal-experimental-showcase/modal-experimental-showcase.component'; import { ModalShowcaseComponent } from './modal-showcase/modal-showcase.component'; import { PageShowcaseComponent } from './page-showcase/page-showcase.component'; import { ProgressCircleShowcaseComponent } from './progress-circle-showcase/progress-circle-showcase.component'; @@ -73,6 +74,7 @@ export const COMPONENT_EXPORTS: any[] = [ StockChartShowcaseComponent, FontsShowcaseComponent, SpinnerShowcaseComponent, + ModalExperimentalShowcaseComponent, ModalShowcaseComponent, SegmentedControlShowcaseComponent, BadgeShowcaseComponent, diff --git a/apps/cookbook/src/app/showcase/showcase.module.ts b/apps/cookbook/src/app/showcase/showcase.module.ts index 7b4c678247..6ce7865a38 100644 --- a/apps/cookbook/src/app/showcase/showcase.module.ts +++ b/apps/cookbook/src/app/showcase/showcase.module.ts @@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { IonicModule } from '@ionic/angular'; -import { KirbyModule } from '@kirbydesign/designsystem'; +import { KirbyModalModule, KirbyModule } from '@kirbydesign/designsystem'; import { IphoneModule } from '../iphone/iphone.module'; import { ApiDescriptionEventsComponent } from '../shared/api-description/api-description-events/api-description-events.component'; @@ -23,6 +23,7 @@ import { COMPONENT_DECLARATIONS, COMPONENT_EXPORTS, COMPONENT_IMPORTS } from './ FormsModule, IonicModule, KirbyModule.forChild({ moduleRootRoutePath: '/home/showcase' }), + KirbyModalModule, IphoneModule, RouterModule, CodeViewerModule, diff --git a/apps/cookbook/src/app/showcase/showcase.routes.ts b/apps/cookbook/src/app/showcase/showcase.routes.ts index 5d9007f2f6..5b00f10b5c 100644 --- a/apps/cookbook/src/app/showcase/showcase.routes.ts +++ b/apps/cookbook/src/app/showcase/showcase.routes.ts @@ -34,6 +34,7 @@ import { ListNoShapeShowcaseComponent } from './list-no-shape-showcase/list-no-s import { ListShowcaseComponent } from './list-showcase/list-showcase.component'; import { ListSwipeShowcaseComponent } from './list-swipe-showcase/list-swipe-showcase.component'; import { LoadingOverlayShowcaseComponent } from './loading-overlay-showcase/loading-overlay-showcase.component'; +import { ModalExperimentalShowcaseComponent } from './modal-experimental-showcase/modal-experimental-showcase.component'; import { ModalShowcaseComponent } from './modal-showcase/modal-showcase.component'; import { PageShowcaseComponent } from './page-showcase/page-showcase.component'; import { ProgressCircleShowcaseComponent } from './progress-circle-showcase/progress-circle-showcase.component'; @@ -186,6 +187,10 @@ export const routes: Routes = [ }, ], }, + { + path: 'modal-experimental', + component: ModalExperimentalShowcaseComponent, + }, { path: 'loading-overlay', component: LoadingOverlayShowcaseComponent, diff --git a/libs/core/src/scss/_global-styles.scss b/libs/core/src/scss/_global-styles.scss index 173f6cbded..dda762e394 100644 --- a/libs/core/src/scss/_global-styles.scss +++ b/libs/core/src/scss/_global-styles.scss @@ -164,6 +164,10 @@ ion-modal.kirby-overlay { } } +ion-modal { + --background: var(--kirby-modal-background, #{utils.get-color('background-color')}); +} + ion-loading.kirby-loading-overlay { --backdrop-opacity: #{utils.$loading-overlay-backdrop-opacity}; --ion-backdrop-color: #{utils.get-color('background-color')}; diff --git a/libs/designsystem/src/lib/components/index.ts b/libs/designsystem/src/lib/components/index.ts index cbdc51fb7b..6ed8d5c859 100644 --- a/libs/designsystem/src/lib/components/index.ts +++ b/libs/designsystem/src/lib/components/index.ts @@ -21,6 +21,7 @@ export { GridCardConfiguration } from './grid/grid-card-configuration'; export { ItemGroupComponent } from './item-group/item-group.component'; export * from './modal'; +export * from './modal-experimental'; export * from './page'; diff --git a/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.html b/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.html new file mode 100644 index 0000000000..f514c9f971 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.html @@ -0,0 +1,3 @@ + + + diff --git a/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.scss b/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.scss new file mode 100644 index 0000000000..bc7f250e3b --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.scss @@ -0,0 +1,61 @@ +@use '@kirbydesign/core/src/scss/utils'; + +$padding-horizontal: utils.size('s'); +$padding-vertical: utils.size('xxs'); + +ion-footer { + box-shadow: utils.get-elevation(8); + display: flex; + justify-content: var(--kirby-modal-footer-justify-content, center); + align-items: center; + background-color: var(--kirby-modal-footer-background, utils.get-color('white')); + color: var(--kirby-modal-footer-color, utils.get-color('white-contrast')); + padding: $padding-vertical $padding-horizontal; + padding-bottom: calc(#{$padding-vertical} + var(--kirby-modal-footer-safe-area-bottom, 0px)); +} + +@include utils.media('', +}) +class TestHostComponent { + snapToKeyboard = false; +} + +describe('ModalFooterComponent', () => { + let spectator: SpectatorHost; + let modalFooterElement: HTMLElement; + let ionFooterElement: HTMLIonFooterElement; + + const createHost = createHostFactory({ + component: ModalFooterExperimentalComponent, + host: TestHostComponent, + declarations: [MockComponents(IonFooter), ThemeColorDirective], + }); + + describe('by default', () => { + beforeEach(() => { + spectator = createHost(``); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should set correct padding on the footer', () => { + ionFooterElement = spectator.query('ion-footer'); + + expect(ionFooterElement).toHaveComputedStyle({ + 'padding-left': BASE_PADDING_HORIZONTAL_PX, + 'padding-right': BASE_PADDING_HORIZONTAL_PX, + 'padding-top': BASE_PADDING_VERTICAL_PX, + 'padding-bottom': BASE_PADDING_VERTICAL_PX, + }); + }); + }); + + describe('Set bottom padding', () => { + beforeEach(() => { + spectator = createHost(``); + modalFooterElement = spectator.element; + ionFooterElement = spectator.query('ion-footer'); + }); + + it('when --kirby-safe-area-bottom is not set', () => { + expect(ionFooterElement).toHaveComputedStyle({ 'padding-bottom': BASE_PADDING_VERTICAL_PX }); + }); + + it('when --kirby-safe-area-bottom is set', () => { + setSafeAreaBottom(); + + expect(ionFooterElement).toHaveComputedStyle({ 'padding-bottom': BASE_PADDING_VERTICAL_PX }); + }); + + describe('on small screens', () => { + beforeAll(async () => { + await TestHelper.resizeTestWindow(TestHelper.screensize.phone); + }); + + afterAll(() => { + TestHelper.resetTestWindow(); + }); + + it('when --kirby-safe-area-bottom is not set', () => { + expect(ionFooterElement).toHaveComputedStyle({ + 'padding-bottom': BASE_PADDING_VERTICAL_PX, + }); + }); + + it('when --kirby-safe-area-bottom is set', () => { + setSafeAreaBottom(); + + const expected = BASE_PADDING_VERTICAL + SAFE_AREA_BOTTOM + 'px'; + + expect(ionFooterElement).toHaveComputedStyle({ 'padding-bottom': expected }); + }); + }); + }); + + describe('Snap to keyboard', () => { + beforeEach(() => { + spectator = createHost( + `` + ); + modalFooterElement = spectator.element; + ionFooterElement = spectator.query('ion-footer'); + }); + + describe('when snapToKeyboard is true', () => { + beforeEach(() => { + spectator.setHostInput('snapToKeyboard', true); + }); + + it('should follow the keyboard up', () => { + keyboardSlideIn(); + + expect(ionFooterElement).toHaveComputedStyle({ + transform: TRANSFORM_PUSHED_BY_KEYBOARD, + }); + }); + + it('should follow the keyboard down', () => { + keyboardSlideOut(); + + expect(ionFooterElement).toHaveComputedStyle({ + transform: 'none', + }); + }); + }); + + describe('when snapToKeyboard is false (default value)', () => { + it('should not follow the keyboard up', () => { + keyboardSlideIn(); + + expect(ionFooterElement).toHaveComputedStyle({ + transform: 'none', + }); + }); + }); + }); + + describe('when inline type is set', () => { + beforeEach(() => { + spectator = createHost( + `` + ); + }); + + it('should have a transparent background color', () => { + expect(spectator.query('ion-footer')).toHaveComputedStyle({ + 'background-color': 'transparent', + }); + }); + + it('should not show a box shadow', () => { + expect(spectator.query('ion-footer')).toHaveComputedStyle({ + 'box-shadow': 'none', + }); + }); + }); + + // utility functions + function setSafeAreaBottom() { + modalFooterElement.style.setProperty('--kirby-safe-area-bottom', SAFE_AREA_BOTTOM_PX); + } + + function keyboardSlideIn() { + modalFooterElement.style.setProperty('--keyboard-offset', KEYBOARD_HEIGHT + 'px'); + modalFooterElement.classList.add('keyboard-visible'); + } + + function keyboardSlideOut() { + modalFooterElement.style.setProperty('--keyboard-offset', '0px'); + modalFooterElement.classList.remove('keyboard-visible'); + } +}); diff --git a/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.ts b/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.ts new file mode 100644 index 0000000000..7ce5900356 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/footer/footer.component.ts @@ -0,0 +1,16 @@ +import { Component, HostBinding, Input } from '@angular/core'; + +@Component({ + selector: 'kirby-modal-footer-experimental', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'], +}) +export class ModalFooterExperimentalComponent { + @HostBinding('class.snap-to-keyboard') + @Input() + snapToKeyboard = false; + + @HostBinding('class') + @Input() + type: 'inline' | 'fixed' = 'fixed'; +} diff --git a/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.html b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.html new file mode 100644 index 0000000000..8fe92470c5 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.html @@ -0,0 +1,34 @@ + + + + + + {{ title }} + + + + + + + + + + {{ title }} + + + + + + + + + diff --git a/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.scss b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.scss new file mode 100644 index 0000000000..fc798d0387 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.scss @@ -0,0 +1,128 @@ +@use '@kirbydesign/core/src/scss/utils'; + +// Global modal styling can be found at scss/base/_ionic.scss + +@mixin contain-content() { + padding-top: 0; + position: relative; + contain: inherit; + min-height: min(var(--min-height), calc(var(--vh100) - var(--kirby-modal-padding-top, 0px))); + + ion-content { + contain: content; + max-height: calc( + var(--vh100) - var(--kirby-modal-padding-top, 0px) - var(--header-height) - + var(--footer-height) + ); + + &::part(scroll) { + height: '100%'; + position: relative; + } + } +} + +$toolbar-padding-inline: utils.size('s'); +$toolbar-padding-block: utils.size('xs'); + +@mixin phablet-toolbar-padding() { + $toolbar-padding-top: utils.size('xxs'); + @include utils.media('>=medium') { + padding-top: $toolbar-padding-top; + } +} + +ion-header { + box-sizing: border-box; + + ion-toolbar { + --padding-start: #{$toolbar-padding-inline}; + --padding-end: #{$toolbar-padding-inline}; + --padding-bottom: #{$toolbar-padding-block}; + --padding-top: #{$toolbar-padding-block}; + --border-width: 0; + --background: transparent; + --color: var(--kirby-modal-color, #{utils.get-color('black')}); + + button { + color: var(--color); + } + + @include phablet-toolbar-padding; + } +} + +:host { + --vh100: var(--vh, 1vh) * 100; /// Fixes an issue with vh units on iOS Safari + --header-height: 0px; + --footer-height: 0px; +} + +:host-context(ion-modal) { + @include utils.media('>=medium') { + @include contain-content; + } +} + +ion-modal { + --background: var(--kirby-modal-background, #{utils.get-color('background-color')}); +} + +ion-title { + box-sizing: border-box; + padding-inline-start: calc(48px + var(--padding-start)); + padding-inline-end: calc(48px + var(--padding-end)); + font-size: utils.font-size('l'); + font-weight: utils.font-weight('bold'); +} + +ion-content { + --background: transparent; + --color: var(--kirby-modal-color, #{utils.get-color('black')}); + + display: flex; + flex-direction: column; + + @include utils.slotted('*') { + box-sizing: border-box; + display: block; + } + + --padding-top: #{utils.size('m')}; + --padding-bottom: #{utils.size('m')}; + --padding-start: #{utils.size('s')}; + --padding-end: #{utils.size('s')}; +} + +// Ensure padding-rules are not merged with other media query, +// as this rule has to come AFTER the default mobile-first rule in order to override: + +/* clean-css ignore:start */ +@include utils.media('>=medium') { + ion-content { + --padding-start: #{utils.size('xxxl')}; + --padding-end: #{utils.size('xxxl')}; + } +} + +:host(.collapsible-title) { + ion-content { + --padding-top: 0px; + + ion-header ion-toolbar:first-of-type { + padding-top: 0; + + --padding-top: 0px; + --padding-bottom: 0px; + --padding-start: 0px; + --padding-end: 0px; + } + } + + ion-title { + font-size: utils.font-size('n'); + font-weight: utils.font-weight('bold'); + } +} + +/* clean-css ignore:end */ diff --git a/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.spec.ts b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.spec.ts new file mode 100644 index 0000000000..98ce87324e --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.spec.ts @@ -0,0 +1,105 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator'; +import { + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonModal, + IonTitle, + IonToolbar, +} from '@ionic/angular'; +import { MockComponents } from 'ng-mocks'; +import { IconComponent } from '@kirbydesign/designsystem/icon'; +import { ButtonComponent } from '../../button/button.component'; +import { FullscreenModalExperimentalComponent } from './fullscreen.component'; + +describe('FullscreenComponent', () => { + const titleText = 'Test Modal'; + + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: FullscreenModalExperimentalComponent, + declarations: [ + MockComponents( + IonModal, + IonHeader, + IonToolbar, + IonTitle, + IonContent, + IonButtons, + IonIcon, + ButtonComponent, + IconComponent + ), + ], + }); + + beforeEach(() => { + spectator = createHost( + ` + +

Test

+
+ `, + { + hostProps: { + title: titleText, + open: true, + }, + } + ); + }); + + describe('by default', () => { + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should be dismissable', () => { + expect(spectator.component.canDismiss).toBeTrue(); + }); + + it('should have a collapsable title', () => { + expect(spectator.component.canDismiss).toBeTrue(); + }); + + it('should enable scroll', () => { + expect(spectator.component.scrollDisabled).toBeFalse(); + }); + }); + + describe('events', () => { + it('should emit the willPresent event, when ion-modal emit its own willPresent event', () => { + const willPresentSpy = spyOn(spectator.component.willPresent, 'emit'); + + spectator.triggerEventHandler(IonModal, 'willPresent', new CustomEvent('willPresent')); + + expect(willPresentSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit the didPresent event, when ion-modal emit its own didPresent event', () => { + const didPresentSpy = spyOn(spectator.component.didPresent, 'emit'); + + spectator.triggerEventHandler(IonModal, 'didPresent', new CustomEvent('didPresent')); + + expect(didPresentSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit the willDismiss event, when ion-modal emit its own willDismiss event', () => { + const willDismissSpy = spyOn(spectator.component.willDismiss, 'emit'); + + spectator.triggerEventHandler(IonModal, 'willDismiss', new CustomEvent('willDismiss')); + + expect(willDismissSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit the didDismiss event, when ion-modal emit its own didDismiss event', () => { + const didDismissSpy = spyOn(spectator.component.didDismiss, 'emit'); + + spectator.triggerEventHandler(IonModal, 'didDismiss', new CustomEvent('didDismiss')); + + expect(didDismissSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.ts b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.ts new file mode 100644 index 0000000000..dd055390ed --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/fullscreen/fullscreen.component.ts @@ -0,0 +1,53 @@ +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { IonContent, IonModal } from '@ionic/angular'; +import { OverlayEventDetail } from '@ionic/core/components'; +import { KirbyAnimation } from '@kirbydesign/designsystem/helpers'; + +@Component({ + selector: 'kirby-fullscreen-modal-experimental', + templateUrl: './fullscreen.component.html', + styleUrls: ['./fullscreen.component.scss'], +}) +export class FullscreenModalExperimentalComponent { + @ViewChild(IonModal) modal: IonModal; + @ViewChild(IonContent) ionContent: IonContent; + + @Input() open = false; + @Input() canDismiss: boolean | (() => Promise) = true; + @Input() title = ''; + @Input() hasCollapsibleTitle = false; + @Input() scrollDisabled = false; + + @Output() willPresent = new EventEmitter>(); + @Output() didPresent = new EventEmitter>(); + @Output() didDismiss = new EventEmitter>(); + @Output() willDismiss = new EventEmitter>(); + + _closeModal() { + this.modal.dismiss(null, 'cancel'); + } + + _onWillPresent(event: CustomEvent) { + this.willPresent.emit(event); + } + + _onDidPresent(event: CustomEvent) { + this.didPresent.emit(event); + } + + _onWillDismiss(event: CustomEvent) { + this.willDismiss.emit(event); + } + + _onDidDismiss(event: CustomEvent) { + this.didDismiss.emit(event); + } + + public scrollToTop(scrollDuration?: KirbyAnimation.Duration) { + this.ionContent.scrollToTop(scrollDuration || 0); + } + + public scrollToBottom(scrollDuration?: KirbyAnimation.Duration) { + this.ionContent.scrollToBottom(scrollDuration || 0); + } +} diff --git a/libs/designsystem/src/lib/components/modal-experimental/index.ts b/libs/designsystem/src/lib/components/modal-experimental/index.ts new file mode 100644 index 0000000000..dd1b081da9 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/index.ts @@ -0,0 +1,4 @@ +export { FullscreenModalExperimentalComponent } from './fullscreen/fullscreen.component'; +export { ModalFooterExperimentalComponent } from './footer/footer.component'; +export { ModalWrapperExperimentalComponent } from './wrapper/wrapper.component'; +export { ModalExperimentalController, ModalExperimentalConfig } from './services/modal.controller'; diff --git a/libs/designsystem/src/lib/components/modal-experimental/services/modal.controller.ts b/libs/designsystem/src/lib/components/modal-experimental/services/modal.controller.ts new file mode 100644 index 0000000000..595999210f --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/services/modal.controller.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { ModalController } from '@ionic/angular'; +import { from, Observable, Subject, switchMap, tap } from 'rxjs'; +import { OverlayEventDetail } from '@ionic/core/components'; + +export type ModalFlavor = 'modal'; + +export type ModalExperimentalConfig = { + flavor?: ModalFlavor; + component: any; + componentProps?: { [key: string]: any }; + cssClass?: string | string[]; + canDismiss?: boolean | (() => Promise); + backdropDismiss?: boolean; + showBackdrop?: boolean; +}; + +type ModalDismissObservables = { + onWillDismiss: Observable; + onDidDismiss: Observable; +}; +@Injectable() +export class ModalExperimentalController { + private isModalOpening = false; + + constructor(private ionicModalController: ModalController) {} + + public showModal(config: ModalExperimentalConfig): ModalDismissObservables { + if (this.isModalOpening) return; + + const $onWillDismiss = new Subject(); + const onWillDismiss$ = $onWillDismiss.asObservable(); + + const $onDidDismiss = new Subject(); + const onDidDismiss$ = $onDidDismiss.asObservable(); + + const modal$ = from( + this.ionicModalController.create({ + component: config.component, + componentProps: config.componentProps, + cssClass: config.cssClass, + canDismiss: config.canDismiss, + backdropDismiss: config.backdropDismiss, + showBackdrop: config.showBackdrop, + }) + ); + + this.isModalOpening = true; + + modal$ + .pipe( + tap((modal) => from(modal.present())), + switchMap((modal) => modal.onWillDismiss()) + ) + .subscribe((res) => { + this.isModalOpening = false; + + $onWillDismiss.next(res); + $onWillDismiss.complete(); + + $onDidDismiss.next(res); + $onDidDismiss.complete(); + }); + + return { + onWillDismiss: onWillDismiss$, + onDidDismiss: onDidDismiss$, + }; + } + + public closeModal(data?: unknown, role?: string): void { + this.ionicModalController.dismiss(data, role); + } +} diff --git a/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.html b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.html new file mode 100644 index 0000000000..f1618179f3 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.html @@ -0,0 +1,23 @@ + + + + {{ title }} + + + + + + + + + + {{ title }} + + + + + + + diff --git a/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.scss b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.scss new file mode 100644 index 0000000000..32fd08e470 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.scss @@ -0,0 +1,98 @@ +@use '@kirbydesign/core/src/scss/utils'; + +// // Global modal styling can be found at scss/base/_ionic.scss + +$toolbar-padding-inline: utils.size('s'); +$toolbar-padding-block: utils.size('xs'); + +@mixin phablet-toolbar-padding() { + $toolbar-padding-top: utils.size('xxs'); + @include utils.media('>=medium') { + padding-top: $toolbar-padding-top; + } +} + +:host { + height: 100%; + display: flex; + flex-direction: column; +} + +ion-header { + box-sizing: border-box; + + ion-toolbar { + --padding-start: #{$toolbar-padding-inline}; + --padding-end: #{$toolbar-padding-inline}; + --padding-bottom: #{$toolbar-padding-block}; + --padding-top: #{$toolbar-padding-block}; + --border-width: 0; + --background: transparent; + --color: var(--kirby-modal-color, #{utils.get-color('black')}); + + button { + color: var(--color); + } + + @include phablet-toolbar-padding; + } +} + +ion-title { + box-sizing: border-box; + padding-inline-start: calc(48px + var(--padding-start)); + padding-inline-end: calc(48px + var(--padding-end)); + font-size: utils.font-size('l'); + font-weight: utils.font-weight('bold'); +} + +ion-content { + --background: transparent; + --color: var(--kirby-modal-color, #{utils.get-color('black')}); + + display: flex; + flex-direction: column; + + @include utils.slotted('*') { + box-sizing: border-box; + display: block; + } + + --padding-top: #{utils.size('m')}; + --padding-bottom: #{utils.size('m')}; + --padding-start: #{utils.size('s')}; + --padding-end: #{utils.size('s')}; +} + +// Ensure padding-rules are not merged with other media query, +// as this rule has to come AFTER the default mobile-first rule in order to override: + +/* clean-css ignore:start */ +@include utils.media('>=medium') { + ion-content { + --padding-start: #{utils.size('xxxl')}; + --padding-end: #{utils.size('xxxl')}; + } +} + +:host(.collapsible-title) { + ion-content { + --padding-top: 0px; + + ion-header ion-toolbar:first-of-type { + padding-top: 0; + + --padding-top: 0px; + --padding-bottom: 0px; + --padding-start: 0px; + --padding-end: 0px; + } + } + + ion-title { + font-size: utils.font-size('n'); + font-weight: utils.font-weight('bold'); + } +} + +/* clean-css ignore:end */ diff --git a/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.spec.ts b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.spec.ts new file mode 100644 index 0000000000..6f1898cb66 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalWrapperExperimentalComponent } from './wrapper.component'; + +describe('WrapperComponent', () => { + let component: ModalWrapperExperimentalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ModalWrapperExperimentalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ModalWrapperExperimentalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.ts b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.ts new file mode 100644 index 0000000000..958b7bc366 --- /dev/null +++ b/libs/designsystem/src/lib/components/modal-experimental/wrapper/wrapper.component.ts @@ -0,0 +1,25 @@ +import { Component, ElementRef, Input, OnInit } from '@angular/core'; +import { IonModal } from '@ionic/angular'; + +@Component({ + selector: 'kirby-modal-wrapper-experimental', + templateUrl: './wrapper.component.html', + styleUrls: ['./wrapper.component.scss'], +}) +export class ModalWrapperExperimentalComponent implements OnInit { + constructor(private elementRef: ElementRef) {} + + private ionModalElement?: HTMLIonModalElement; + + @Input() title = ''; + @Input() hasCollapsibleTitle = true; + @Input() scrollDisabled = false; + + ngOnInit() { + this.ionModalElement = this.elementRef.nativeElement.closest('ion-modal'); + } + + close() { + this.ionModalElement.dismiss(); + } +} diff --git a/libs/designsystem/src/lib/components/modal/services/modal.controller.ts b/libs/designsystem/src/lib/components/modal/services/modal.controller.ts index 4f5be00228..d48ca5d7ca 100644 --- a/libs/designsystem/src/lib/components/modal/services/modal.controller.ts +++ b/libs/designsystem/src/lib/components/modal/services/modal.controller.ts @@ -41,6 +41,7 @@ export class ModalController implements OnDestroy { const navigateOnWillClose = () => { this.modalNavigationService.navigateOutOfModalOutlet(); }; + const siblingModalRouteActivated$ = modalRouteActivated$.pipe( filter((modalRouteActivation) => !modalRouteActivation.isNewModal), map((modalRouteActivation) => modalRouteActivation.route) diff --git a/libs/designsystem/src/lib/index.ts b/libs/designsystem/src/lib/index.ts index 376afd533c..7b6859f1b0 100644 --- a/libs/designsystem/src/lib/index.ts +++ b/libs/designsystem/src/lib/index.ts @@ -33,4 +33,5 @@ export * from './components/charts/chart-config'; export { KirbyModule } from './kirby.module'; export { KirbyExperimentalModule } from './kirby-experimental.module'; +export { KirbyModalModule } from './kirby-modal.module'; export { StockChartConfig, BarChartConfig } from './components/charts/chart-config'; diff --git a/libs/designsystem/src/lib/kirby-modal.module.ts b/libs/designsystem/src/lib/kirby-modal.module.ts new file mode 100644 index 0000000000..e9598adf7c --- /dev/null +++ b/libs/designsystem/src/lib/kirby-modal.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { CommonModule } from '@angular/common'; +import { IconModule } from '@kirbydesign/designsystem/icon'; +import { KirbyModule } from './kirby.module'; +import { FullscreenModalExperimentalComponent } from './components/modal-experimental/fullscreen/fullscreen.component'; +import { ModalFooterExperimentalComponent } from './components/modal-experimental/footer/footer.component'; +import { ModalWrapperExperimentalComponent } from './components/modal-experimental/wrapper/wrapper.component'; +import { ModalExperimentalController } from './components/modal-experimental/services/modal.controller'; + +const COMPONENT_DECLARATIONS = [ + FullscreenModalExperimentalComponent, + ModalFooterExperimentalComponent, + ModalWrapperExperimentalComponent, +]; +@NgModule({ + declarations: COMPONENT_DECLARATIONS, + imports: [CommonModule, IonicModule, IconModule, KirbyModule], + exports: COMPONENT_DECLARATIONS, + providers: [ModalExperimentalController], +}) +export class KirbyModalModule {}