feat(modal): add canDismiss property to manage modal dismissing (#24928)

resolves #22297

Co-authored-by: EinfachHans <EinfachHans@users.noreply.github.com>
This commit is contained in:
Liam DeBeasi
2022-03-11 14:52:29 -05:00
committed by GitHub
parent e932a04223
commit 4b21958ec5
18 changed files with 1526 additions and 81 deletions

View File

@ -768,6 +768,7 @@ ion-modal,prop,animated,boolean,true,false,false
ion-modal,prop,backdropBreakpoint,number,0,false,false
ion-modal,prop,backdropDismiss,boolean,true,false,false
ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false
ion-modal,prop,canDismiss,(() => Promise<boolean>) | boolean | undefined,undefined,false,false
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-modal,prop,handle,boolean | undefined,undefined,false,false
ion-modal,prop,htmlAttributes,ModalAttributes | undefined,undefined,false,false

View File

@ -1509,6 +1509,10 @@ export namespace Components {
* The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1]
*/
"breakpoints"?: number[];
/**
* 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.
*/
"canDismiss"?: undefined | boolean | (() => Promise<boolean>);
/**
* The component to display inside of the modal.
*/
@ -1584,6 +1588,7 @@ export namespace Components {
"showBackdrop": boolean;
/**
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
* @deprecated - To prevent modals from dismissing, use canDismiss instead.
*/
"swipeToClose": boolean;
/**
@ -5222,6 +5227,10 @@ declare namespace LocalJSX {
* The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1]
*/
"breakpoints"?: number[];
/**
* 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.
*/
"canDismiss"?: undefined | boolean | (() => Promise<boolean>);
/**
* The component to display inside of the modal.
*/
@ -5311,6 +5320,7 @@ declare namespace LocalJSX {
"showBackdrop"?: boolean;
/**
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
* @deprecated - To prevent modals from dismissing, use canDismiss instead.
*/
"swipeToClose"?: boolean;
/**

View File

@ -3,6 +3,8 @@ import { GestureDetail, createGesture } from '../../../utils/gesture';
import { clamp, raf } from '../../../utils/helpers';
import { getBackdropValueForSheet } from '../utils';
import { calculateSpringStep, handleCanDismiss } from './utils';
export const createSheetGesture = (
baseEl: HTMLIonModalElement,
backdropEl: HTMLIonBackdropElement,
@ -38,9 +40,12 @@ export const createSheetGesture = (
const height = wrapperEl.clientHeight;
let currentBreakpoint = initialBreakpoint;
let offset = 0;
let canDismissBlocksGesture = false;
const canDismissMaxStep = 0.95;
const wrapperAnimation = animation.childAnimations.find(ani => ani.id === 'wrapperAnimation');
const backdropAnimation = animation.childAnimations.find(ani => ani.id === 'backdropAnimation');
const maxBreakpoint = breakpoints[breakpoints.length - 1];
const minBreakpoint = breakpoints[0];
const enableBackdrop = () => {
baseEl.style.setProperty('pointer-events', 'auto');
@ -117,6 +122,20 @@ export const createSheetGesture = (
};
const onStart = () => {
/**
* If canDismiss is anything other than `true`
* then users should be able to swipe down
* until a threshold is hit. At that point,
* the card modal should not proceed any further.
*
* canDismiss is never fired via gesture if there is
* no 0 breakpoint. However, it can be fired if the user
* presses Esc or the hardware back button.
* TODO (FW-937)
* Remove undefined check
*/
canDismissBlocksGesture = baseEl.canDismiss !== undefined && baseEl.canDismiss !== true && minBreakpoint === 0;
/**
* If swiping on the content
* we should disable scrolling otherwise
@ -144,7 +163,34 @@ export const createSheetGesture = (
* relative to where the user dragged.
*/
const initialStep = 1 - currentBreakpoint;
offset = clamp(0.0001, initialStep + (detail.deltaY / height), 0.9999);
const secondToLastBreakpoint = breakpoints.length > 1 ? 1 - breakpoints[1] : undefined;
const step = initialStep + (detail.deltaY / height);
const isAttempingDismissWithCanDismiss = secondToLastBreakpoint !== undefined && step >= secondToLastBreakpoint && canDismissBlocksGesture;
/**
* If we are blocking the gesture from dismissing,
* set the max step value so that the sheet cannot be
* completely hidden.
*/
const maxStep = isAttempingDismissWithCanDismiss ? canDismissMaxStep : 0.9999;
/**
* If we are blocking the gesture from
* dismissing, calculate the spring modifier value
* this will be added to the starting breakpoint
* value to give the gesture a spring-like feeling.
* Note that when isAttempingDismissWithCanDismiss is true,
* the modifier is always added to the breakpoint that
* appears right after the 0 breakpoint.
*
* Note that this modifier is essentially the progression
* between secondToLastBreakpoint and maxStep which is
* why we subtract secondToLastBreakpoint. This lets us get
* the result as a value from 0 to 1.
*/
const processedStep = isAttempingDismissWithCanDismiss ? secondToLastBreakpoint + calculateSpringStep((step - secondToLastBreakpoint) / (maxStep - secondToLastBreakpoint)) : step;
offset = clamp(0.0001, processedStep, maxStep);
animation.progressStep(offset);
};
@ -156,12 +202,20 @@ export const createSheetGesture = (
const velocity = detail.velocityY;
const threshold = (detail.deltaY + velocity * 100) / height;
const diff = currentBreakpoint - threshold;
const closest = breakpoints.reduce((a, b) => {
return Math.abs(b - diff) < Math.abs(a - diff) ? b : a;
});
const shouldRemainOpen = closest !== 0;
/**
* canDismiss should only prevent snapping
* when users are trying to dismiss. If canDismiss
* is present but the user is trying to swipe upwards,
* we should allow that to happen,
*/
const shouldPreventDismiss = canDismissBlocksGesture && closest === 0;
const snapToBreakpoint = shouldPreventDismiss ? currentBreakpoint : closest;
const shouldRemainOpen = snapToBreakpoint !== 0;
currentBreakpoint = 0;
/**
@ -171,12 +225,12 @@ export const createSheetGesture = (
if (wrapperAnimation && backdropAnimation) {
wrapperAnimation.keyframes([
{ offset: 0, transform: `translateY(${offset * 100}%)` },
{ offset: 1, transform: `translateY(${(1 - closest) * 100}%)` }
{ offset: 1, transform: `translateY(${(1 - snapToBreakpoint) * 100}%)` }
]);
backdropAnimation.keyframes([
{ offset: 0, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(1 - offset, backdropBreakpoint)})` },
{ offset: 1, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(closest, backdropBreakpoint)})` }
{ offset: 1, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(snapToBreakpoint, backdropBreakpoint)})` }
]);
animation.progressStep(0);
@ -203,8 +257,8 @@ export const createSheetGesture = (
raf(() => {
wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]);
backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]);
animation.progressStart(true, 1 - closest);
currentBreakpoint = closest;
animation.progressStart(true, 1 - snapToBreakpoint);
currentBreakpoint = snapToBreakpoint;
onBreakpointChange(currentBreakpoint);
/**
@ -241,7 +295,9 @@ export const createSheetGesture = (
}, { oneTimeCallback: true })
.progressEnd(1, 0, 500);
if (!shouldRemainOpen) {
if (shouldPreventDismiss) {
handleCanDismiss(baseEl, animation);
} else if (!shouldRemainOpen) {
onDismiss();
}
};

View File

@ -3,6 +3,8 @@ import { getTimeGivenProgression } from '../../../utils/animation/cubic-bezier';
import { GestureDetail, createGesture } from '../../../utils/gesture';
import { clamp } from '../../../utils/helpers';
import { calculateSpringStep, handleCanDismiss } from './utils';
// Defaults for the card swipe animation
export const SwipeToCloseDefaults = {
MIN_PRESENTING_SCALE: 0.93,
@ -15,6 +17,8 @@ export const createSwipeToCloseGesture = (
) => {
const height = el.offsetHeight;
let isOpen = false;
let canDismissBlocksGesture = false;
const canDismissMaxStep = 0.20;
const canStart = (detail: GestureDetail) => {
const target = detail.event.target as HTMLElement | null;
@ -35,34 +39,82 @@ export const createSwipeToCloseGesture = (
};
const onStart = () => {
/**
* If canDismiss is anything other than `true`
* then users should be able to swipe down
* until a threshold is hit. At that point,
* the card modal should not proceed any further.
* TODO (FW-937)
* Remove undefined check
*/
canDismissBlocksGesture = el.canDismiss !== undefined && el.canDismiss !== true;
animation.progressStart(true, (isOpen) ? 1 : 0);
};
const onMove = (detail: GestureDetail) => {
const step = clamp(0.0001, detail.deltaY / height, 0.9999);
const step = detail.deltaY / height;
animation.progressStep(step);
/**
* Check if user is swiping down and
* if we have a canDismiss value that
* should block the gesture from
* proceeding,
*/
const isAttempingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture;
/**
* If we are blocking the gesture from dismissing,
* set the max step value so that the sheet cannot be
* completely hidden.
*/
const maxStep = isAttempingDismissWithCanDismiss ? canDismissMaxStep : 0.9999;
/**
* If we are blocking the gesture from
* dismissing, calculate the spring modifier value
* this will be added to the starting breakpoint
* value to give the gesture a spring-like feeling.
* Note that the starting breakpoint is always 0,
* so we omit adding 0 to the result.
*/
const processedStep = isAttempingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;
const clampedStep = clamp(0.0001, processedStep, maxStep);
animation.progressStep(clampedStep);
};
const onEnd = (detail: GestureDetail) => {
const velocity = detail.velocityY;
const step = detail.deltaY / height;
const step = clamp(0.0001, detail.deltaY / height, 0.9999);
const isAttempingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture;
const maxStep = isAttempingDismissWithCanDismiss ? canDismissMaxStep : 0.9999;
const processedStep = isAttempingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;
const clampedStep = clamp(0.0001, processedStep, maxStep);
const threshold = (detail.deltaY + velocity * 1000) / height;
const shouldComplete = threshold >= 0.5;
/**
* If canDismiss blocks
* the swipe gesture, then the
* animation can never complete until
* canDismiss is checked.
*/
const shouldComplete = !isAttempingDismissWithCanDismiss && threshold >= 0.5;
let newStepValue = (shouldComplete) ? -0.001 : 0.001;
if (!shouldComplete) {
animation.easing('cubic-bezier(1, 0, 0.68, 0.28)');
newStepValue += getTimeGivenProgression([0, 0], [1, 0], [0.68, 0.28], [1, 1], step)[0];
newStepValue += getTimeGivenProgression([0, 0], [1, 0], [0.68, 0.28], [1, 1], clampedStep)[0];
} else {
animation.easing('cubic-bezier(0.32, 0.72, 0, 1)');
newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], step)[0];
newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], clampedStep)[0];
}
const duration = (shouldComplete) ? computeDuration(step * height, velocity) : computeDuration((1 - step) * height, velocity);
const duration = (shouldComplete) ? computeDuration(step * height, velocity) : computeDuration((1 - clampedStep) * height, velocity);
isOpen = shouldComplete;
gesture.enable(false);
@ -75,7 +127,21 @@ export const createSwipeToCloseGesture = (
})
.progressEnd((shouldComplete) ? 1 : 0, newStepValue, duration);
if (shouldComplete) {
/**
* If the canDismiss value blocked the gesture
* from proceeding, then we should ignore whatever
* shouldComplete is. Whether or not the modal
* animation should complete is now determined by
* canDismiss.
*
* If the user swiped >25% of the way
* to the max step, then we should
* check canDismiss. 25% was chosen
* to avoid accidental swipes.
*/
if (isAttempingDismissWithCanDismiss && clampedStep > (maxStep / 4)) {
handleCanDismiss(el, animation);
} else if (shouldComplete) {
onDismiss();
}
};

View File

@ -0,0 +1,119 @@
import { Animation } from '../../../interface';
export const handleCanDismiss = async (
el: HTMLIonModalElement,
animation: Animation,
) => {
/**
* If canDismiss is not a function
* then we can return early. If canDismiss is `true`,
* then canDismissBlocksGesture is `false` as canDismiss
* will never interrupt the gesture. As a result,
* this code block is never reached. If canDismiss is `false`,
* then we never dismiss.
*/
if (typeof el.canDismiss !== 'function') { return; }
/**
* Run the canDismiss callback.
* If the function returns `true`,
* then we can proceed with dismiss.
*/
const shouldDismiss = await el.canDismiss();
if (!shouldDismiss) { return; }
/**
* If canDismiss resolved after the snap
* back animation finished, we can
* dismiss immediately.
*
* If canDismiss resolved before the snap
* back animation finished, we need to
* wait until the snap back animation is
* done before dismissing.
*/
if (animation.isRunning()) {
animation.onFinish(() => {
el.dismiss(undefined, 'handler')
}, { oneTimeCallback: true })
} else {
el.dismiss(undefined, 'handler');
}
}
/**
* This function lets us simulate a realistic spring-like animation
* when swiping down on the modal.
* There are two forces that we need to use to compute the spring physics:
*
* 1. Stiffness, k: This is a measure of resistance applied a spring.
* 2. Dampening, c: This value has the effect of reducing or preventing oscillation.
*
* Using these two values, we can calculate the Spring Force and the Dampening Force
* to compute the total force applied to a spring.
*
* Spring Force: This force pulls a spring back into its equilibrium position.
* Hooke's Law tells us that that spring force (FS) = kX.
* k is the stiffness of a spring, and X is the displacement of the spring from its
* equilibrium position. In this case, it is the amount by which the free end
* of a spring was displaced (stretched/pushed) from its "relaxed" position.
*
* Dampening Force: This force slows down motion. Without it, a spring would oscillate forever.
* The dampening force, FD, can be found via this formula: FD = -cv
* where c the dampening value and v is velocity.
*
* Therefore, the resulting force that is exerted on the block is:
* F = FS + FD = -kX - cv
*
* Newton's 2nd Law tells us that F = ma:
* ma = -kX - cv.
*
* For Ionic's purposes, we can assume that m = 1:
* a = -kX - cv
*
* Imagine a block attached to the end of a spring. At equilibrium
* the block is at position x = 1.
* Pressing on the block moves it to position x = 0;
* So, to calculate the displacement, we need to take the
* current position and subtract the previous position from it.
* X = x - x0 = 0 - 1 = -1.
*
* For Ionic's purposes, we are only pushing on the spring modal
* so we have a max position of 1.
* As a result, we can expand displacement to this formula:
* X = x - 1
*
* a = -k(x - 1) - cv
*
* We can represent the motion of something as a function of time: f(t) = x.
* The derivative of position gives us the velocity: f'(t)
* The derivative of the velocity gives us the acceleration: f''(t)
*
* We can substitute the formula above with these values:
*
* f"(t) = -k * (f(t) - 1) - c * f'(t)
*
* This is called a differential equation.
*
* We know that at t = 0, we are at x = 0 because the modal does not move: f(0) = 0
* This means our velocity is also zero: f'(0) = 0.
*
* We can cheat a bit and plug the formula into Wolfram Alpha.
* However, we need to pick stiffness and dampening values:
* k = 0.57
* c = 15
*
* I picked these as they are fairly close to native iOS's spring effect
* with the modal.
*
* What we plug in is this: f(0) = 0; f'(0) = 0; f''(t) = -0.57(f(t) - 1) - 15f'(t)
*
* The result is a formula that lets us calculate the acceleration
* for a given time t.
* Note: This is the approximate form of the solution. Wolfram Alpha will
* give you a complex differential equation too.
*/
export const calculateSpringStep = (t: number) => {
return 0.00255275 * 2.71828 ** (-14.9619 * t) - 1.00255 * 2.71828 ** (-0.0380968 * t) + 1
}

View File

@ -1,4 +1,5 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { printIonWarning } from '@utils/logging';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
@ -157,6 +158,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
/**
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
* @deprecated - To prevent modals from dismissing, use canDismiss instead.
*/
@Prop() swipeToClose = false;
@ -198,6 +200,23 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.configureTriggerInteraction();
}
/**
* TODO (FW-937)
* This needs to default to true in the next
* major release. We default it to undefined
* so we can force the card modal to be swipeable
* when using canDismiss.
*/
/**
* 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.
*/
@Prop() canDismiss?: undefined | boolean | (() => Promise<boolean>);
/**
* Emitted after the modal has presented.
*/
@ -256,7 +275,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
componentWillLoad() {
const { breakpoints, initialBreakpoint } = this;
const { breakpoints, initialBreakpoint, swipeToClose } = this;
/**
* If user has custom ID set then we should
@ -266,7 +285,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined;
if (breakpoints !== undefined && initialBreakpoint !== undefined && !breakpoints.includes(initialBreakpoint)) {
console.warn('[Ionic Warning]: Your breakpoints array must include the initialBreakpoint value.')
printIonWarning('Your breakpoints array must include the initialBreakpoint value.')
}
if (swipeToClose) {
printIonWarning('swipeToClose has been deprecated in favor of canDismiss.\n\nIf you want a card modal to be swipeable, set canDismiss to `true`. In the next major release of Ionic, swipeToClose will be removed, and all card modals will be swipeable by default.');
}
}
@ -339,6 +362,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
return { inline, delegate }
}
/**
* Determines whether or not the
* modal is allowed to dismiss based
* on the state of the canDismiss prop.
*/
private async checkCanDismiss() {
const { canDismiss } = this;
/**
* TODO (FW-937) - Remove the following check in
* the next major release of Ionic.
*/
if (canDismiss === undefined) { return true; }
if (typeof canDismiss === 'function') {
return canDismiss();
}
return canDismiss;
}
/**
* Present the modal overlay after it has been created.
*/
@ -378,7 +422,17 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.isSheetModal) {
this.initSheetGesture();
} else if (this.swipeToClose) {
/**
* TODO (FW-937) - In the next major release of Ionic, all card modals
* will be swipeable by default. canDismiss will be used to determine if the
* modal can be dismissed. This check should change to check the presence of
* presentingElement instead.
*
* If we did not do this check, then not using swipeToClose would mean you could
* not run canDismiss on swipe as there would be no swipe gesture created.
*/
} else if (this.swipeToClose || (this.canDismiss !== undefined && this.presentingElement !== undefined)) {
this.initSwipeToClose();
}
@ -500,6 +554,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
return false;
}
/**
* If a canDismiss handler is responsible
* for calling the dismiss method, we should
* not run the canDismiss check again.
*/
if (role !== 'handler' && !await this.checkCanDismiss()) {
return false;
}
/* tslint:disable-next-line */
if (typeof window !== 'undefined' && this.keyboardOpenCallback) {
window.removeEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);

View File

@ -62,6 +62,28 @@ See [Usage](#usage) for examples on how to use the sheet modal.
> Note: The `swipeToClose` property has no effect when using a sheet modal as sheet modals must be swipeable in order to be usable.
## Preventing a Modal from Dismissing
When entering data into a modal, it is often desirable to have a way of preventing accidental data loss. The `canDismiss` property on `ion-modal` gives developers control over when a modal is allowed to dismiss.
There are two different ways of using the `canDismiss` property.
> Note: When using a sheet modal, `canDismiss` will not be checked on swipe if there is no `0` breakpoint set. However, it will still be checked when pressing `Esc` or the hardware back button.
### Setting a boolean value
Developers can set `canDismiss` to a boolean value. 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.
Setting a boolean value should be used when you need to require a particular action to be taken prior to a modal being dismissed. For example, if developers want to require that a "Terms of Use" checkbox is checked prior to closing the modal, they could set `canDismiss` to `false` initially and update it to `true` when the checkbox is checked.
### Setting a callback function
Developers can set `canDismiss` to be a function. This function 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.
Setting a callback function should be used when you have complex dismissing criteria such as showing a confirmation dialog prior to dismissing the modal. The option that users select in this dialog can then be used to determine whether or not the modal should proceed with dismissing.
Note that setting a callback function will cause the swipe gesture to be interrupted when using a card or sheet modal. This is because Ionic does not know what your callback function will resolve to ahead of time.
## Interfaces
Below you will find all of the options available to you when using the `modalController`. These options should be supplied when calling `modalController.create()`.
@ -221,11 +243,18 @@ When the backdrop is disabled, users will be able to interact with elements outs
<app-angular-component title="Ionic"></app-angular-component>
</ng-template>
</ion-modal>
<!-- Require Action Sheet confirmation before dismissing -->
<ion-modal [isOpen]="true" [canDismiss]="canDismiss">
<ng-template>
<ion-content>Modal Content</ion-content>
</ng-template>
</ion-modal>
```
```typescript
import { Component } from '@angular/core';
import { IonRouterOutlet } from '@ionic/angular';
import { IonRouterOutlet, ActionSheetController } from '@ionic/angular';
@Component({
selector: 'modal-example',
@ -233,7 +262,36 @@ import { IonRouterOutlet } from '@ionic/angular';
styleUrls: ['./modal-example.css']
})
export class ModalExample {
constructor(public routerOutlet: IonRouterOutlet) {}
constructor(
public routerOutlet: IonRouterOutlet,
private actionSheetCtrl: ActionSheetController
) {}
async canDismiss() {
const actionSheet = await this.actionSheetCtrl.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
}
```
@ -438,36 +496,73 @@ In Angular, the CSS of a specific page is scoped only to elements of that page.
### Inline Modal
```html
<!-- Default -->
<ion-modal is-open="true">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Use a trigger -->
<ion-button id="trigger-button">Click to open modal</ion-button>
<ion-modal trigger="trigger-button">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Sheet Modal -->
<ion-modal is-open="true" id="sheet-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Card Modal -->
<ion-modal is-open="true" id="card-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<ion-app>
<!-- Default -->
<ion-modal is-open="true">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Use a trigger -->
<ion-button id="trigger-button">Click to open modal</ion-button>
<ion-modal trigger="trigger-button">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Sheet Modal -->
<ion-modal is-open="true" id="sheet-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Card Modal -->
<ion-modal is-open="true" id="card-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Require Action Sheet confirmation before dismissing -->
<ion-modal is-open="true" id="can-dismiss-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
</ion-app>
<script>
const sheetModal = document.querySelector('#sheet-modal');
const cardModal = document.querySelector('#sheet-modal');
const canDismissModal = document.querySelector('#can-dismiss-modal');
const app = document.querySelector('ion-app');
sheetModal.breakpoints = [0.1, 0.5, 1];
sheetModal.initialBreakpoint = 0.5;
cardModal.swipeToClose = true;
cardModal.presentingElement = document.querySelector('ion-app');
canDismissModal.canDismiss = async () => {
const actionSheet = document.createElement('ion-action-sheet');
actionSheet.header = 'Are you sure you want to discard your changes?';
actionSheet.buttons = [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
];
app.appendChild(actionSheet);
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
</script>
```
@ -618,8 +713,38 @@ interface Props {
import React from 'react';
import AppReactComponent from './AppReactComponent';
import { IonModal, IonContent, IonButton } from '@ionic/react';
import { IonModal, IonContent, IonButton, useIonActionSheet } from '@ionic/react';
export const ModalExample: React.FC<Props> = ({ router }) => {
const [present, dismiss] = useIonActionSheet();
const canDismiss = () => {
return new Promise(async (resolve) => {
await present({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
],
onDidDismiss: (ev: CustomEvent) => {
const role = ev.detail.role;
if (role === 'destructive') {
resolve(true);
}
resolve(false);
}
});
});
});
}
return (
<>
{/* Default */}
@ -655,6 +780,11 @@ export const ModalExample: React.FC<Props> = ({ router }) => {
<IonModal isOpen={true}>
<AppReactComponent title="Ionic"></AppReactComponent>
</IonModal>
{/* Require Action Sheet confirmation before dismissing */}
<IonModal isOpen={true} canDismiss={canDismiss}>
<IonContent>Modal Content</IonContent>
</IonModal>
</>
);
};
@ -869,6 +999,7 @@ const Home: React.FC = () => {
```tsx
import { Component, Element, h } from '@stencil/core';
import { actionSheetController } from '@ionic/core';
@Component({
tag: 'modal-example',
@ -881,6 +1012,32 @@ export class ModalExample {
this.routerOutlet = this.el.closest('ion-router-outlet');
}
async canDismiss() {
const actionSheet = await actionSheetController.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
render() {
return (
<div>
@ -917,6 +1074,11 @@ export class ModalExample {
<ion-modal isOpen={true}>
<app-stencil-component title="Ionic"></app-stencil-component>
</ion-modal>
{/* Require Action Sheet confirmation before dismissing */}
<ion-modal isOpen={true} canDismiss={() => this.canDismiss()}>
<ion-content>Modal Content</ion-content>
</ion-modal>
</div>
)
}
@ -1172,13 +1334,50 @@ export class ModalExample {
<app-vue-component title="Ionic"></app-vue-component>
</ion-modal>
<!-- Require Action Sheet confirmation before dismissing -->
<ion-modal
:is-open="true"
:can-dismiss="canDismiss"
>
<ion-content>Modal Content</ion-content>
</ion-modal>
<script>
import { IonModal, IonButton, IonContent } from '@ionic/vue';
import { defineComponent } from 'vue';
import AppVueComponent from './AppVueComponent.vue'
export default defineComponent({
components: { IonModal, IonButton, IonContent, AppVueComponent }
components: { IonModal, IonButton, IonContent, AppVueComponent },
setup() {
const canDismiss = async () => {
const actionSheet = await actionSheetController.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
};
return { canDismiss }
}
});
</script>
```
@ -1364,6 +1563,7 @@ export default {
| `backdropBreakpoint` | `backdrop-breakpoint` | A decimal value between 0 and 1 that indicates the point after which the backdrop will begin to fade in when using a sheet modal. Prior to this point, the backdrop will be hidden and the content underneath the sheet can be interacted with. This value is exclusive meaning the backdrop will become active after the value specified. | `number` | `0` |
| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` |
| `breakpoints` | -- | The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1] | `number[] \| undefined` | `undefined` |
| `canDismiss` | `can-dismiss` | 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. | `(() => Promise<boolean>) \| boolean \| undefined` | `undefined` |
| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
| `handle` | `handle` | The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. | `boolean \| undefined` | `undefined` |
| `htmlAttributes` | -- | Additional attributes to pass to the modal. | `ModalAttributes \| undefined` | `undefined` |
@ -1374,7 +1574,7 @@ export default {
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
| `presentingElement` | -- | The element that presented the modal. This is used for card presentation effects and for stacking multiple modals on top of each other. Only applies in iOS mode. | `HTMLElement \| undefined` | `undefined` |
| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the modal. | `boolean` | `true` |
| `swipeToClose` | `swipe-to-close` | If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. | `boolean` | `false` |
| `swipeToClose` | `swipe-to-close` | <span style="color:red">**[DEPRECATED]**</span> - To prevent modals from dismissing, use canDismiss instead.<br/><br/>If `true`, the modal can be swiped to dismiss. Only applies in iOS mode. | `boolean` | `false` |
| `trigger` | `trigger` | An ID corresponding to the trigger element that causes the modal to open when clicked. | `string \| undefined` | `undefined` |

View File

@ -0,0 +1,388 @@
import { newE2EPage, newE2EPage } from '@stencil/core/testing';
import { dragElementBy } from '@utils/test';
describe('modal - canDismiss handler', () => {
let page: E2EPage;
describe('regular modal', () => {
beforeEach(async () => {
page = await newE2EPage({ url: '/src/components/modal/test/canDismiss?ionic:_testing=true' });
});
it('should dismiss when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(true);
});
it('should not dismiss when canDismiss is false', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(false);
});
it('should dismiss when canDismiss is Promise<true>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(true);
});
it('should not dismiss when canDismiss is Promise<false>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-promise-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(false);
});
it('should dismiss when canDismiss is Action Sheet and user clicks confirm', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
await page.click('#radio-action-sheet');
await page.click('#show-modal');
await ionModalDidPresent.next();
await page.keyboard.press('Escape');
await ionActionSheetDidPresent.next();
await page.click('.button-confirm');
await ionModalDidDismiss.next();
});
});
describe('card modal', () => {
beforeEach(async () => {
page = await newE2EPage({ url: '/src/components/modal/test/canDismiss?ionic:_testing=true&ionic:mode=ios' });
await page.click('#radio-card');
});
it('should dismiss when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(true);
});
it('should not dismiss when canDismiss is false', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(false);
});
it('should dismiss when canDismiss is Promise<true>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(true);
});
it('should not dismiss when canDismiss is Promise<false>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-promise-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(false);
});
it('should dismiss on swipe when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionModalDidDismiss.next();
});
it('should not dismiss on swipe when canDismiss is false', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
const modal = await page.find('ion-modal');
expect(modal).not.toBe(null);
});
it('should dismiss on swipe when canDismiss is Promise<true>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionModalDidDismiss.next();
});
it('should not dismiss on swipe when canDismiss is Promise<false>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent')
const ionHandlerDone = await page.spyOnEvent('ionHandlerDone');
await page.click('#radio-promise-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionHandlerDone.next();
const modal = await page.find('ion-modal');
expect(modal).not.toBe(null);
});
it('should dismiss when canDismiss is Action Sheet and user clicks confirm', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
await page.click('#radio-action-sheet');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionActionSheetDidPresent.next();
await page.click('.button-confirm');
await ionModalDidDismiss.next();
});
});
describe('sheet modal', () => {
beforeEach(async () => {
page = await newE2EPage({ url: '/src/components/modal/test/canDismiss?ionic:_testing=true' });
await page.click('#radio-sheet');
});
it('should dismiss when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(true);
});
it('should not dismiss when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(false);
});
it('should dismiss when canDismiss is Promise<true>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(true);
});
it('should not dismiss when canDismiss is Promise<false>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-promise-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modal = await page.find('ion-modal');
const returnValue = await modal.callMethod('dismiss');
expect(returnValue).toBe(false);
});
it('should dismiss on swipe when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionModalDidDismiss.next();
});
it('should not dismiss on swipe when canDismiss is true', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#radio-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
const modal = await page.find('ion-modal');
expect(modal).not.toBe(null);
});
it('should dismiss on swipe when canDismiss is Promise<true>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionModalDidDismiss.next();
});
it('should not dismiss on swipe when canDismiss is Promise<false>', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionHandlerDone = await page.spyOnEvent('ionHandlerDone');
await page.click('#radio-promise-false');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionHandlerDone.next();
const modal = await page.find('ion-modal');
expect(modal).not.toBe(null);
});
it('should not dismiss on swipe when not attempting to close', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionHandlerDone = await page.spyOnEvent('ionHandlerDone');
const screenshotCompares = [];
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, -500);
screenshotCompares.push(await page.compareScreenshot());
const modal = await page.find('ion-modal');
expect(modal).not.toBe(null);
for (const screenshotCompare of screenshotCompares) {
expect(screenshotCompare).toMatchScreenshot();
}
});
it('should hit the dismiss threshold when swiping', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click('#radio-promise-true');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 100);
await ionModalDidDismiss.next();
});
it('should dismiss when canDismiss is Action Sheet and user clicks confirm', async () => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
await page.click('#radio-action-sheet');
await page.click('#show-modal');
await ionModalDidPresent.next();
const modalHeader = await page.$('#modal-header');
await dragElementBy(modalHeader, page, 0, 500);
await ionActionSheetDidPresent.next();
await page.click('.button-confirm');
await ionModalDidDismiss.next();
});
});
});

View File

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Modal - canDismiss</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<script type="module">
import { modalController, actionSheetController } from '../../../../dist/ionic/index.esm.js';
window.modalController = modalController;
window.actionSheetController = actionSheetController;
</script>
<style>
form {
display: inline-block;
margin: 0 20px 20px 0;
}
</style>
</head>
<body>
<ion-app>
<div class="root-page ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Modal - canDismiss</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form id="handler">
<input type="radio" id="radio-true" value="true" name="canDismiss" checked />
<label for="radio-true">
<code>canDismiss="true"</code>
</label>
<br />
<input type="radio" id="radio-false" value="false" name="canDismiss" />
<label for="radio-false">
<code>canDismiss="false"</code>
</label>
<br />
<input type="radio" id="radio-promise-true" value="promise-true" name="canDismiss" />
<label for="radio-promise-true">
<code>Promise&lt;true&gt;</code>
</label>
<br />
<input type="radio" id="radio-promise-false" value="promise-false" name="canDismiss" />
<label for="radio-promise-false">
<code>Promise&lt;false&gt;</code>
</label>
<br />
<input type="radio" id="radio-action-sheet" value="action-sheet" name="canDismiss" />
<label for="radio-action-sheet">
<code>Action Sheet</code>
</label>
</form>
<form id="modal-type">
<input type="radio" id="radio-regular" value="regular" name="modalType" checked />
<label for="radio-regular">
<code>Regular</code>
</label>
<br />
<input type="radio" id="radio-card" value="card" name="modalType" />
<label for="radio-card">
<code>Card</code>
</label>
<br />
<input type="radio" id="radio-sheet" value="sheet" name="modalType" />
<label for="radio-sheet">
<code>Sheet</code>
</label>
</form>
<br />
<ion-button id="show-modal" onclick="createModal()">
Show Modal
</ion-button>
</ion-content>
</div>
</ion-app>
<script>
const rootPage = document.querySelector('.root-page');
const handlerForm = document.querySelector('form#handler');
const modalTypeForm = document.querySelector('form#modal-type');
const modal = document.querySelector('ion-modal');
let handler = true;
let type = 'regular';
handlerForm.addEventListener('change', (ev) => {
switch (ev.target.value) {
case "true":
handler = true;
break;
case "false":
handler = false;
break;
case "promise-true":
handler = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
setTimeout(() => {
const event = new CustomEvent('ionHandlerDone');
window.dispatchEvent(event);
}, 1000);
}, 250);
});
}
break;
case "promise-false":
handler = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(false);
setTimeout(() => {
const event = new CustomEvent('ionHandlerDone');
window.dispatchEvent(event);
}, 1000);
}, 250);
});
}
break;
case "action-sheet":
handler = async () => {
const actionSheet = await actionSheetController.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
cssClass: 'button-confirm',
text: 'Discard Changes',
role: 'destructive'
},
{
cssClass: 'button-cancel',
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
default:
break;
}
});
modalTypeForm.addEventListener('change', (ev) => {
switch (ev.target.value) {
case "regular":
type = "regular";
break;
case "card":
type = "card";
break;
case "sheet":
type = "sheet";
break;
default:
break;
}
});
const createModal = async () => {
const el = document.createElement('div');
el.innerHTML = `
<ion-header id="modal-header">
<ion-toolbar>
<ion-title>Modal</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
Modal with canDismiss
</ion-content>
`;
const opts = {
component: el,
canDismiss: handler
};
if (type === 'card') {
opts.presentingElement = rootPage;
} else if (type === 'sheet') {
opts.breakpoints = [0, 0.25, 0.5, 1];
opts.initialBreakpoint = 0.25;
}
const modal = await modalController.create(opts);
await modal.present();
}
</script>
</body>
</html>

View File

@ -44,11 +44,18 @@
<app-angular-component title="Ionic"></app-angular-component>
</ng-template>
</ion-modal>
<!-- Require Action Sheet confirmation before dismissing -->
<ion-modal [isOpen]="true" [canDismiss]="canDismiss">
<ng-template>
<ion-content>Modal Content</ion-content>
</ng-template>
</ion-modal>
```
```typescript
import { Component } from '@angular/core';
import { IonRouterOutlet } from '@ionic/angular';
import { IonRouterOutlet, ActionSheetController } from '@ionic/angular';
@Component({
selector: 'modal-example',
@ -56,7 +63,36 @@ import { IonRouterOutlet } from '@ionic/angular';
styleUrls: ['./modal-example.css']
})
export class ModalExample {
constructor(public routerOutlet: IonRouterOutlet) {}
constructor(
public routerOutlet: IonRouterOutlet,
private actionSheetCtrl: ActionSheetController
) {}
async canDismiss() {
const actionSheet = await this.actionSheetCtrl.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
}
```
@ -253,4 +289,4 @@ async presentModal() {
### Style Placement
In Angular, the CSS of a specific page is scoped only to elements of that page. Even though the Modal can be presented from within a page, the `ion-modal` element is appended outside of the current page. This means that any custom styles need to go in a global stylesheet file. In an Ionic Angular starter this can be the `src/global.scss` file or you can register a new global style file by [adding to the `styles` build option in `angular.json`](https://angular.io/guide/workspace-config#style-script-config).
In Angular, the CSS of a specific page is scoped only to elements of that page. Even though the Modal can be presented from within a page, the `ion-modal` element is appended outside of the current page. This means that any custom styles need to go in a global stylesheet file. In an Ionic Angular starter this can be the `src/global.scss` file or you can register a new global style file by [adding to the `styles` build option in `angular.json`](https://angular.io/guide/workspace-config#style-script-config).

View File

@ -1,36 +1,73 @@
### Inline Modal
```html
<!-- Default -->
<ion-modal is-open="true">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Use a trigger -->
<ion-button id="trigger-button">Click to open modal</ion-button>
<ion-modal trigger="trigger-button">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Sheet Modal -->
<ion-modal is-open="true" id="sheet-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Card Modal -->
<ion-modal is-open="true" id="card-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<ion-app>
<!-- Default -->
<ion-modal is-open="true">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Use a trigger -->
<ion-button id="trigger-button">Click to open modal</ion-button>
<ion-modal trigger="trigger-button">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Sheet Modal -->
<ion-modal is-open="true" id="sheet-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Card Modal -->
<ion-modal is-open="true" id="card-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
<!-- Require Action Sheet confirmation before dismissing -->
<ion-modal is-open="true" id="can-dismiss-modal">
<ion-content>Modal Content</ion-content>
</ion-modal>
</ion-app>
<script>
const sheetModal = document.querySelector('#sheet-modal');
const cardModal = document.querySelector('#sheet-modal');
const canDismissModal = document.querySelector('#can-dismiss-modal');
const app = document.querySelector('ion-app');
sheetModal.breakpoints = [0.1, 0.5, 1];
sheetModal.initialBreakpoint = 0.5;
cardModal.swipeToClose = true;
cardModal.presentingElement = document.querySelector('ion-app');
canDismissModal.canDismiss = async () => {
const actionSheet = document.createElement('ion-action-sheet');
actionSheet.header = 'Are you sure you want to discard your changes?';
actionSheet.buttons = [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
];
app.appendChild(actionSheet);
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
</script>
```

View File

@ -24,8 +24,38 @@ interface Props {
import React from 'react';
import AppReactComponent from './AppReactComponent';
import { IonModal, IonContent, IonButton } from '@ionic/react';
import { IonModal, IonContent, IonButton, useIonActionSheet } from '@ionic/react';
export const ModalExample: React.FC<Props> = ({ router }) => {
const [present, dismiss] = useIonActionSheet();
const canDismiss = () => {
return new Promise(async (resolve) => {
await present({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
],
onDidDismiss: (ev: CustomEvent) => {
const role = ev.detail.role;
if (role === 'destructive') {
resolve(true);
}
resolve(false);
}
});
});
});
}
return (
<>
{/* Default */}
@ -61,6 +91,11 @@ export const ModalExample: React.FC<Props> = ({ router }) => {
<IonModal isOpen={true}>
<AppReactComponent title="Ionic"></AppReactComponent>
</IonModal>
{/* Require Action Sheet confirmation before dismissing */}
<IonModal isOpen={true} canDismiss={canDismiss}>
<IonContent>Modal Content</IonContent>
</IonModal>
</>
);
};
@ -266,4 +301,4 @@ const Home: React.FC = () => {
);
};
```
```

View File

@ -2,6 +2,7 @@
```tsx
import { Component, Element, h } from '@stencil/core';
import { actionSheetController } from '@ionic/core';
@Component({
tag: 'modal-example',
@ -14,6 +15,32 @@ export class ModalExample {
this.routerOutlet = this.el.closest('ion-router-outlet');
}
async canDismiss() {
const actionSheet = await actionSheetController.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
}
render() {
return (
<div>
@ -50,6 +77,11 @@ export class ModalExample {
<ion-modal isOpen={true}>
<app-stencil-component title="Ionic"></app-stencil-component>
</ion-modal>
{/* Require Action Sheet confirmation before dismissing */}
<ion-modal isOpen={true} canDismiss={() => this.canDismiss()}>
<ion-content>Modal Content</ion-content>
</ion-modal>
</div>
)
}
@ -263,4 +295,4 @@ export class ModalExample {
]
}
}
```
```

View File

@ -35,13 +35,50 @@
<app-vue-component title="Ionic"></app-vue-component>
</ion-modal>
<!-- Require Action Sheet confirmation before dismissing -->
<ion-modal
:is-open="true"
:can-dismiss="canDismiss"
>
<ion-content>Modal Content</ion-content>
</ion-modal>
<script>
import { IonModal, IonButton, IonContent } from '@ionic/vue';
import { defineComponent } from 'vue';
import AppVueComponent from './AppVueComponent.vue'
export default defineComponent({
components: { IonModal, IonButton, IonContent, AppVueComponent }
components: { IonModal, IonButton, IonContent, AppVueComponent },
setup() {
const canDismiss = async () => {
const actionSheet = await actionSheetController.create({
header: 'Are you sure you want to discard your changes?',
buttons: [
{
text: 'Discard Changes',
role: 'destructive'
},
{
text: 'Keep Editing',
role: 'cancel'
}
]
});
await actionSheet.present();
const { role } = await actionSheet.onDidDismiss();
if (role === 'destructive') {
return true;
}
return false;
};
return { canDismiss }
}
});
</script>
```

View File

@ -212,6 +212,12 @@ export interface Animation {
* upon the animation ending
*/
onFinish(callback: AnimationLifecycle, opts?: AnimationCallbackOptions): Animation;
/**
* Returns `true` if the animation is running.
* Returns `false` otherwise.
*/
isRunning(): boolean;
}
export type AnimationLifecycle = (currentStep: 0 | 1, animation: Animation) => void;

View File

@ -50,6 +50,7 @@ export const createAnimation = (animationId?: string): Animation => {
let shouldCalculateNumAnimations = true;
let keyframeName: string | undefined;
let ani: AnimationInternal;
let paused = false;
const id: string | undefined = animationId;
const onFinishCallbacks: AnimationOnFinishCallback[] = [];
@ -113,8 +114,13 @@ export const createAnimation = (animationId?: string): Animation => {
numAnimationsRunning = 0;
finished = false;
willComplete = true;
paused = false;
};
const isRunning = () => {
return numAnimationsRunning !== 0 && !paused;
}
const onFinish = (callback: AnimationLifecycle, opts?: AnimationCallbackOptions) => {
const callbacks = (opts && opts.oneTimeCallback) ? onFinishOneTimeCallbacks : onFinishCallbacks;
callbacks.push({ c: callback, o: opts });
@ -770,6 +776,8 @@ export const createAnimation = (animationId?: string): Animation => {
setStyleProperty(element, 'animation-play-state', 'paused');
});
}
paused = true;
}
};
@ -906,6 +914,8 @@ export const createAnimation = (animationId?: string): Animation => {
} else {
playCSSAnimations();
}
paused = false;
});
};
@ -1000,6 +1010,7 @@ export const createAnimation = (animationId?: string): Animation => {
beforeRemoveClass,
beforeAddClass,
onFinish,
isRunning,
progressStart,
progressStep,

View File

@ -4,6 +4,119 @@ import { getTimeGivenProgression } from '../cubic-bezier';
import { Animation } from '../animation-interface';
describe('Animation Class', () => {
describe('isRunning()', () => {
let animation: Animation;
beforeEach(() => {
animation = createAnimation();
});
it('should not be running initially', () => {
expect(animation.isRunning()).toEqual(false);
});
it('should not be running due to not having keyframes', () => {
animation.play();
expect(animation.isRunning()).toEqual(false);
});
it('should be running', () => {
const el = document.createElement('div');
animation.addElement(el);
animation.keyframes([
{ transform: 'scale(1)', opacity: 1, offset: 0 },
{ transform: 'scale(0)', opacity: 0, offset: 1 }
]);
animation.duration(250);
animation.play();
expect(animation.isRunning()).toEqual(true);
});
it('should not be running after finishing the animation', async () => {
const el = document.createElement('div');
animation.addElement(el);
animation.keyframes([
{ transform: 'scale(1)', opacity: 1, offset: 0 },
{ transform: 'scale(0)', opacity: 0, offset: 1 }
]);
animation.duration(250);
await animation.play();
expect(animation.isRunning()).toEqual(false);
});
it('should not be running after calling pause', () => {
const el = document.createElement('div');
animation.addElement(el);
animation.keyframes([
{ transform: 'scale(1)', opacity: 1, offset: 0 },
{ transform: 'scale(0)', opacity: 0, offset: 1 }
]);
animation.duration(250);
animation.play();
expect(animation.isRunning()).toEqual(true);
animation.pause();
expect(animation.isRunning()).toEqual(false);
});
it('should not be running when doing progress steps', () => {
const el = document.createElement('div');
animation.addElement(el);
animation.keyframes([
{ transform: 'scale(1)', opacity: 1, offset: 0 },
{ transform: 'scale(0)', opacity: 0, offset: 1 }
]);
animation.duration(250);
animation.play();
animation.progressStart();
expect(animation.isRunning()).toEqual(false);
});
it('should be running after calling progressEnd', () => {
const el = document.createElement('div');
animation.addElement(el);
animation.keyframes([
{ transform: 'scale(1)', opacity: 1, offset: 0 },
{ transform: 'scale(0)', opacity: 0, offset: 1 }
]);
animation.duration(250);
animation.play();
animation.progressStart();
animation.progressEnd(1);
expect(animation.isRunning()).toEqual(true);
});
it('should not be running after playing to beginning', async () => {
const el = document.createElement('div');
animation.addElement(el);
animation.keyframes([
{ transform: 'scale(1)', opacity: 1, offset: 0 },
{ transform: 'scale(0)', opacity: 0, offset: 1 }
]);
animation.duration(250);
await animation.play();
animation.progressStart();
animation.progressEnd(0);
await new Promise((resolve) => {
animation.onFinish(() => {
expect(animation.isRunning()).toEqual(false);
resolve();
});
});
});
})
describe('addElement()', () => {
let animation: Animation;
@ -83,7 +196,7 @@ describe('Animation Class', () => {
expect(animation.getKeyframes().length).toEqual(3);
});
it('should convert properties for CSS Animations', () => {
const processedKeyframes = processKeyframes([
{ borderRadius: '0px', easing: 'ease-in' , offset: 0 },
@ -375,7 +488,7 @@ describe('cubic-bezier conversion', () => {
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.1), [0.03]);
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.70), [0.35]);
})
it('cubic-bezier(0.32, 0.72, 0, 1) (with out of bounds progression)', () => {
const equation = [
[0, 0],
@ -383,11 +496,11 @@ describe('cubic-bezier conversion', () => {
[.14, 1.72],
[1, 1]
];
expect(getTimeGivenProgression(...equation, 1.32)[0]).toBeUndefined();
expect(getTimeGivenProgression(...equation, -0.32)[0]).toBeUndefined();
})
it('cubic-bezier(0.21, 1.71, 0.88, 0.9) (multiple solutions)', () => {
const equation = [
[0, 0],
@ -395,10 +508,10 @@ describe('cubic-bezier conversion', () => {
[0.88, 0.9],
[1, 1]
];
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 1.02), [0.35, 0.87]);
})
it('cubic-bezier(0.32, 0.72, 0, 1) (with out of bounds progression)', () => {
const equation = [
[0, 0],
@ -406,11 +519,11 @@ describe('cubic-bezier conversion', () => {
[.14, 1.72],
[1, 1]
];
expect(getTimeGivenProgression(...equation, 1.32)).toEqual([]);
expect(getTimeGivenProgression(...equation, -0.32)).toEqual([]);
})
})
});
@ -418,4 +531,4 @@ const shouldApproximatelyEqual = (givenValues: number[], expectedValues: number[
givenValues.forEach((givenValue, i) => {
expect(Math.abs(expectedValues[i] - givenValue)).toBeLessThanOrEqual(0.01);
});
}
}

View File

@ -1,4 +1,14 @@
/**
* Logs a warning to the console with an Ionic prefix
* to indicate the library that is warning the developer.
*
* @param message - The string message to be logged to the console.
*/
export const printIonWarning = (message: string) => {
return console.warn(`[Ionic Warning]: ${message}`);
}
/*
* Logs an error to the console with an Ionic prefix
* to indicate the library that is warning the developer.
*
@ -7,4 +17,4 @@
*/
export const printIonError = (message: string, ...params: any) => {
return console.error(`[Ionic Error]: ${message}`, ...params);
}
}