mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 03:00:58 +08:00
feat(modal): add card-style presentation with swipe to close gesture (#19428)
resolves #18660
This commit is contained in:
@ -16,6 +16,8 @@ import { RouteView, getUrl } from './stack-utils';
|
||||
inputs: ['animated', 'swipeGesture']
|
||||
})
|
||||
export class IonRouterOutlet implements OnDestroy, OnInit {
|
||||
nativeEl: HTMLIonRouterOutletElement;
|
||||
|
||||
private activated: ComponentRef<any> | null = null;
|
||||
private activatedView: RouteView | null = null;
|
||||
|
||||
@ -23,7 +25,6 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
|
||||
private _swipeGesture?: boolean;
|
||||
private name: string;
|
||||
private stackCtrl: StackController;
|
||||
private nativeEl: HTMLIonRouterOutletElement;
|
||||
|
||||
// Maintain map of activated route proxies for each component instance
|
||||
private proxyMap = new WeakMap<any, ActivatedRoute>();
|
||||
|
@ -683,7 +683,9 @@ ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefin
|
||||
ion-modal,prop,keyboardClose,boolean,true,false,false
|
||||
ion-modal,prop,leaveAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
|
||||
ion-modal,prop,mode,"ios" | "md",undefined,false,false
|
||||
ion-modal,prop,presentingElement,HTMLElement | undefined,undefined,false,false
|
||||
ion-modal,prop,showBackdrop,boolean,true,false,false
|
||||
ion-modal,prop,swipeToClose,boolean,false,false,false
|
||||
ion-modal,method,dismiss,dismiss(data?: any, role?: string | undefined) => Promise<boolean>
|
||||
ion-modal,method,onDidDismiss,onDidDismiss() => Promise<OverlayEventDetail<any>>
|
||||
ion-modal,method,onWillDismiss,onWillDismiss() => Promise<OverlayEventDetail<any>>
|
||||
|
16
core/src/components.d.ts
vendored
16
core/src/components.d.ts
vendored
@ -1500,9 +1500,17 @@ export namespace Components {
|
||||
*/
|
||||
'present': () => Promise<void>;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
'presentingElement'?: HTMLElement;
|
||||
/**
|
||||
* If `true`, a backdrop will be displayed behind the modal.
|
||||
*/
|
||||
'showBackdrop': boolean;
|
||||
/**
|
||||
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
|
||||
*/
|
||||
'swipeToClose': boolean;
|
||||
}
|
||||
interface IonModalController {
|
||||
/**
|
||||
@ -4853,9 +4861,17 @@ declare namespace LocalJSX {
|
||||
*/
|
||||
'onIonModalWillPresent'?: (event: CustomEvent<void>) => void;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
'presentingElement'?: HTMLElement;
|
||||
/**
|
||||
* If `true`, a backdrop will be displayed behind the modal.
|
||||
*/
|
||||
'showBackdrop'?: boolean;
|
||||
/**
|
||||
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
|
||||
*/
|
||||
'swipeToClose'?: boolean;
|
||||
}
|
||||
interface IonModalController {}
|
||||
interface IonNav {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, Watch, h } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { AlertButton, AlertInput, Animation, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface } from '../../interface';
|
||||
import { AlertButton, AlertInput, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface } from '../../interface';
|
||||
import { BACKDROP, dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays';
|
||||
import { sanitizeDOMString } from '../../utils/sanitization';
|
||||
import { getClassMap } from '../../utils/theme';
|
||||
@ -30,7 +30,6 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
||||
private processedButtons: AlertButton[] = [];
|
||||
|
||||
presented = false;
|
||||
animation?: Animation;
|
||||
mode = getIonMode(this);
|
||||
|
||||
@Element() el!: HTMLIonAlertElement;
|
||||
|
@ -120,6 +120,8 @@
|
||||
}
|
||||
|
||||
.transition-effect {
|
||||
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
/* stylelint-disable property-blacklist */
|
||||
|
@ -2,7 +2,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Meth
|
||||
|
||||
import { config } from '../../global/config';
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { Animation, AnimationBuilder, OverlayEventDetail, OverlayInterface, SpinnerTypes } from '../../interface';
|
||||
import { AnimationBuilder, OverlayEventDetail, OverlayInterface, SpinnerTypes } from '../../interface';
|
||||
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays';
|
||||
import { sanitizeDOMString } from '../../utils/sanitization';
|
||||
import { getClassMap } from '../../utils/theme';
|
||||
@ -27,7 +27,6 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
||||
private durationTimeout: any;
|
||||
|
||||
presented = false;
|
||||
animation?: Animation;
|
||||
mode = getIonMode(this);
|
||||
|
||||
@Element() el!: HTMLIonLoadingElement;
|
||||
|
@ -1,90 +1,53 @@
|
||||
import { Animation } from '../../../interface';
|
||||
import { createAnimation } from '../../../utils/animation/animation';
|
||||
import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
|
||||
|
||||
/**
|
||||
* iOS Modal Enter Animation
|
||||
* iOS Modal Enter Animation for the Card presentation style
|
||||
*/
|
||||
export const iosEnterAnimation = (baseEl: HTMLElement): Animation => {
|
||||
const baseAnimation = createAnimation();
|
||||
const backdropAnimation = createAnimation();
|
||||
const wrapperAnimation = createAnimation();
|
||||
|
||||
backdropAnimation
|
||||
export const iosEnterAnimation = (
|
||||
baseEl: HTMLElement,
|
||||
presentingEl?: HTMLElement,
|
||||
): Animation => {
|
||||
// The top translate Y for the presenting element
|
||||
const backdropAnimation = createAnimation()
|
||||
.addElement(baseEl.querySelector('ion-backdrop')!)
|
||||
.fromTo('opacity', 0.01, 'var(--backdrop-opacity)');
|
||||
|
||||
wrapperAnimation
|
||||
const wrapperAnimation = createAnimation()
|
||||
.addElement(baseEl.querySelector('.modal-wrapper')!)
|
||||
.beforeStyles({ 'opacity': 1 })
|
||||
.fromTo('transform', 'translateY(100%)', 'translateY(0%)');
|
||||
|
||||
return baseAnimation
|
||||
const baseAnimation = createAnimation()
|
||||
.addElement(baseEl)
|
||||
.easing('cubic-bezier(0.36,0.66,0.04,1)')
|
||||
.duration(400)
|
||||
.easing('cubic-bezier(0.32,0.72,0,1)')
|
||||
.duration(500)
|
||||
.beforeAddClass('show-modal')
|
||||
.addAnimation([backdropAnimation, wrapperAnimation]);
|
||||
|
||||
if (presentingEl) {
|
||||
const modalTransform = (presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined) ? 40 : 0;
|
||||
const bodyEl = document.body;
|
||||
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
|
||||
const finalTransform = `translateY(${-modalTransform}px) scale(${toPresentingScale})`;
|
||||
|
||||
const presentingAnimation = createAnimation()
|
||||
.beforeStyles({
|
||||
'transform': 'translateY(0)'
|
||||
})
|
||||
.afterStyles({
|
||||
'transform': finalTransform
|
||||
})
|
||||
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black'))
|
||||
.addElement(presentingEl)
|
||||
.keyframes([
|
||||
{ offset: 0, transform: 'translateY(0px) scale(1)', 'border-radius': '0px' },
|
||||
{ offset: 1, transform: finalTransform, 'border-radius': '10px 10px 0 0' }
|
||||
]);
|
||||
|
||||
baseAnimation.addAnimation(presentingAnimation);
|
||||
}
|
||||
|
||||
return baseAnimation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Animations for modals
|
||||
*/
|
||||
// export function modalSlideIn(rootEl: HTMLElement) {
|
||||
|
||||
// }
|
||||
|
||||
// export class ModalSlideOut {
|
||||
// constructor(el: HTMLElement) {
|
||||
// let backdrop = new Animation(this.plt, el.querySelector('ion-backdrop'));
|
||||
// let wrapperEle = <HTMLElement>el.querySelector('.modal-wrapper');
|
||||
// let wrapperEleRect = wrapperEle.getBoundingClientRect();
|
||||
// let wrapper = new Animation(this.plt, wrapperEle);
|
||||
|
||||
// // height of the screen - top of the container tells us how much to scoot it down
|
||||
// // so it's off-screen
|
||||
// wrapper.fromTo('translateY', '0px', `${this.plt.height() - wrapperEleRect.top}px`);
|
||||
// backdrop.fromTo('opacity', 0.4, 0.0);
|
||||
|
||||
// this
|
||||
// .element(this.leavingView.pageRef())
|
||||
// .easing('ease-out')
|
||||
// .duration(250)
|
||||
// .add(backdrop)
|
||||
// .add(wrapper);
|
||||
// }
|
||||
// }
|
||||
|
||||
// export class ModalMDSlideIn {
|
||||
// constructor(el: HTMLElement) {
|
||||
// const backdrop = new Animation(this.plt, el.querySelector('ion-backdrop'));
|
||||
// const wrapper = new Animation(this.plt, el.querySelector('.modal-wrapper'));
|
||||
|
||||
// backdrop.fromTo('opacity', 0.01, 0.4);
|
||||
// wrapper.fromTo('translateY', '40px', '0px');
|
||||
// wrapper.fromTo('opacity', 0.01, 1);
|
||||
|
||||
// const DURATION = 280;
|
||||
// const EASING = 'cubic-bezier(0.36,0.66,0.04,1)';
|
||||
// this.element(this.enteringView.pageRef()).easing(EASING).duration(DURATION)
|
||||
// .add(backdrop)
|
||||
// .add(wrapper);
|
||||
// }
|
||||
// }
|
||||
|
||||
// export class ModalMDSlideOut {
|
||||
// constructor(el: HTMLElement) {
|
||||
// const backdrop = new Animation(this.plt, el.querySelector('ion-backdrop'));
|
||||
// const wrapper = new Animation(this.plt, el.querySelector('.modal-wrapper'));
|
||||
|
||||
// backdrop.fromTo('opacity', 0.4, 0.0);
|
||||
// wrapper.fromTo('translateY', '0px', '40px');
|
||||
// wrapper.fromTo('opacity', 0.99, 0);
|
||||
|
||||
// this
|
||||
// .element(this.leavingView.pageRef())
|
||||
// .duration(200)
|
||||
// .easing('cubic-bezier(0.47,0,0.745,0.715)')
|
||||
// .add(wrapper)
|
||||
// .add(backdrop);
|
||||
// }
|
||||
// }
|
||||
|
@ -1,28 +1,55 @@
|
||||
import { Animation } from '../../../interface';
|
||||
import { createAnimation } from '../../../utils/animation/animation';
|
||||
import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
|
||||
|
||||
/**
|
||||
* iOS Modal Leave Animation
|
||||
*/
|
||||
export const iosLeaveAnimation = (baseEl: HTMLElement): Animation => {
|
||||
const baseAnimation = createAnimation();
|
||||
const backdropAnimation = createAnimation();
|
||||
const wrapperAnimation = createAnimation();
|
||||
const wrapperEl = baseEl.querySelector('.modal-wrapper');
|
||||
const wrapperElRect = wrapperEl!.getBoundingClientRect();
|
||||
export const iosLeaveAnimation = (
|
||||
baseEl: HTMLElement,
|
||||
presentingEl?: HTMLElement,
|
||||
duration = 500
|
||||
): Animation => {
|
||||
|
||||
backdropAnimation
|
||||
const backdropAnimation = createAnimation()
|
||||
.addElement(baseEl.querySelector('ion-backdrop')!)
|
||||
.fromTo('opacity', 'var(--backdrop-opacity)', 0.0);
|
||||
|
||||
wrapperAnimation
|
||||
.addElement(wrapperEl!)
|
||||
const wrapperAnimation = createAnimation()
|
||||
.addElement(baseEl.querySelector('.modal-wrapper')!)
|
||||
.beforeStyles({ 'opacity': 1 })
|
||||
.fromTo('transform', 'translateY(0%)', `translateY(${(baseEl.ownerDocument as any).defaultView.innerHeight - wrapperElRect.top}px)`);
|
||||
.fromTo('transform', `translateY(0%)`, 'translateY(100%)');
|
||||
|
||||
return baseAnimation
|
||||
const baseAnimation = createAnimation()
|
||||
.addElement(baseEl)
|
||||
.easing('ease-out')
|
||||
.duration(250)
|
||||
.easing('cubic-bezier(0.32,0.72,0,1)')
|
||||
.duration(duration)
|
||||
.addAnimation([backdropAnimation, wrapperAnimation]);
|
||||
|
||||
if (presentingEl) {
|
||||
const modalTransform = (presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined) ? 40 : 0;
|
||||
const bodyEl = document.body;
|
||||
const currentPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
|
||||
const presentingAnimation = createAnimation()
|
||||
.addElement(presentingEl)
|
||||
.beforeClearStyles(['transform'])
|
||||
.afterClearStyles(['transform'])
|
||||
.onFinish(currentStep => {
|
||||
// only reset background color if this is the last card-style modal
|
||||
if (currentStep !== 1) { return; }
|
||||
|
||||
const numModals = Array.from(bodyEl.querySelectorAll('ion-modal')).filter(m => m.presentingElement !== undefined).length;
|
||||
if (numModals <= 1) {
|
||||
bodyEl.style.setProperty('background-color', '');
|
||||
}
|
||||
})
|
||||
.keyframes([
|
||||
{ offset: 0, transform: `translateY(${-modalTransform}px) scale(${currentPresentingScale})`, 'border-radius': '10px 10px 0 0' },
|
||||
{ offset: 1, transform: 'translateY(0px) scale(1)', 'border-radius': '0px' }
|
||||
]);
|
||||
|
||||
baseAnimation.addAnimation(presentingAnimation);
|
||||
}
|
||||
|
||||
return baseAnimation;
|
||||
};
|
||||
|
97
core/src/components/modal/gestures/swipe-to-close.ts
Normal file
97
core/src/components/modal/gestures/swipe-to-close.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { Animation } from '../../../interface';
|
||||
import { getTimeGivenProgression } from '../../../utils/animation/cubic-bezier';
|
||||
import { GestureDetail, createGesture } from '../../../utils/gesture';
|
||||
import { clamp } from '../../../utils/helpers';
|
||||
|
||||
// Defaults for the card swipe animation
|
||||
export const SwipeToCloseDefaults = {
|
||||
MIN_BACKDROP_OPACITY: 0.4,
|
||||
MIN_PRESENTING_SCALE: 0.95,
|
||||
MIN_Y_CARD: 44,
|
||||
MIN_Y_FULLSCREEN: 0,
|
||||
MIN_PRESENTING_Y: 0
|
||||
};
|
||||
|
||||
export const createSwipeToCloseGesture = (
|
||||
el: HTMLIonModalElement,
|
||||
animation: Animation,
|
||||
onDismiss: () => void
|
||||
) => {
|
||||
const height = el.offsetHeight;
|
||||
let isOpen = false;
|
||||
|
||||
const canStart = (detail: GestureDetail) => {
|
||||
const target = detail.event.target as HTMLElement | null;
|
||||
|
||||
if (target === null ||
|
||||
!(target as any).closest) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const content = target.closest('ion-content');
|
||||
if (content === null) {
|
||||
return true;
|
||||
}
|
||||
// Target is in the content so we don't start the gesture.
|
||||
// We could be more nuanced here and allow it for content that
|
||||
// does not need to scroll.
|
||||
return false;
|
||||
};
|
||||
|
||||
const onStart = () => {
|
||||
animation.progressStart(true, (isOpen) ? 1 : 0);
|
||||
};
|
||||
|
||||
const onMove = (detail: GestureDetail) => {
|
||||
const step = detail.deltaY / height;
|
||||
if (step < 0) { return; }
|
||||
|
||||
animation.progressStep(step);
|
||||
};
|
||||
|
||||
const onEnd = (detail: GestureDetail) => {
|
||||
const velocity = detail.velocityY;
|
||||
const step = detail.deltaY / height;
|
||||
if (step < 0) { return; }
|
||||
|
||||
const threshold = (detail.deltaY + velocity * 1000) / height;
|
||||
|
||||
const shouldComplete = 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];
|
||||
} 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];
|
||||
}
|
||||
|
||||
const duration = (shouldComplete) ? computeDuration(step * height, velocity) : computeDuration((1 - step) * height, velocity);
|
||||
isOpen = shouldComplete;
|
||||
|
||||
animation
|
||||
.onFinish(() => {
|
||||
if (shouldComplete) {
|
||||
onDismiss();
|
||||
}
|
||||
})
|
||||
.progressEnd((shouldComplete) ? 1 : 0, newStepValue, duration);
|
||||
};
|
||||
|
||||
return createGesture({
|
||||
el,
|
||||
gestureName: 'modalSwipeToClose',
|
||||
gesturePriority: 40,
|
||||
direction: 'y',
|
||||
threshold: 10,
|
||||
canStart,
|
||||
onStart,
|
||||
onMove,
|
||||
onEnd
|
||||
});
|
||||
};
|
||||
|
||||
const computeDuration = (remaining: number, velocity: number) => {
|
||||
return clamp(100, remaining / Math.abs(velocity * 1.1), 400);
|
||||
};
|
@ -3,11 +3,13 @@ import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Mode
|
||||
export interface ModalOptions<T extends ComponentRef = ComponentRef> {
|
||||
component: T;
|
||||
componentProps?: ComponentProps<T>;
|
||||
presentingElement?: HTMLElement;
|
||||
showBackdrop?: boolean;
|
||||
backdropDismiss?: boolean;
|
||||
cssClass?: string | string[];
|
||||
delegate?: FrameworkDelegate;
|
||||
animated?: boolean;
|
||||
swipeToClose?: boolean;
|
||||
|
||||
mode?: Mode;
|
||||
keyboardClose?: boolean;
|
||||
|
@ -18,3 +18,12 @@
|
||||
// hidden by default to prevent flickers, the animation will show it
|
||||
@include transform(translate3d(0, 100%, 0));
|
||||
}
|
||||
|
||||
:host(.modal-card) {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
:host(.modal-card) .modal-wrapper {
|
||||
@include border-radius($modal-ios-border-radius, $modal-ios-border-radius, 0, 0);
|
||||
height: calc(100% - 40px);
|
||||
}
|
@ -8,3 +8,5 @@ $modal-ios-background-color: $background-color !default;
|
||||
|
||||
/// @prop - Border radius for the modal
|
||||
$modal-ios-border-radius: 10px !default;
|
||||
|
||||
$modal-ios-card-border-radius: 10px !default;
|
||||
|
@ -20,4 +20,4 @@
|
||||
@include transform(translate3d(0, 40px, 0));
|
||||
|
||||
opacity: .01;
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface';
|
||||
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, OverlayEventDetail, OverlayInterface } from '../../interface';
|
||||
import { attachComponent, detachComponent } from '../../utils/framework-delegate';
|
||||
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays';
|
||||
import { BACKDROP, activeAnimations, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays';
|
||||
import { getClassMap } from '../../utils/theme';
|
||||
import { deepReady } from '../../utils/transition';
|
||||
|
||||
@ -11,6 +11,7 @@ import { iosEnterAnimation } from './animations/ios.enter';
|
||||
import { iosLeaveAnimation } from './animations/ios.leave';
|
||||
import { mdEnterAnimation } from './animations/md.enter';
|
||||
import { mdLeaveAnimation } from './animations/md.leave';
|
||||
import { createSwipeToCloseGesture } from './gestures/swipe-to-close';
|
||||
|
||||
/**
|
||||
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
|
||||
@ -24,11 +25,13 @@ import { mdLeaveAnimation } from './animations/md.leave';
|
||||
scoped: true
|
||||
})
|
||||
export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private gesture?: Gesture;
|
||||
|
||||
// Reference to the user's provided modal content
|
||||
private usersElement?: HTMLElement;
|
||||
|
||||
presented = false;
|
||||
animation: Animation | undefined;
|
||||
animation?: Animation;
|
||||
mode = getIonMode(this);
|
||||
|
||||
@Element() el!: HTMLIonModalElement;
|
||||
@ -85,6 +88,17 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
@Prop() animated = true;
|
||||
|
||||
/**
|
||||
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
|
||||
*/
|
||||
@Prop() swipeToClose = false;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Prop() presentingElement?: HTMLElement;
|
||||
|
||||
/**
|
||||
* Emitted after the modal has presented.
|
||||
*/
|
||||
@ -127,7 +141,21 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
};
|
||||
this.usersElement = await attachComponent(this.delegate, container, this.component, ['ion-page'], componentProps);
|
||||
await deepReady(this.usersElement);
|
||||
return present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation);
|
||||
await present(this, 'modalEnter', iosEnterAnimation, mdEnterAnimation, this.presentingElement);
|
||||
|
||||
const mode = getIonMode(this);
|
||||
if (this.swipeToClose && mode === 'ios') {
|
||||
// All of the elements needed for the swipe gesture
|
||||
// should be in the DOM and referenced by now, except
|
||||
// for the presenting el
|
||||
const ani = this.animation = iosLeaveAnimation(this.el, this.presentingElement);
|
||||
this.gesture = createSwipeToCloseGesture(
|
||||
this.el,
|
||||
ani,
|
||||
() => this.dismiss(undefined, 'gesture')
|
||||
);
|
||||
this.gesture.enable(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,10 +166,21 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
@Method()
|
||||
async dismiss(data?: any, role?: string): Promise<boolean> {
|
||||
const dismissed = await dismiss(this, data, role, 'modalLeave', iosLeaveAnimation, mdLeaveAnimation);
|
||||
const iosAni = (this.animation === undefined || (role === BACKDROP || role === undefined)) ? iosLeaveAnimation : undefined;
|
||||
const enteringAnimation = activeAnimations.get(this) || [];
|
||||
const dismissed = await dismiss(this, data, role, 'modalLeave', iosAni, mdLeaveAnimation, this.presentingElement);
|
||||
|
||||
if (dismissed) {
|
||||
await detachComponent(this.delegate, this.usersElement);
|
||||
if (this.animation) {
|
||||
this.animation.destroy();
|
||||
}
|
||||
|
||||
enteringAnimation.forEach(ani => ani.destroy());
|
||||
}
|
||||
|
||||
this.animation = undefined;
|
||||
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
@ -194,6 +233,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
aria-modal="true"
|
||||
class={{
|
||||
[mode]: true,
|
||||
[`modal-card`]: this.presentingElement !== undefined,
|
||||
...getClassMap(this.cssClass)
|
||||
}}
|
||||
style={{
|
||||
@ -209,10 +249,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}/>
|
||||
<div
|
||||
role="dialog"
|
||||
class={{
|
||||
[`modal-wrapper`]: true,
|
||||
[mode]: true,
|
||||
}}
|
||||
class="modal-wrapper"
|
||||
>
|
||||
</div>
|
||||
</Host>
|
||||
|
@ -153,6 +153,42 @@ import { EventModalModule } from '../modals/event/event.module';
|
||||
export class CalendarComponentModule {}
|
||||
```
|
||||
|
||||
### Swipeable Modals
|
||||
|
||||
Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped.
|
||||
|
||||
```javascript
|
||||
import { IonRouterOutlet } from '@ionic/angular';
|
||||
|
||||
constructor(private routerOutlet: IonRouterOutlet) {}
|
||||
|
||||
async presentModal() {
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalPage,
|
||||
swipeToClose: true,
|
||||
presentingElement: this.routerOutlet.nativeEl
|
||||
});
|
||||
return await modal.present();
|
||||
}
|
||||
```
|
||||
|
||||
In most scenarios, using the `ion-router-outlet` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` element as the `presentingElement`.
|
||||
|
||||
```javascript
|
||||
import { ModalController } from '@ionic/angular';
|
||||
|
||||
constructor(private modalCtrl: ModalController) {}
|
||||
|
||||
async presentModal() {
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalPage,
|
||||
swipeToClose: true,
|
||||
presentingElement: await this.modalCtrl.getTop() // Get the top-most ion-modal
|
||||
});
|
||||
return await modal.present();
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Javascript
|
||||
|
||||
@ -235,6 +271,27 @@ console.log(data);
|
||||
```
|
||||
|
||||
|
||||
### Swipeable Modals
|
||||
|
||||
Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped.
|
||||
|
||||
```javascript
|
||||
const modalElement = document.createElement('ion-modal');
|
||||
modalElement.component = 'modal-page';
|
||||
modalElement.swipeToClose = true;
|
||||
modalElement.presentingElement = document.querySelector('ion-nav');
|
||||
```
|
||||
|
||||
In most scenarios, using the `ion-nav` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within a modal, you should pass in the top-most `ion-modal` element as the `presentingElement`.
|
||||
|
||||
```javascript
|
||||
const modalElement = document.createElement('ion-modal');
|
||||
modalElement.component = 'modal-page';
|
||||
modalElement.swipeToClose = true;
|
||||
modalElement.presentingElement = await modalController.getTop(); // Get the top-most ion-modal
|
||||
```
|
||||
|
||||
|
||||
### React
|
||||
|
||||
```tsx
|
||||
@ -256,6 +313,43 @@ export const ModalExample: React.FC = () => {
|
||||
};
|
||||
```
|
||||
|
||||
### Swipeable Modals
|
||||
|
||||
Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped.
|
||||
|
||||
```tsx
|
||||
<IonModal
|
||||
isOpen={showModal}
|
||||
swipeToClose={true}
|
||||
presentingElement={pageRef.current}
|
||||
onDidDismiss={() => setShowModal(false)}>
|
||||
<p>This is modal content</p>
|
||||
<IonButton onClick={() => setShowModal(false)}>Close Modal</IonButton>
|
||||
</IonModal>
|
||||
```
|
||||
|
||||
In most scenarios, setting a ref on `IonPage` and passing that ref's `current` value to `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` ref as the `presentingElement`.
|
||||
|
||||
```tsx
|
||||
<IonModal
|
||||
ref={firstModalRef}
|
||||
isOpen={showModal}
|
||||
swipeToClose={true}
|
||||
presentingElement={pageRef.current}
|
||||
onDidDismiss={() => setShowModal(false)}>
|
||||
<p>This is modal content</p>
|
||||
<IonButton onClick={() => setShow2ndModal(true)}>Show 2nd Modal</IonButton>
|
||||
<IonButton onClick={() => setShowModal(false)}>Close Modal</IonButton>
|
||||
</IonModal>
|
||||
<IonModal
|
||||
isOpen={show2ndModal}
|
||||
presentingElement={firstModalRef.current}
|
||||
onDidDismiss={() => setShow2ndModal(false)}>
|
||||
<p>This is more modal content</p>
|
||||
<IonButton onClick={() => setShow2ndModal(false)}>Close Modal</IonButton>
|
||||
</IonModal>
|
||||
```
|
||||
|
||||
|
||||
### Vue
|
||||
|
||||
@ -326,18 +420,20 @@ export default {
|
||||
|
||||
## Properties
|
||||
|
||||
| Property | Attribute | Description | Type | Default |
|
||||
| ------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- |
|
||||
| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` |
|
||||
| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` |
|
||||
| `component` _(required)_ | `component` | The component to display inside of the modal. | `Function \| HTMLElement \| null \| string` | `undefined` |
|
||||
| `componentProps` | -- | The data to pass to the modal component. | `undefined \| { [key: string]: any; }` | `undefined` |
|
||||
| `cssClass` | `css-class` | Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. | `string \| string[] \| undefined` | `undefined` |
|
||||
| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
|
||||
| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` |
|
||||
| `leaveAnimation` | -- | Animation to use when the modal is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
|
||||
| `mode` | `mode` | The mode determines which platform styles to use. | `"ios" \| "md"` | `undefined` |
|
||||
| `showBackdrop` | `show-backdrop` | If `true`, a backdrop will be displayed behind the modal. | `boolean` | `true` |
|
||||
| Property | Attribute | Description | Type | Default |
|
||||
| ------------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------- |
|
||||
| `animated` | `animated` | If `true`, the modal will animate. | `boolean` | `true` |
|
||||
| `backdropDismiss` | `backdrop-dismiss` | If `true`, the modal will be dismissed when the backdrop is clicked. | `boolean` | `true` |
|
||||
| `component` _(required)_ | `component` | The component to display inside of the modal. | `Function \| HTMLElement \| null \| string` | `undefined` |
|
||||
| `componentProps` | -- | The data to pass to the modal component. | `undefined \| { [key: string]: any; }` | `undefined` |
|
||||
| `cssClass` | `css-class` | Additional classes to apply for custom CSS. If multiple classes are provided they should be separated by spaces. | `string \| string[] \| undefined` | `undefined` |
|
||||
| `enterAnimation` | -- | Animation to use when the modal is presented. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
|
||||
| `keyboardClose` | `keyboard-close` | If `true`, the keyboard will be automatically dismissed when the overlay is presented. | `boolean` | `true` |
|
||||
| `leaveAnimation` | -- | Animation to use when the modal is dismissed. | `((baseEl: any, opts?: any) => Animation) \| undefined` | `undefined` |
|
||||
| `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` |
|
||||
|
||||
|
||||
## Events
|
||||
|
@ -23,23 +23,26 @@
|
||||
<body>
|
||||
<ion-app>
|
||||
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal - Basic</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<div class="ion-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Modal - Basic</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<p>
|
||||
<ion-button id="basic-modal" onclick="presentModal()">Present modal</ion-button>
|
||||
</p>
|
||||
<p>
|
||||
<ion-button id="presentModal" class="e2ePresentModal" onclick="presentCloseModal()">Present and close modal</ion-button>
|
||||
</p>
|
||||
<p>
|
||||
<ion-button id="presentModal" class="e2ePresentModal" onclick="presentCloseModal2()">Present and close modal (crash)</ion-button>
|
||||
</p>
|
||||
</ion-content>
|
||||
<ion-content class="ion-padding">
|
||||
<p>
|
||||
<ion-button id="basic-modal" onclick="presentModal()">Present modal</ion-button>
|
||||
</p>
|
||||
<p>
|
||||
<ion-button id="presentModal" class="e2ePresentModal" onclick="presentCloseModal()">Present and close modal</ion-button>
|
||||
</p>
|
||||
<p>
|
||||
<ion-button id="presentModal" class="e2ePresentModal" onclick="presentCloseModal2()">Present and close modal (crash)</ion-button>
|
||||
</p>
|
||||
</ion-content>
|
||||
</div>
|
||||
<ion-modal-controller></ion-modal-controller>
|
||||
|
||||
</ion-app>
|
||||
|
||||
@ -77,8 +80,9 @@
|
||||
}
|
||||
|
||||
async function presentModal() {
|
||||
const presentingEl = document.querySelectorAll('.ion-page')[1];
|
||||
const modal = createModal();
|
||||
await modal.present();
|
||||
await modal.present(presentingEl);
|
||||
}
|
||||
async function presentCloseModal() {
|
||||
const modal = createModal();
|
||||
|
11
core/src/components/modal/test/spec/e2e.ts
Normal file
11
core/src/components/modal/test/spec/e2e.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { testModal } from '../test.utils';
|
||||
|
||||
const DIRECTORY = 'spec';
|
||||
|
||||
test('modal: card', async () => {
|
||||
await testModal(DIRECTORY, '#card-modal');
|
||||
});
|
||||
|
||||
test('modal:rtl: card', async () => {
|
||||
await testModal(DIRECTORY, '#card-modal', true);
|
||||
});
|
478
core/src/components/modal/test/spec/index.html
Normal file
478
core/src/components/modal/test/spec/index.html
Normal file
@ -0,0 +1,478 @@
|
||||
<!DOCTYPE html>
|
||||
<html dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Modal - Spec</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 nomodule src="../../../../../dist/ionic/ionic.js"></script>
|
||||
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--ion-safe-area-top: 20px;
|
||||
--ion-safe-area-bottom: 20px;
|
||||
}
|
||||
|
||||
#modal-header {
|
||||
padding-top: 5px !important;
|
||||
height: 55px;
|
||||
}
|
||||
#modal-header ion-title {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
#modal-header ion-note {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 15px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
ion-list ion-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
|
||||
<div class="ion-page">
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button id="card-modal" onclick="presentModal(document.querySelectorAll('.ion-page')[1])">
|
||||
<ion-icon name="add" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Favorites</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-list id="list"></ion-list>
|
||||
</ion-content>
|
||||
</div>
|
||||
<ion-modal-controller></ion-modal-controller>
|
||||
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
window.addEventListener("ionModalDidDismiss", function (e) { console.log('DidDismiss', e) })
|
||||
window.addEventListener("ionModalWillDismiss", function (e) { console.log('WillDismiss', e) })
|
||||
|
||||
const people = [
|
||||
{
|
||||
"name": "Miyah Myles",
|
||||
"email": "miyah.myles@gmail.com",
|
||||
"position": "Business Analyst",
|
||||
},
|
||||
{
|
||||
"name": "June Cha",
|
||||
"email": "june.cha@gmail.com",
|
||||
"position": "Data Entry Clerk",
|
||||
},
|
||||
{
|
||||
"name": "Iida Niskanen",
|
||||
"email": "iida.niskanen@gmail.com",
|
||||
"position": "Business Analyst",
|
||||
},
|
||||
{
|
||||
"name": "Renee Sims",
|
||||
"email": "renee.sims@gmail.com",
|
||||
"position": "Lead Developer",
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Nu\u00f1ez",
|
||||
"email": "jonathan.nu\u00f1ez@gmail.com",
|
||||
"position": "Receptionist",
|
||||
},
|
||||
{
|
||||
"name": "Sasha Ho",
|
||||
"email": "sasha.ho@gmail.com",
|
||||
"position": "Sales",
|
||||
},
|
||||
{
|
||||
"name": "Abdullah Hadley",
|
||||
"email": "abdullah.hadley@gmail.com",
|
||||
"position": "Sales Manager",
|
||||
},
|
||||
{
|
||||
"name": "Veeti Seppanen",
|
||||
"email": "veeti.seppanen@gmail.com",
|
||||
"position": "Marketing",
|
||||
},
|
||||
{
|
||||
"name": "Thomas Stock",
|
||||
"email": "thomas.stock@gmail.com",
|
||||
"position": "Clerical",
|
||||
},
|
||||
{
|
||||
"name": "Bonnie Riley",
|
||||
"email": "bonnie.riley@gmail.com",
|
||||
"position": "Medical Assistant",
|
||||
}
|
||||
];
|
||||
|
||||
const allContacts = [
|
||||
{
|
||||
"name": "Miyah Myles",
|
||||
"email": "miyah.myles@gmail.com",
|
||||
"position": "Office Assistant",
|
||||
},
|
||||
{
|
||||
"name": "June Cha",
|
||||
"email": "june.cha@gmail.com",
|
||||
"position": "Administrative Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Iida Niskanen",
|
||||
"email": "iida.niskanen@gmail.com",
|
||||
"position": "Customer Service Representative",
|
||||
},
|
||||
{
|
||||
"name": "Renee Sims",
|
||||
"email": "renee.sims@gmail.com",
|
||||
"position": "Customer Service Representative",
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Nu\u00f1ez",
|
||||
"email": "jonathan.nu\u00f1ez@gmail.com",
|
||||
"position": "Sales",
|
||||
},
|
||||
{
|
||||
"name": "Sasha Ho",
|
||||
"email": "sasha.ho@gmail.com",
|
||||
"position": "Marketing",
|
||||
},
|
||||
{
|
||||
"name": "Abdullah Hadley",
|
||||
"email": "abdullah.hadley@gmail.com",
|
||||
"position": "Marketing",
|
||||
},
|
||||
{
|
||||
"name": "Veeti Seppanen",
|
||||
"email": "veeti.seppanen@gmail.com",
|
||||
"position": "Project Manager",
|
||||
},
|
||||
{
|
||||
"name": "Thomas Stock",
|
||||
"email": "thomas.stock@gmail.com",
|
||||
"position": "Customer Service",
|
||||
},
|
||||
{
|
||||
"name": "Bonnie Riley",
|
||||
"email": "bonnie.riley@gmail.com",
|
||||
"position": "Executive Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Steve T. Scaife",
|
||||
"email": "steve.t..scaife@gmail.com",
|
||||
"position": "Receptionist",
|
||||
},
|
||||
{
|
||||
"name": "Andreas Brixen",
|
||||
"email": "andreas.brixen@gmail.com",
|
||||
"position": "Director",
|
||||
},
|
||||
{
|
||||
"name": "Lilja Peltola",
|
||||
"email": "lilja.peltola@gmail.com",
|
||||
"position": "Sales Manager",
|
||||
},
|
||||
{
|
||||
"name": "Sean PJPGR Doran",
|
||||
"email": "sean.pjpgr.doran@gmail.com",
|
||||
"position": "Lead Developer",
|
||||
},
|
||||
{
|
||||
"name": "Elliana Palacios",
|
||||
"email": "elliana.palacios@gmail.com",
|
||||
"position": "Marketing",
|
||||
},
|
||||
{
|
||||
"name": "Eduard Franz",
|
||||
"email": "eduard.franz@gmail.com",
|
||||
"position": "Manager",
|
||||
},
|
||||
{
|
||||
"name": "Leah Stevens",
|
||||
"email": "leah.stevens@gmail.com",
|
||||
"position": "Attorney",
|
||||
},
|
||||
{
|
||||
"name": "Britney Cooper",
|
||||
"email": "britney.cooper@gmail.com",
|
||||
"position": "Data Entry Clerk",
|
||||
},
|
||||
{
|
||||
"name": "Chrishell Stause",
|
||||
"email": "chrishell.stause@gmail.com",
|
||||
"position": "Receptionist",
|
||||
},
|
||||
{
|
||||
"name": "Ana De Armas",
|
||||
"email": "ana.de.armas@gmail.com",
|
||||
"position": "Administrative Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Jennifer Fritz",
|
||||
"email": "jennifer.fritz@gmail.com",
|
||||
"position": "Graphic Designer",
|
||||
},
|
||||
{
|
||||
"name": "Wyatt Morris",
|
||||
"email": "wyatt.morris@gmail.com",
|
||||
"position": "Executive Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Lourdes Browning",
|
||||
"email": "lourdes.browning@gmail.com",
|
||||
"position": "Sales",
|
||||
},
|
||||
{
|
||||
"name": "Tim Schoch",
|
||||
"email": "tim.schoch@gmail.com",
|
||||
"position": "Product Designer",
|
||||
},
|
||||
{
|
||||
"name": "Nykyta Korotkevych",
|
||||
"email": "nykyta.korotkevych@gmail.com",
|
||||
"position": "Lead Developer",
|
||||
},
|
||||
{
|
||||
"name": "Carys Metz",
|
||||
"email": "carys.metz@gmail.com",
|
||||
"position": "Administrative Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Loki Bright",
|
||||
"email": "loki.bright@gmail.com",
|
||||
"position": "Data Entry",
|
||||
},
|
||||
{
|
||||
"name": "Ferdinand Karl",
|
||||
"email": "ferdinand.karl@gmail.com",
|
||||
"position": "Medical Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Andrew Kumar",
|
||||
"email": "andrew.kumar@gmail.com",
|
||||
"position": "Accounting",
|
||||
},
|
||||
{
|
||||
"name": "Mario Palmer",
|
||||
"email": "mario.palmer@gmail.com",
|
||||
"position": "Attorney",
|
||||
},
|
||||
{
|
||||
"name": "Zechariah Burrell",
|
||||
"email": "zechariah.burrell@gmail.com",
|
||||
"position": "Part Time",
|
||||
},
|
||||
{
|
||||
"name": "Lucr\u00e9cia Caldeira",
|
||||
"email": "lucr\u00e9cia.caldeira@gmail.com",
|
||||
"position": "Human Resources",
|
||||
},
|
||||
{
|
||||
"name": "Love Grayson",
|
||||
"email": "love.grayson@gmail.com",
|
||||
"position": "Office Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Elizabeth Olsen",
|
||||
"email": "elizabeth.olsen@gmail.com",
|
||||
"position": "Accounting",
|
||||
},
|
||||
{
|
||||
"name": "Layton Diament",
|
||||
"email": "layton.diament@gmail.com",
|
||||
"position": "Receptionist",
|
||||
},
|
||||
{
|
||||
"name": "Sophie French",
|
||||
"email": "sophie.french@gmail.com",
|
||||
"position": "Medical Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Mia Denys",
|
||||
"email": "mia.denys@gmail.com",
|
||||
"position": "Data Entry Clerk",
|
||||
},
|
||||
{
|
||||
"name": "Christine M. Maldonado",
|
||||
"email": "christine.m..maldonado@gmail.com",
|
||||
"position": "Director",
|
||||
},
|
||||
{
|
||||
"name": "Line Rolland",
|
||||
"email": "line.rolland@gmail.com",
|
||||
"position": "Project Manager",
|
||||
},
|
||||
{
|
||||
"name": "Micheal Murphy",
|
||||
"email": "micheal.murphy@gmail.com",
|
||||
"position": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"name": "Jacob Ginnish",
|
||||
"email": "jacob.ginnish@gmail.com",
|
||||
"position": "Sales",
|
||||
},
|
||||
{
|
||||
"name": "Erwan Gauthier",
|
||||
"email": "erwan.gauthier@gmail.com",
|
||||
"position": "Marketing",
|
||||
},
|
||||
{
|
||||
"name": "Derrick Wells",
|
||||
"email": "derrick.wells@gmail.com",
|
||||
"position": "Office Assistant",
|
||||
},
|
||||
{
|
||||
"name": "Emre Topalo\u011flu",
|
||||
"email": "emre.topalo\u011flu@gmail.com",
|
||||
"position": "Project Manager",
|
||||
},
|
||||
{
|
||||
"name": "Lucy Walker",
|
||||
"email": "lucy.walker@gmail.com",
|
||||
"position": "Business Analyst",
|
||||
},
|
||||
{
|
||||
"name": "Ece Akman",
|
||||
"email": "ece.akman@gmail.com",
|
||||
"position": "Accounting",
|
||||
},
|
||||
{
|
||||
"name": "Sophie Louise Hart",
|
||||
"email": "sophie.louise.hart@gmail.com",
|
||||
"position": "Attorney",
|
||||
},
|
||||
{
|
||||
"name": "Carmen Velasco",
|
||||
"email": "carmen.velasco@gmail.com",
|
||||
"position": "Executive Assistant",
|
||||
}
|
||||
]
|
||||
|
||||
const list = document.querySelector('#list');
|
||||
|
||||
const addFavorite = (p) => {
|
||||
const item = document.createElement('ion-item');
|
||||
item.innerHTML = `
|
||||
<ion-avatar slot="start"><ion-icon name="person"></ion-icon></ion-avatar>
|
||||
<ion-label>
|
||||
<h2>${p.name}</h2>
|
||||
<h3>${p.position}</h3>
|
||||
</ion-label>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
people.forEach(p => addFavorite(p));
|
||||
|
||||
function handleAddFavorite (email) {
|
||||
const modalController = document.querySelector('ion-modal-controller');
|
||||
modalController.dismiss(email);
|
||||
}
|
||||
|
||||
async function createModal(presentingEl) {
|
||||
// initialize controller
|
||||
const modalController = document.querySelector('ion-modal-controller');
|
||||
await modalController.componentOnReady();
|
||||
|
||||
const contactGroups = allContacts
|
||||
.sort((a, b) => {
|
||||
const aSplit = a.name.split(' ');
|
||||
const bSplit = b.name.split(' ');
|
||||
return aSplit[1].localeCompare(bSplit[1]);
|
||||
}).reduce((groups, contact) => {
|
||||
const firstLast = contact.name.split(' ')[1].charAt(0);
|
||||
|
||||
if (!groups.hasOwnProperty(firstLast)) {
|
||||
groups[firstLast] = [];
|
||||
}
|
||||
groups[firstLast].push(contact);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
const sortedGroups = Object.keys(contactGroups)
|
||||
.map(k => { return { letter: k, contacts: contactGroups[k] }})
|
||||
.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||
|
||||
const items = new Array(20).fill(0).map((item, i) => {
|
||||
return {
|
||||
title: `Person ${i}`
|
||||
}
|
||||
});
|
||||
|
||||
// create component to open
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = `
|
||||
<ion-header id="modal-header">
|
||||
<ion-note>Choose a contact to add to Favorites</ion-note>
|
||||
<ion-toolbar>
|
||||
<ion-title>Contacts</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button class="add">
|
||||
<ion-icon name="add" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
${sortedGroups.map(group => {
|
||||
return `<ion-item-divider sticky="true">
|
||||
<ion-label>
|
||||
${group.letter}
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
${group.contacts.map(item => `<ion-item onclick="handleAddFavorite('${item.email}')"><ion-label>${item.name}</ion-label></ion-item>`).join('')}
|
||||
`;
|
||||
}).join('')}
|
||||
</ion-list>
|
||||
<ion-button class="dismiss">Dismiss Modal</ion-button>
|
||||
</ion-content>
|
||||
`;
|
||||
|
||||
// listen for close event
|
||||
const button = element.querySelector('ion-button.dismiss');
|
||||
button.addEventListener('click', () => {
|
||||
modalController.dismiss();
|
||||
});
|
||||
|
||||
const create = element.querySelector('ion-button.add');
|
||||
create.addEventListener('click', async () => {
|
||||
const topModal = await modalController.getTop();
|
||||
|
||||
presentModal(topModal);
|
||||
});
|
||||
// present the modal
|
||||
const modalElement = await modalController.create({
|
||||
presentingElement: presentingEl,
|
||||
component: element,
|
||||
swipeToClose: true
|
||||
});
|
||||
return modalElement;
|
||||
}
|
||||
|
||||
async function presentModal(presentingEl) {
|
||||
const modal = await createModal(presentingEl);
|
||||
await modal.present();
|
||||
const data = await modal.onWillDismiss();
|
||||
const person = allContacts.find(c => c.email === data.data);
|
||||
person && addFavorite(person);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -129,4 +129,40 @@ import { EventModalModule } from '../modals/event/event.module';
|
||||
})
|
||||
|
||||
export class CalendarComponentModule {}
|
||||
```
|
||||
```
|
||||
|
||||
### Swipeable Modals
|
||||
|
||||
Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped.
|
||||
|
||||
```javascript
|
||||
import { IonRouterOutlet } from '@ionic/angular';
|
||||
|
||||
constructor(private routerOutlet: IonRouterOutlet) {}
|
||||
|
||||
async presentModal() {
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalPage,
|
||||
swipeToClose: true,
|
||||
presentingElement: this.routerOutlet.nativeEl
|
||||
});
|
||||
return await modal.present();
|
||||
}
|
||||
```
|
||||
|
||||
In most scenarios, using the `ion-router-outlet` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` element as the `presentingElement`.
|
||||
|
||||
```javascript
|
||||
import { ModalController } from '@ionic/angular';
|
||||
|
||||
constructor(private modalCtrl: ModalController) {}
|
||||
|
||||
async presentModal() {
|
||||
const modal = await this.modalController.create({
|
||||
component: ModalPage,
|
||||
swipeToClose: true,
|
||||
presentingElement: await this.modalCtrl.getTop() // Get the top-most ion-modal
|
||||
});
|
||||
return await modal.present();
|
||||
}
|
||||
```
|
||||
|
@ -75,4 +75,25 @@ After being dismissed, the data can be read in through the `onWillDismiss` or `o
|
||||
```javascript
|
||||
const { data } = await modalElement.onWillDismiss();
|
||||
console.log(data);
|
||||
```
|
||||
```
|
||||
|
||||
|
||||
### Swipeable Modals
|
||||
|
||||
Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped.
|
||||
|
||||
```javascript
|
||||
const modalElement = document.createElement('ion-modal');
|
||||
modalElement.component = 'modal-page';
|
||||
modalElement.swipeToClose = true;
|
||||
modalElement.presentingElement = document.querySelector('ion-nav');
|
||||
```
|
||||
|
||||
In most scenarios, using the `ion-nav` element as the `presentingElement` is fine. In cases where you are presenting a card-style modal from within a modal, you should pass in the top-most `ion-modal` element as the `presentingElement`.
|
||||
|
||||
```javascript
|
||||
const modalElement = document.createElement('ion-modal');
|
||||
modalElement.component = 'modal-page';
|
||||
modalElement.swipeToClose = true;
|
||||
modalElement.presentingElement = await modalController.getTop(); // Get the top-most ion-modal
|
||||
```
|
||||
|
@ -16,3 +16,40 @@ export const ModalExample: React.FC = () => {
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Swipeable Modals
|
||||
|
||||
Modals in iOS mode have the ability to be presented in a card-style and swiped to close. The card-style presentation and swipe to close gesture are not mutually exclusive, meaning you can pick and choose which features you want to use. For example, you can have a card-style modal that cannot be swiped or a full sized modal that can be swiped.
|
||||
|
||||
```tsx
|
||||
<IonModal
|
||||
isOpen={showModal}
|
||||
swipeToClose={true}
|
||||
presentingElement={pageRef.current}
|
||||
onDidDismiss={() => setShowModal(false)}>
|
||||
<p>This is modal content</p>
|
||||
<IonButton onClick={() => setShowModal(false)}>Close Modal</IonButton>
|
||||
</IonModal>
|
||||
```
|
||||
|
||||
In most scenarios, setting a ref on `IonPage` and passing that ref's `current` value to `presentingElement` is fine. In cases where you are presenting a card-style modal from within another modal, you should pass in the top-most `ion-modal` ref as the `presentingElement`.
|
||||
|
||||
```tsx
|
||||
<IonModal
|
||||
ref={firstModalRef}
|
||||
isOpen={showModal}
|
||||
swipeToClose={true}
|
||||
presentingElement={pageRef.current}
|
||||
onDidDismiss={() => setShowModal(false)}>
|
||||
<p>This is modal content</p>
|
||||
<IonButton onClick={() => setShow2ndModal(true)}>Show 2nd Modal</IonButton>
|
||||
<IonButton onClick={() => setShowModal(false)}>Close Modal</IonButton>
|
||||
</IonModal>
|
||||
<IonModal
|
||||
isOpen={show2ndModal}
|
||||
presentingElement={firstModalRef.current}
|
||||
onDidDismiss={() => setShow2ndModal(false)}>
|
||||
<p>This is more modal content</p>
|
||||
<IonButton onClick={() => setShow2ndModal(false)}>Close Modal</IonButton>
|
||||
</IonModal>
|
||||
```
|
||||
|
@ -986,7 +986,7 @@ export class Nav implements NavOutlet {
|
||||
newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], stepValue)[0];
|
||||
}
|
||||
|
||||
(this.sbAni as Animation).progressEnd(shouldComplete ? 1 : 0, newStepValue, dur);
|
||||
this.sbAni.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, h } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { Animation, AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface, PickerButton, PickerColumn } from '../../interface';
|
||||
import { AnimationBuilder, CssClassMap, OverlayEventDetail, OverlayInterface, PickerButton, PickerColumn } from '../../interface';
|
||||
import { BACKDROP, dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays';
|
||||
import { getClassMap } from '../../utils/theme';
|
||||
|
||||
@ -24,8 +24,6 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
||||
|
||||
mode = getIonMode(this);
|
||||
|
||||
animation?: Animation;
|
||||
|
||||
@Element() el!: HTMLIonPickerElement;
|
||||
|
||||
@State() presented = false;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface';
|
||||
import { AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, OverlayEventDetail, OverlayInterface } from '../../interface';
|
||||
import { attachComponent, detachComponent } from '../../utils/framework-delegate';
|
||||
import { BACKDROP, dismiss, eventMethod, prepareOverlay, present } from '../../utils/overlays';
|
||||
import { getClassMap } from '../../utils/theme';
|
||||
@ -28,7 +28,6 @@ export class Popover implements ComponentInterface, OverlayInterface {
|
||||
private usersElement?: HTMLElement;
|
||||
|
||||
presented = false;
|
||||
animation?: Animation;
|
||||
mode = getIonMode(this);
|
||||
|
||||
@Element() el!: HTMLIonPopoverElement;
|
||||
|
@ -96,7 +96,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {
|
||||
newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], step)[0];
|
||||
}
|
||||
|
||||
(this.ani as Animation).progressEnd(shouldComplete ? 1 : 0, newStepValue, dur);
|
||||
this.ani.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur);
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, h } from '@stencil/core';
|
||||
|
||||
import { getIonMode } from '../../global/ionic-global';
|
||||
import { Animation, AnimationBuilder, Color, CssClassMap, OverlayEventDetail, OverlayInterface, ToastButton } from '../../interface';
|
||||
import { AnimationBuilder, Color, CssClassMap, OverlayEventDetail, OverlayInterface, ToastButton } from '../../interface';
|
||||
import { dismiss, eventMethod, isCancel, prepareOverlay, present, safeCall } from '../../utils/overlays';
|
||||
import { sanitizeDOMString } from '../../utils/sanitization';
|
||||
import { createColorClasses, getClassMap } from '../../utils/theme';
|
||||
@ -27,7 +27,6 @@ export class Toast implements ComponentInterface, OverlayInterface {
|
||||
private durationTimeout: any;
|
||||
|
||||
presented = false;
|
||||
animation?: Animation;
|
||||
mode = getIonMode(this);
|
||||
|
||||
@Element() el!: HTMLIonToastElement;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from '@stencil/core';
|
||||
import { HTMLStencilElement } from '@stencil/core/internal';
|
||||
|
||||
import { Animation, AnimationBuilder, Mode } from '../interface';
|
||||
import { AnimationBuilder, Mode } from '../interface';
|
||||
|
||||
export interface OverlayEventDetail<T = any> {
|
||||
data?: T;
|
||||
@ -15,7 +15,6 @@ export interface OverlayInterface {
|
||||
keyboardClose: boolean;
|
||||
overlayIndex: number;
|
||||
presented: boolean;
|
||||
animation?: Animation;
|
||||
|
||||
enterAnimation?: AnimationBuilder;
|
||||
leaveAnimation?: AnimationBuilder;
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { config } from '../global/config';
|
||||
import { ActionSheetOptions, AlertOptions, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface';
|
||||
import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface';
|
||||
|
||||
let lastId = 0;
|
||||
|
||||
export const activeAnimations = new WeakMap<OverlayInterface, Animation[]>();
|
||||
|
||||
const createController = <Opts extends object, HTMLElm extends any>(tagName: string) => {
|
||||
return {
|
||||
create(options: Opts): Promise<HTMLElm> {
|
||||
@ -140,7 +142,7 @@ export const dismiss = async (
|
||||
data: any | undefined,
|
||||
role: string | undefined,
|
||||
name: keyof IonicConfig,
|
||||
iosLeaveAnimation: AnimationBuilder,
|
||||
iosLeaveAnimation: AnimationBuilder | undefined,
|
||||
mdLeaveAnimation: AnimationBuilder,
|
||||
opts?: any
|
||||
): Promise<boolean> => {
|
||||
@ -156,9 +158,13 @@ export const dismiss = async (
|
||||
? overlay.leaveAnimation
|
||||
: config.get(name, overlay.mode === 'ios' ? iosLeaveAnimation : mdLeaveAnimation);
|
||||
|
||||
await overlayAnimation(overlay, animationBuilder, overlay.el, opts);
|
||||
if (animationBuilder !== undefined) {
|
||||
await overlayAnimation(overlay, animationBuilder, overlay.el, opts);
|
||||
}
|
||||
overlay.didDismiss.emit({ data, role });
|
||||
|
||||
activeAnimations.delete(overlay);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
@ -177,19 +183,12 @@ const overlayAnimation = async (
|
||||
baseEl: any,
|
||||
opts: any
|
||||
): Promise<boolean> => {
|
||||
if (overlay.animation) {
|
||||
overlay.animation.destroy();
|
||||
overlay.animation = undefined;
|
||||
return false;
|
||||
}
|
||||
// Make overlay visible in case it's hidden
|
||||
baseEl.classList.remove('overlay-hidden');
|
||||
|
||||
const aniRoot = baseEl.shadowRoot || overlay.el;
|
||||
|
||||
const animation = animationBuilder(aniRoot, opts);
|
||||
|
||||
overlay.animation = animation;
|
||||
if (!overlay.animated || !config.getBoolean('animated', true)) {
|
||||
animation.duration(0);
|
||||
}
|
||||
@ -203,9 +202,11 @@ const overlayAnimation = async (
|
||||
});
|
||||
}
|
||||
|
||||
const activeAni = activeAnimations.get(overlay) || [];
|
||||
activeAnimations.set(overlay, [...activeAni, animation]);
|
||||
|
||||
await animation.play();
|
||||
|
||||
overlay.animation = undefined;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
@ -256,8 +256,8 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio
|
||||
|
||||
enteringTransitionEffect
|
||||
.addElement(enteringTransitionEffectEl)
|
||||
.beforeStyles({ opacity: '1' })
|
||||
.afterStyles({ opacity: '' });
|
||||
.beforeStyles({ opacity: '1', display: 'block' })
|
||||
.afterStyles({ opacity: '', display: '' });
|
||||
|
||||
enteringTransitionCover
|
||||
.addElement(enteringTransitionCoverEl!) // REVIEW
|
||||
@ -406,8 +406,8 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio
|
||||
|
||||
leavingTransitionEffect
|
||||
.addElement(leavingTransitionEffectEl)
|
||||
.beforeStyles({ opacity: '1' })
|
||||
.afterStyles({ opacity: '' });
|
||||
.beforeStyles({ opacity: '1', display: 'block' })
|
||||
.afterStyles({ opacity: '', display: '' });
|
||||
|
||||
leavingTransitionCover
|
||||
.addElement(leavingTransitionCoverEl!) // REVIEW
|
||||
|
Reference in New Issue
Block a user