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.
+
+ 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.
+
+ 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.
+ 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(
+ `
+
+