feat(modal): ability to programmatically set current sheet breakpoint (#24648)

Resolves #23917

Co-authored-by: Sean Perkins <sean@ionic.io>
This commit is contained in:
Hans Krywalsky
2022-03-21 23:05:25 +01:00
committed by GitHub
parent 2a438da010
commit 3145c76934
15 changed files with 463 additions and 125 deletions

View File

@ -11,7 +11,7 @@ import {
TemplateRef, TemplateRef,
} from '@angular/core'; } from '@angular/core';
import { ProxyCmp, proxyOutputs } from '../angular-component-lib/utils'; import { ProxyCmp, proxyOutputs } from '../angular-component-lib/utils';
import { Components } from '@ionic/core'; import { Components, ModalBreakpointChangeEventDetail } from '@ionic/core';
export declare interface IonModal extends Components.IonModal { export declare interface IonModal extends Components.IonModal {
/** /**
@ -30,6 +30,10 @@ export declare interface IonModal extends Components.IonModal {
* Emitted after the modal has dismissed. * Emitted after the modal has dismissed.
*/ */
ionModalDidDismiss: EventEmitter<CustomEvent>; ionModalDidDismiss: EventEmitter<CustomEvent>;
/**
* Emitted after the modal breakpoint has changed.
*/
ionBreakpointDidChange: EventEmitter<CustomEvent<ModalBreakpointChangeEventDetail>>;
/** /**
* Emitted after the modal has presented. Shorthand for ionModalWillDismiss. * Emitted after the modal has presented. Shorthand for ionModalWillDismiss.
*/ */
@ -68,7 +72,7 @@ export declare interface IonModal extends Components.IonModal {
'translucent', 'translucent',
'trigger', 'trigger',
], ],
methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss'], methods: ['present', 'dismiss', 'onDidDismiss', 'onWillDismiss', 'setCurrentBreakpoint', 'getCurrentBreakpoint'],
}) })
@Component({ @Component({
selector: 'ion-modal', selector: 'ion-modal',
@ -119,6 +123,7 @@ export class IonModal {
'ionModalWillPresent', 'ionModalWillPresent',
'ionModalWillDismiss', 'ionModalWillDismiss',
'ionModalDidDismiss', 'ionModalDidDismiss',
'ionBreakpointDidChange',
'didPresent', 'didPresent',
'willPresent', 'willPresent',
'willDismiss', 'willDismiss',

View File

@ -74,5 +74,20 @@ describe('Modals: Inline', () => {
cy.get('ion-modal').trigger('click', 20, 20); cy.get('ion-modal').trigger('click', 20, 20);
cy.get('ion-modal').children('.ion-page').should('not.exist'); cy.get('ion-modal').children('.ion-page').should('not.exist');
}) });
describe('setting the current breakpoint', () => {
it('should emit ionBreakpointDidChange', () => {
cy.get('#open-modal').click();
cy.get('ion-modal').then(modal => {
(modal.get(0) as any).setCurrentBreakpoint(1);
});
cy.get('#breakpointDidChange').should('have.text', '1');
});
});
}); });

View File

@ -1,6 +1,13 @@
<ion-button id="open-modal">Open Modal</ion-button> <ion-button id="open-modal">Open Modal</ion-button>
<ion-modal [animated]="false" trigger="open-modal" [breakpoints]="[0.1, 0.5, 1]" [initialBreakpoint]="0.5"> <ul>
<li>
breakpointDidChange event count: <span id="breakpointDidChange">{{ breakpointDidChangeCounter }}</span>
</li>
</ul>
<ion-modal [animated]="false" trigger="open-modal" [breakpoints]="[0.1, 0.5, 1]" [initialBreakpoint]="0.5"
(ionBreakpointDidChange)="onBreakpointDidChange()">
<ng-template> <ng-template>
<ion-content> <ion-content>
<ion-list> <ion-list>

View File

@ -13,9 +13,15 @@ export class ModalInlineComponent implements AfterViewInit {
items: string[] = []; items: string[] = [];
breakpointDidChangeCounter = 0;
ngAfterViewInit(): void { ngAfterViewInit(): void {
setTimeout(() => { setTimeout(() => {
this.items = ['A', 'B', 'C', 'D']; this.items = ['A', 'B', 'C', 'D'];
}, 1000); }, 1000);
} }
onBreakpointDidChange() {
this.breakpointDidChangeCounter++;
}
} }

View File

@ -783,11 +783,14 @@ ion-modal,prop,showBackdrop,boolean,true,false,false
ion-modal,prop,swipeToClose,boolean,false,false,false ion-modal,prop,swipeToClose,boolean,false,false,false
ion-modal,prop,trigger,string | undefined,undefined,false,false ion-modal,prop,trigger,string | undefined,undefined,false,false
ion-modal,method,dismiss,dismiss(data?: any, role?: string | undefined) => Promise<boolean> ion-modal,method,dismiss,dismiss(data?: any, role?: string | undefined) => Promise<boolean>
ion-modal,method,getCurrentBreakpoint,getCurrentBreakpoint() => Promise<number | undefined>
ion-modal,method,onDidDismiss,onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>> ion-modal,method,onDidDismiss,onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-modal,method,onWillDismiss,onWillDismiss<T = any>() => Promise<OverlayEventDetail<T>> ion-modal,method,onWillDismiss,onWillDismiss<T = any>() => Promise<OverlayEventDetail<T>>
ion-modal,method,present,present() => Promise<void> ion-modal,method,present,present() => Promise<void>
ion-modal,method,setCurrentBreakpoint,setCurrentBreakpoint(breakpoint: number) => Promise<void>
ion-modal,event,didDismiss,OverlayEventDetail<any>,true ion-modal,event,didDismiss,OverlayEventDetail<any>,true
ion-modal,event,didPresent,void,true ion-modal,event,didPresent,void,true
ion-modal,event,ionBreakpointDidChange,ModalBreakpointChangeEventDetail,true
ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true ion-modal,event,ionModalDidDismiss,OverlayEventDetail<any>,true
ion-modal,event,ionModalDidPresent,void,true ion-modal,event,ionModalDidPresent,void,true
ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true ion-modal,event,ionModalWillDismiss,OverlayEventDetail<any>,true

View File

@ -5,7 +5,7 @@
* It contains typing information for all components that exist in this project. * It contains typing information for all components that exist in this project.
*/ */
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
import { AccordionGroupChangeEventDetail, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface"; import { AccordionGroupChangeEventDetail, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, ModalBreakpointChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface";
import { IonicSafeString } from "./utils/sanitization"; import { IonicSafeString } from "./utils/sanitization";
import { AlertAttributes } from "./components/alert/alert-interface"; import { AlertAttributes } from "./components/alert/alert-interface";
import { CounterFormatter } from "./components/item/item-interface"; import { CounterFormatter } from "./components/item/item-interface";
@ -1541,6 +1541,10 @@ export namespace Components {
* Animation to use when the modal is presented. * Animation to use when the modal is presented.
*/ */
"enterAnimation"?: AnimationBuilder; "enterAnimation"?: AnimationBuilder;
/**
* Returns the current breakpoint of a sheet style modal
*/
"getCurrentBreakpoint": () => Promise<number | undefined>;
/** /**
* The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. * The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties.
*/ */
@ -1587,6 +1591,10 @@ export namespace Components {
* 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. * 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; "presentingElement"?: HTMLElement;
/**
* Move a sheet style modal to a specific breakpoint. The breakpoint value must be a value defined in your `breakpoints` array.
*/
"setCurrentBreakpoint": (breakpoint: number) => Promise<void>;
/** /**
* If `true`, a backdrop will be displayed behind the modal. * If `true`, a backdrop will be displayed behind the modal.
*/ */
@ -5294,6 +5302,10 @@ declare namespace LocalJSX {
* Emitted after the modal has presented. Shorthand for ionModalWillDismiss. * Emitted after the modal has presented. Shorthand for ionModalWillDismiss.
*/ */
"onDidPresent"?: (event: CustomEvent<void>) => void; "onDidPresent"?: (event: CustomEvent<void>) => void;
/**
* Emitted after the modal breakpoint has changed.
*/
"onIonBreakpointDidChange"?: (event: CustomEvent<ModalBreakpointChangeEventDetail>) => void;
/** /**
* Emitted after the modal has dismissed. * Emitted after the modal has dismissed.
*/ */

View File

@ -5,6 +5,32 @@ import { getBackdropValueForSheet } from '../utils';
import { calculateSpringStep, handleCanDismiss } from './utils'; import { calculateSpringStep, handleCanDismiss } from './utils';
export interface MoveSheetToBreakpointOptions {
/**
* The breakpoint value to move the sheet to.
*/
breakpoint: number;
/**
* The offset value between the current breakpoint and the new breakpoint.
*
* For breakpoint changes as a result of a touch gesture, this value
* will be calculated internally.
*
* For breakpoint changes as a result of dynamically setting the value,
* this value should be the difference between the new and old breakpoint.
* For example:
* - breakpoints: [0, 0.25, 0.5, 0.75, 1]
* - Current breakpoint value is 1.
* - Setting the breakpoint to 0.25.
* - The offset value should be 0.75 (1 - 0.25).
*/
breakpointOffset: number;
/**
* `true` if the sheet can be transitioned and dismissed off the view.
*/
canDismiss?: boolean;
}
export const createSheetGesture = ( export const createSheetGesture = (
baseEl: HTMLIonModalElement, baseEl: HTMLIonModalElement,
backdropEl: HTMLIonBackdropElement, backdropEl: HTMLIonBackdropElement,
@ -13,6 +39,7 @@ export const createSheetGesture = (
backdropBreakpoint: number, backdropBreakpoint: number,
animation: Animation, animation: Animation,
breakpoints: number[] = [], breakpoints: number[] = [],
getCurrentBreakpoint: () => number,
onDismiss: () => void, onDismiss: () => void,
onBreakpointChange: (breakpoint: number) => void onBreakpointChange: (breakpoint: number) => void
) => { ) => {
@ -113,6 +140,7 @@ export const createSheetGesture = (
* allow for scrolling on the content. * allow for scrolling on the content.
*/ */
const content = (detail.event.target! as HTMLElement).closest('ion-content'); const content = (detail.event.target! as HTMLElement).closest('ion-content');
currentBreakpoint = getCurrentBreakpoint();
if (currentBreakpoint === 1 && content) { if (currentBreakpoint === 1 && content) {
return false; return false;
@ -206,30 +234,39 @@ export const createSheetGesture = (
return Math.abs(b - diff) < Math.abs(a - diff) ? b : a; return Math.abs(b - diff) < Math.abs(a - diff) ? b : a;
}); });
moveSheetToBreakpoint({
breakpoint: closest,
breakpointOffset: offset,
canDismiss: canDismissBlocksGesture,
});
};
const moveSheetToBreakpoint = (options: MoveSheetToBreakpointOptions) => {
const { breakpoint, canDismiss, breakpointOffset } = options;
/** /**
* canDismiss should only prevent snapping * canDismiss should only prevent snapping
* when users are trying to dismiss. If canDismiss * when users are trying to dismiss. If canDismiss
* is present but the user is trying to swipe upwards, * is present but the user is trying to swipe upwards,
* we should allow that to happen, * we should allow that to happen,
*/ */
const shouldPreventDismiss = canDismissBlocksGesture && closest === 0; const shouldPreventDismiss = canDismiss && breakpoint === 0;
const snapToBreakpoint = shouldPreventDismiss ? currentBreakpoint : closest; const snapToBreakpoint = shouldPreventDismiss ? currentBreakpoint : breakpoint;
const shouldRemainOpen = snapToBreakpoint !== 0; const shouldRemainOpen = snapToBreakpoint !== 0;
currentBreakpoint = 0;
currentBreakpoint = 0;
/** /**
* Update the animation so that it plays from * Update the animation so that it plays from
* the last offset to the closest snap point. * the last offset to the closest snap point.
*/ */
if (wrapperAnimation && backdropAnimation) { if (wrapperAnimation && backdropAnimation) {
wrapperAnimation.keyframes([ wrapperAnimation.keyframes([
{ offset: 0, transform: `translateY(${offset * 100}%)` }, { offset: 0, transform: `translateY(${breakpointOffset * 100}%)` },
{ offset: 1, transform: `translateY(${(1 - snapToBreakpoint) * 100}%)` } { offset: 1, transform: `translateY(${(1 - snapToBreakpoint) * 100}%)` }
]); ]);
backdropAnimation.keyframes([ backdropAnimation.keyframes([
{ offset: 0, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(1 - offset, backdropBreakpoint)})` }, { offset: 0, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(1 - breakpointOffset, backdropBreakpoint)})` },
{ offset: 1, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(snapToBreakpoint, backdropBreakpoint)})` } { offset: 1, opacity: `calc(var(--backdrop-opacity) * ${getBackdropValueForSheet(snapToBreakpoint, backdropBreakpoint)})` }
]); ]);
@ -313,5 +350,9 @@ export const createSheetGesture = (
onMove, onMove,
onEnd onEnd
}); });
return gesture;
return {
gesture,
moveSheetToBreakpoint
};
}; };

View File

@ -34,3 +34,11 @@ export interface ModalAnimationOptions {
} }
export interface ModalAttributes extends JSXBase.HTMLAttributes<HTMLElement> { } export interface ModalAttributes extends JSXBase.HTMLAttributes<HTMLElement> { }
export interface ModalBreakpointChangeEventDetail {
breakpoint: number;
}
export interface ModalCustomEvent extends CustomEvent {
target: HTMLIonModalElement;
}

View File

@ -3,7 +3,7 @@ import { printIonWarning } from '@utils/logging';
import { config } from '../../global/config'; import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global'; import { getIonMode } from '../../global/ionic-global';
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, ModalAttributes, OverlayEventDetail, OverlayInterface } from '../../interface'; import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, ModalAttributes, ModalBreakpointChangeEventDetail, OverlayEventDetail, OverlayInterface } from '../../interface';
import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate'; import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate';
import { raf } from '../../utils/helpers'; import { raf } from '../../utils/helpers';
import { KEYBOARD_DID_OPEN } from '../../utils/keyboard/keyboard'; import { KEYBOARD_DID_OPEN } from '../../utils/keyboard/keyboard';
@ -15,7 +15,7 @@ import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave'; import { iosLeaveAnimation } from './animations/ios.leave';
import { mdEnterAnimation } from './animations/md.enter'; import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave'; import { mdLeaveAnimation } from './animations/md.leave';
import { createSheetGesture } from './gestures/sheet'; import { MoveSheetToBreakpointOptions, createSheetGesture } from './gestures/sheet';
import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; import { createSwipeToCloseGesture } from './gestures/swipe-to-close';
/** /**
@ -46,7 +46,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
private currentBreakpoint?: number; private currentBreakpoint?: number;
private wrapperEl?: HTMLElement; private wrapperEl?: HTMLElement;
private backdropEl?: HTMLIonBackdropElement; private backdropEl?: HTMLIonBackdropElement;
private sortedBreakpoints?: number[];
private keyboardOpenCallback?: () => void; private keyboardOpenCallback?: () => void;
private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => void;
private inline = false; private inline = false;
private workingDelegate?: FrameworkDelegate; private workingDelegate?: FrameworkDelegate;
@ -56,6 +58,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Whether or not modal is being dismissed via gesture // Whether or not modal is being dismissed via gesture
private gestureAnimationDismissing = false; private gestureAnimationDismissing = false;
lastFocus?: HTMLElement; lastFocus?: HTMLElement;
animation?: Animation; animation?: Animation;
@ -237,6 +240,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/ */
@Event({ eventName: 'ionModalDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>; @Event({ eventName: 'ionModalDidDismiss' }) didDismiss!: EventEmitter<OverlayEventDetail>;
/**
* Emitted after the modal breakpoint has changed.
*/
@Event() ionBreakpointDidChange!: EventEmitter<ModalBreakpointChangeEventDetail>;
/** /**
* Emitted after the modal has presented. * Emitted after the modal has presented.
* Shorthand for ionModalWillDismiss. * Shorthand for ionModalWillDismiss.
@ -270,6 +278,12 @@ export class Modal implements ComponentInterface, OverlayInterface {
} }
} }
breakpointsChanged(breakpoints: number[] | undefined) {
if (breakpoints !== undefined) {
this.sortedBreakpoints = breakpoints.sort((a, b) => a - b);
}
}
connectedCallback() { connectedCallback() {
prepareOverlay(this.el); prepareOverlay(this.el);
} }
@ -282,7 +296,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
* not assign the default incrementing ID. * not assign the default incrementing ID.
*/ */
this.modalId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id')! : `ion-modal-${this.modalIndex}`; this.modalId = (this.el.hasAttribute('id')) ? this.el.getAttribute('id')! : `ion-modal-${this.modalIndex}`;
this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined; const isSheetModal = this.isSheetModal = breakpoints !== undefined && initialBreakpoint !== undefined;
if (isSheetModal) {
this.currentBreakpoint = this.initialBreakpoint;
}
if (breakpoints !== undefined && initialBreakpoint !== undefined && !breakpoints.includes(initialBreakpoint)) { if (breakpoints !== undefined && initialBreakpoint !== undefined && !breakpoints.includes(initialBreakpoint)) {
printIonWarning('Your breakpoints array must include the initialBreakpoint value.') printIonWarning('Your breakpoints array must include the initialBreakpoint value.')
@ -301,7 +319,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.isOpen === true) { if (this.isOpen === true) {
raf(() => this.present()); raf(() => this.present());
} }
this.breakpointsChanged(this.breakpoints);
this.configureTriggerInteraction(); this.configureTriggerInteraction();
} }
@ -508,17 +526,31 @@ export class Modal implements ComponentInterface, OverlayInterface {
ani.progressStart(true, 1); ani.progressStart(true, 1);
const sortedBreakpoints = (this.breakpoints?.sort((a, b) => a - b)) || []; const { gesture, moveSheetToBreakpoint } = createSheetGesture(
this.gesture = createSheetGesture(
this.el, this.el,
this.backdropEl!, this.backdropEl!,
wrapperEl, wrapperEl,
initialBreakpoint, initialBreakpoint,
backdropBreakpoint, backdropBreakpoint,
ani, ani,
sortedBreakpoints, this.sortedBreakpoints,
() => { () => this.currentBreakpoint ?? 0,
() => this.sheetOnDismiss(),
(breakpoint: number) => {
if (this.currentBreakpoint !== breakpoint) {
this.currentBreakpoint = breakpoint;
this.ionBreakpointDidChange.emit({ breakpoint });
}
}
);
this.gesture = gesture;
this.moveSheetToBreakpoint = moveSheetToBreakpoint;
this.gesture.enable(true);
}
private sheetOnDismiss() {
/** /**
* While the gesture animation is finishing * While the gesture animation is finishing
* it is possible for a user to tap the backdrop. * it is possible for a user to tap the backdrop.
@ -531,15 +563,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
*/ */
this.gestureAnimationDismissing = true; this.gestureAnimationDismissing = true;
this.animation!.onFinish(async () => { this.animation!.onFinish(async () => {
this.currentBreakpoint = 0;
this.ionBreakpointDidChange.emit({ breakpoint: this.currentBreakpoint });
await this.dismiss(undefined, 'gesture'); await this.dismiss(undefined, 'gesture');
this.gestureAnimationDismissing = false; this.gestureAnimationDismissing = false;
}); });
},
(breakpoint: number) => {
this.currentBreakpoint = breakpoint;
}
);
this.gesture.enable(true);
} }
/** /**
@ -623,6 +651,44 @@ export class Modal implements ComponentInterface, OverlayInterface {
return eventMethod(this.el, 'ionModalWillDismiss'); return eventMethod(this.el, 'ionModalWillDismiss');
} }
/**
* Move a sheet style modal to a specific breakpoint. The breakpoint value must
* be a value defined in your `breakpoints` array.
*/
@Method()
async setCurrentBreakpoint(breakpoint: number): Promise<void> {
if (!this.isSheetModal) {
printIonWarning('setCurrentBreakpoint is only supported on sheet modals.');
return;
}
if (!this.breakpoints!.includes(breakpoint)) {
printIonWarning(`Attempted to set invalid breakpoint value ${breakpoint}. Please double check that the breakpoint value is part of your defined breakpoints.`);
return;
}
const { currentBreakpoint, moveSheetToBreakpoint, canDismiss, breakpoints } = this;
if (currentBreakpoint === breakpoint) {
return;
}
if (moveSheetToBreakpoint) {
moveSheetToBreakpoint({
breakpoint,
breakpointOffset: 1 - currentBreakpoint!,
canDismiss: canDismiss !== undefined && canDismiss !== true && breakpoints![0] === 0,
});
}
}
/**
* Returns the current breakpoint of a sheet style modal
*/
@Method()
async getCurrentBreakpoint(): Promise<number | undefined> {
return this.currentBreakpoint;
}
private onBackdropTap = () => { private onBackdropTap = () => {
this.dismiss(undefined, BACKDROP); this.dismiss(undefined, BACKDROP);
} }

View File

@ -54,7 +54,7 @@ Developers can create a sheet modal effect similar to the drawer components avai
The `breakpoints` property accepts an array which states each breakpoint that the sheet can snap to when swiped. A `breakpoints` property of `[0, 0.5, 1]` would indicate that the sheet can be swiped to show 0% of the modal, 50% of the modal, and 100% of the modal. When the modal is swiped to 0%, the modal will be automatically dismissed. The `breakpoints` property accepts an array which states each breakpoint that the sheet can snap to when swiped. A `breakpoints` property of `[0, 0.5, 1]` would indicate that the sheet can be swiped to show 0% of the modal, 50% of the modal, and 100% of the modal. When the modal is swiped to 0%, the modal will be automatically dismissed.
The `initialBreakpoint` property is required so that the sheet modal knows which breakpoint to start at when presenting. The `initalBreakpoint` value must also exist in the `breakpoints` array. Given a `breakpoints` value of `[0, 0.5, 1]`, an `initialBreakpoint` value of `0.5` would be valid as `0.5` is in the `breakpoints` array. An `initialBreakpoint` value of `0.25` would not be valid as `0.25` does not exist in the `breakpoints` array. The `initialBreakpoint` property is required so that the sheet modal knows which breakpoint to start at when presenting. The `initialBreakpoint` value must also exist in the `breakpoints` array. Given a `breakpoints` value of `[0, 0.5, 1]`, an `initialBreakpoint` value of `0.5` would be valid as `0.5` is in the `breakpoints` array. An `initialBreakpoint` value of `0.25` would not be valid as `0.25` does not exist in the `breakpoints` array.
The `backdropBreakpoint` property can be used to customize the point at which the `ion-backdrop` will begin to fade in. This is useful when creating interfaces that have content underneath the sheet that should remain interactive. A common use case is a sheet modal that overlays a map where the map is interactive until the sheet is fully expanded. The `backdropBreakpoint` property can be used to customize the point at which the `ion-backdrop` will begin to fade in. This is useful when creating interfaces that have content underneath the sheet that should remain interactive. A common use case is a sheet modal that overlays a map where the map is interactive until the sheet is fully expanded.
@ -86,6 +86,8 @@ Note that setting a callback function will cause the swipe gesture to be interru
## Interfaces ## Interfaces
### ModalOptions
Below you will find all of the options available to you when using the `modalController`. These options should be supplied when calling `modalController.create()`. Below you will find all of the options available to you when using the `modalController`. These options should be supplied when calling `modalController.create()`.
```typescript ```typescript
@ -107,6 +109,15 @@ interface ModalOptions {
leaveAnimation?: AnimationBuilder; leaveAnimation?: AnimationBuilder;
} }
``` ```
### ModalCustomEvent
While not required, this interface can be used in place of the `CustomEvent` interface for stronger typing with Ionic events emitted from this component.
```typescript
interface ModalCustomEvent extends CustomEvent {
target: HTMLIonModalElement;
}
```
## Dismissing ## Dismissing
@ -1581,9 +1592,10 @@ export default {
## Events ## Events
| Event | Description | Type | | Event | Description | Type |
| --------------------- | -------------------------------------------------------------------------- | -------------------------------------- | | ------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------- |
| `didDismiss` | Emitted after the modal has dismissed. Shorthand for ionModalDidDismiss. | `CustomEvent<OverlayEventDetail<any>>` | | `didDismiss` | Emitted after the modal has dismissed. Shorthand for ionModalDidDismiss. | `CustomEvent<OverlayEventDetail<any>>` |
| `didPresent` | Emitted after the modal has presented. Shorthand for ionModalWillDismiss. | `CustomEvent<void>` | | `didPresent` | Emitted after the modal has presented. Shorthand for ionModalWillDismiss. | `CustomEvent<void>` |
| `ionBreakpointDidChange` | Emitted after the modal breakpoint has changed. | `CustomEvent<ModalBreakpointChangeEventDetail>` |
| `ionModalDidDismiss` | Emitted after the modal has dismissed. | `CustomEvent<OverlayEventDetail<any>>` | | `ionModalDidDismiss` | Emitted after the modal has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
| `ionModalDidPresent` | Emitted after the modal has presented. | `CustomEvent<void>` | | `ionModalDidPresent` | Emitted after the modal has presented. | `CustomEvent<void>` |
| `ionModalWillDismiss` | Emitted before the modal has dismissed. | `CustomEvent<OverlayEventDetail<any>>` | | `ionModalWillDismiss` | Emitted before the modal has dismissed. | `CustomEvent<OverlayEventDetail<any>>` |
@ -1604,6 +1616,16 @@ Type: `Promise<boolean>`
### `getCurrentBreakpoint() => Promise<number | undefined>`
Returns the current breakpoint of a sheet style modal
#### Returns
Type: `Promise<number | undefined>`
### `onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>>` ### `onDidDismiss<T = any>() => Promise<OverlayEventDetail<T>>`
Returns a promise that resolves when the modal did dismiss. Returns a promise that resolves when the modal did dismiss.
@ -1634,6 +1656,17 @@ Type: `Promise<void>`
### `setCurrentBreakpoint(breakpoint: number) => Promise<void>`
Move a sheet style modal to a specific breakpoint. The breakpoint value must
be a value defined in your `breakpoints` array.
#### Returns
Type: `Promise<void>`
## Slots ## Slots

View File

@ -1,4 +1,4 @@
import { testModal } from '../test.utils'; import { openModal, testModal } from '../test.utils';
import { newE2EPage } from '@stencil/core/testing'; import { newE2EPage } from '@stencil/core/testing';
const DIRECTORY = 'basic'; const DIRECTORY = 'basic';
@ -96,3 +96,31 @@ test('it should dismiss the modal when clicking the backdrop', async () => {
await page.mouse.click(20, 20); await page.mouse.click(20, 20);
await ionModalDidDismiss.next(); await ionModalDidDismiss.next();
}) })
test('modal: setting the breakpoint should warn the developer', async () => {
const page = await newE2EPage({ url: '/src/components/modal/test/basic?ionic:_testing=true' });
const warnings = [];
page.on('console', (ev) => {
if (ev.type() === 'warning') {
warnings.push(ev.text());
}
});
const modal = await openModal(page, '#basic-modal');
await modal.callMethod('setCurrentBreakpoint', 0.5);
expect(warnings.length).toBe(1);
expect(warnings[0]).toBe('[Ionic Warning]: setCurrentBreakpoint is only supported on sheet modals.');
});
test('modal: getting the breakpoint should return undefined', async () => {
const page = await newE2EPage({ url: '/src/components/modal/test/basic?ionic:_testing=true' });
const modal = await openModal(page, '#basic-modal');
const breakpoint = await modal.callMethod('getCurrentBreakpoint');
expect(breakpoint).toBeUndefined();
})

View File

@ -1,6 +1,6 @@
import { newE2EPage } from '@stencil/core/testing'; import { E2EElement, E2EPage, newE2EPage } from '@stencil/core/testing';
import { testModal } from '../test.utils'; import { openModal, testModal } from '../test.utils';
import { getActiveElement, getActiveElementParent } from '@utils/test'; import { getActiveElement, getActiveElementParent, dragElementBy } from '@utils/test';
const DIRECTORY = 'sheet'; const DIRECTORY = 'sheet';
@ -117,3 +117,95 @@ test('input should not be focusable when backdrop is active', async () => {
const parentEl = await getActiveElement(page); const parentEl = await getActiveElement(page);
expect(parentEl.tagName).toEqual('ION-BUTTON'); expect(parentEl.tagName).toEqual('ION-BUTTON');
}); });
describe('modal: sheet: setting the breakpoint', () => {
let page: E2EPage;
beforeEach(async () => {
page = await newE2EPage({
url: '/src/components/modal/test/sheet?ionic:_testing=true'
});
});
describe('setting an invalid value', () => {
let warnings: string[];
let modal: E2EElement;
beforeEach(async () => {
warnings = [];
page.on('console', (ev) => {
if (ev.type() === 'warning') {
warnings.push(ev.text());
}
});
modal = await openModal(page, '#sheet-modal');
await modal.callMethod('setCurrentBreakpoint', 0.01);
});
it('should not change the breakpoint', async () => {
const breakpoint = await modal.callMethod('getCurrentBreakpoint');
expect(breakpoint).toBe(0.25);
});
it('should console a warning to developers', async () => {
expect(warnings.length).toBe(1);
expect(warnings[0]).toBe('[Ionic Warning]: Attempted to set invalid breakpoint value 0.01. Please double check that the breakpoint value is part of your defined breakpoints.');
});
});
describe('setting the breakpoint to a valid value', () => {
it('should update the current breakpoint', async () => {
const modal = await openModal(page, '#sheet-modal');
await modal.callMethod('setCurrentBreakpoint', 0.5);
await modal.waitForEvent('ionBreakpointDidChange');
const breakpoint = await modal.callMethod('getCurrentBreakpoint');
expect(breakpoint).toBe(0.5);
});
it('should emit ionBreakpointDidChange', async () => {
const modal = await openModal(page, '#sheet-modal');
const ionBreakpointDidChangeSpy = await modal.spyOnEvent('ionBreakpointDidChange');
await modal.callMethod('setCurrentBreakpoint', 0.5);
await modal.waitForEvent('ionBreakpointDidChange');
expect(ionBreakpointDidChangeSpy).toHaveReceivedEventTimes(1);
});
it('should emit ionBreakpointDidChange when breakpoint is set to 0', async () => {
const modal = await openModal(page, '#sheet-modal');
const ionBreakpointDidChangeSpy = await modal.spyOnEvent('ionBreakpointDidChange');
await modal.callMethod('setCurrentBreakpoint', 0);
await modal.waitForEvent('ionBreakpointDidChange');
expect(ionBreakpointDidChangeSpy).toHaveReceivedEventTimes(1);
});
});
it('should emit ionBreakpointDidChange when the sheet is swiped to breakpoint 0', async () => {
const modal = await openModal(page, '#sheet-modal');
const ionBreakpointDidChangeSpy = await modal.spyOnEvent('ionBreakpointDidChange');
const headerEl = await page.$('ion-modal ion-header');
await dragElementBy(headerEl, page, 0, 500);
await modal.waitForEvent('ionBreakpointDidChange');
expect(ionBreakpointDidChangeSpy).toHaveReceivedEventTimes(1);
});
});

View File

@ -4,7 +4,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Modal - Sheet</title> <title>Modal - Sheet</title>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport"
content="viewport-fit=cover, 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="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet"> <link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script> <script src="../../../../../scripts/testing/scripts.js"></script>
@ -70,7 +71,6 @@
.grid-item { .grid-item {
height: 200px; height: 200px;
} }
</style> </style>
</head> </head>
@ -91,15 +91,30 @@
</ion-item> </ion-item>
<ion-button id="sheet-modal" onclick="presentModal()">Present Sheet Modal</ion-button> <ion-button id="sheet-modal" onclick="presentModal()">Present Sheet Modal</ion-button>
<ion-button id="custom-breakpoint-modal" onclick="presentModal({ initialBreakpoint: 0.5, breakpoints: [0, 0.5, 1] })">Present Sheet Modal (Custom Breakpoints)</ion-button> <ion-button id="custom-breakpoint-modal"
<ion-button id="custom-breakpoint-modal" onclick="presentModal({ initialBreakpoint: 0.5, breakpoints: [0, 0.5, 0.75] })">Present Sheet Modal (Max breakpoint is not 1)</ion-button> onclick="presentModal({ initialBreakpoint: 0.5, breakpoints: [0, 0.5, 1] })">Present Sheet Modal (Custom
<ion-button id="custom-backdrop-modal" onclick="presentModal({ backdropBreakpoint: 0.5, initialBreakpoint: 0.5 })">Present Sheet Modal (Custom Backdrop Breakpoint)</ion-button> Breakpoints)</ion-button>
<ion-button id="custom-backdrop-off-center-modal" onClick="presentModal({ backdropBreakpoint: 0.3, initialBreakpoint: 0.3, breakpoints: [0.3, 0.5, 0.7, 1] })">Present Sheet Modal (Backdrop breakpoint is off-center)</ion-button> <ion-button id="custom-breakpoint-modal"
<ion-button id="custom-height-modal" onclick="presentModal({ cssClass: 'custom-height' })">Present Sheet Modal (Custom Height)</ion-button> onclick="presentModal({ initialBreakpoint: 0.5, breakpoints: [0, 0.5, 0.75] })">Present Sheet Modal (Max
<ion-button id="custom-handle-modal" onclick="presentModal({ cssClass: 'custom-handle' })">Present Sheet Modal (Custom Handle)</ion-button> breakpoint is not 1)</ion-button>
<ion-button id="no-handle-modal" onclick="presentModal({ handle: false })">Present Sheet Modal (No Handle)</ion-button> <ion-button id="custom-backdrop-modal"
<ion-button id="backdrop-active" onclick="presentModal({ backdropBreakpoint: 0.3, initialBreakpoint: 0.5, breakpoints: [0.3, 0.5, 0.7, 1] })">Backdrop is active</ion-button> onclick="presentModal({ backdropBreakpoint: 0.5, initialBreakpoint: 0.5 })">Present Sheet Modal (Custom
<ion-button id="backdrop-inactive" onclick="presentModal({ cssClass: 'backdrop-inactive', backdropBreakpoint: 0.5, initialBreakpoint: 0.3, breakpoints: [0.3, 0.5, 0.7, 1] })">Backdrop is inactive</ion-button> Backdrop Breakpoint)</ion-button>
<ion-button id="custom-backdrop-off-center-modal"
onClick="presentModal({ backdropBreakpoint: 0.3, initialBreakpoint: 0.3, breakpoints: [0.3, 0.5, 0.7, 1] })">
Present Sheet Modal (Backdrop breakpoint is off-center)</ion-button>
<ion-button id="custom-height-modal" onclick="presentModal({ cssClass: 'custom-height' })">Present Sheet Modal
(Custom Height)</ion-button>
<ion-button id="custom-handle-modal" onclick="presentModal({ cssClass: 'custom-handle' })">Present Sheet Modal
(Custom Handle)</ion-button>
<ion-button id="no-handle-modal" onclick="presentModal({ handle: false })">Present Sheet Modal (No Handle)
</ion-button>
<ion-button id="backdrop-active"
onclick="presentModal({ backdropBreakpoint: 0.3, initialBreakpoint: 0.5, breakpoints: [0.3, 0.5, 0.7, 1] })">
Backdrop is active</ion-button>
<ion-button id="backdrop-inactive"
onclick="presentModal({ cssClass: 'backdrop-inactive', backdropBreakpoint: 0.5, initialBreakpoint: 0.3, breakpoints: [0.3, 0.5, 0.7, 1] })">
Backdrop is inactive</ion-button>
<div class="grid"> <div class="grid">
<div class="grid-item red"></div> <div class="grid-item red"></div>
@ -117,9 +132,6 @@
</ion-app> </ion-app>
<script> <script>
window.addEventListener("ionModalDidDismiss", function (e) { console.log('DidDismiss', e) })
window.addEventListener("ionModalWillDismiss", function (e) { console.log('WillDismiss', e) })
function createModal(options) { function createModal(options) {
let items = ''; let items = '';

View File

@ -1,7 +1,28 @@
import { newE2EPage } from '@stencil/core/testing'; import { E2EPage, newE2EPage } from '@stencil/core/testing';
import { generateE2EUrl } from '@utils/test'; import { generateE2EUrl } from '@utils/test';
export const openModal = async (
page: E2EPage,
selector: string
) => {
const ionModalWillPresent = await page.spyOnEvent('ionModalWillPresent');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click(selector);
await ionModalWillPresent.next();
await ionModalDidPresent.next();
await page.waitForSelector(selector);
const modal = await page.find('ion-modal');
await modal.waitForVisible();
await page.waitForTimeout(100);
return modal;
}
export const testModal = async ( export const testModal = async (
type: string, type: string,
selector: string, selector: string,
@ -15,21 +36,10 @@ export const testModal = async (
}); });
const screenshotCompares = []; const screenshotCompares = [];
const ionModalWillPresent = await page.spyOnEvent('ionModalWillPresent');
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const ionModalWillDismiss = await page.spyOnEvent('ionModalWillDismiss'); const ionModalWillDismiss = await page.spyOnEvent('ionModalWillDismiss');
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
await page.click(selector); let modal = await openModal(page, selector);
await ionModalWillPresent.next();
await ionModalDidPresent.next();
await page.waitForSelector(selector);
let modal = await page.find('ion-modal');
await modal.waitForVisible();
await page.waitForTimeout(100);
screenshotCompares.push(await page.compareScreenshot()); screenshotCompares.push(await page.compareScreenshot());

View File

@ -83,7 +83,7 @@ export const listenForEvent = async (page: any, eventType: string, element: any,
* element. * element.
*/ */
export const dragElementBy = async ( export const dragElementBy = async (
element: any, element: ElementHandle<Element>,
page: any, page: any,
x = 0, x = 0,
y = 0, y = 0,