diff --git a/angular/src/directives/overlays/modal.ts b/angular/src/directives/overlays/modal.ts index 5db8c6749df..960b5d22427 100644 --- a/angular/src/directives/overlays/modal.ts +++ b/angular/src/directives/overlays/modal.ts @@ -63,6 +63,7 @@ export declare interface IonModal extends Components.IonModal { 'enterAnimation', 'event', 'handle', + 'handleBehavior', 'initialBreakpoint', 'isOpen', 'keyboardClose', @@ -93,6 +94,7 @@ export declare interface IonModal extends Components.IonModal { 'enterAnimation', 'event', 'handle', + 'handleBehavior', 'initialBreakpoint', 'isOpen', 'keyboardClose', diff --git a/core/api.txt b/core/api.txt index dec444946d4..94151e92853 100644 --- a/core/api.txt +++ b/core/api.txt @@ -773,6 +773,7 @@ ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false ion-modal,prop,canDismiss,(() => Promise) | boolean | undefined,undefined,false,false ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false ion-modal,prop,handle,boolean | undefined,undefined,false,false +ion-modal,prop,handleBehavior,"cycle" | "none" | undefined,'none',false,false ion-modal,prop,htmlAttributes,undefined | { [key: string]: any; },undefined,false,false ion-modal,prop,initialBreakpoint,number | undefined,undefined,false,false ion-modal,prop,isOpen,boolean,false,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index cd73b59f2aa..5ac6618becc 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -5,7 +5,7 @@ * It contains typing information for all components that exist in this project. */ 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, ModalBreakpointChangeEventDetail, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, 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, ModalHandleBehavior, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, 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 { AlertAttributes } from "./components/alert/alert-interface"; import { CounterFormatter } from "./components/item/item-interface"; @@ -1558,6 +1558,10 @@ export namespace Components { * The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. */ "handle"?: boolean; + /** + * The interaction behavior for the sheet modal when the handle is pressed. Defaults to `"none"`, which means the modal will not change size or position when the handle is pressed. Set to `"cycle"` to let the modal cycle between available breakpoints when pressed. Handle behavior is unavailable when the `handle` property is set to `false` or when the `breakpoints` property is not set (using a fullscreen or card modal). + */ + "handleBehavior"?: ModalHandleBehavior; "hasController": boolean; /** * Additional attributes to pass to the modal. @@ -5483,6 +5487,10 @@ declare namespace LocalJSX { * The horizontal line that displays at the top of a sheet modal. It is `true` by default when setting the `breakpoints` and `initialBreakpoint` properties. */ "handle"?: boolean; + /** + * The interaction behavior for the sheet modal when the handle is pressed. Defaults to `"none"`, which means the modal will not change size or position when the handle is pressed. Set to `"cycle"` to let the modal cycle between available breakpoints when pressed. Handle behavior is unavailable when the `handle` property is set to `false` or when the `breakpoints` property is not set (using a fullscreen or card modal). + */ + "handleBehavior"?: ModalHandleBehavior; "hasController"?: boolean; /** * Additional attributes to pass to the modal. diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 72cee60a2a0..cd1c51247f4 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -294,66 +294,72 @@ export const createSheetGesture = ( */ gesture.enable(false); - animation - .onFinish( - () => { - if (shouldRemainOpen) { - /** - * Once the snapping animation completes, - * we need to reset the animation to go - * from 0 to 1 so users can swipe in any direction. - * We then set the animation offset to the current - * breakpoint so that it starts at the snapped position. - */ - if (wrapperAnimation && backdropAnimation) { - raf(() => { - wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); - backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); - animation.progressStart(true, 1 - snapToBreakpoint); - currentBreakpoint = snapToBreakpoint; - onBreakpointChange(currentBreakpoint); - - /** - * If the sheet is fully expanded, we can safely - * enable scrolling again. - */ - if (contentEl && currentBreakpoint === breakpoints[breakpoints.length - 1]) { - contentEl.scrollY = true; - } - - /** - * Backdrop should become enabled - * after the backdropBreakpoint value - */ - const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint; - if (shouldEnableBackdrop) { - enableBackdrop(); - } else { - disableBackdrop(); - } - - gesture.enable(true); - }); - } else { - gesture.enable(true); - } - } - - /** - * This must be a one time callback - * otherwise a new callback will - * be added every time onEnd runs. - */ - }, - { oneTimeCallback: true } - ) - .progressEnd(1, 0, 500); - if (shouldPreventDismiss) { handleCanDismiss(baseEl, animation); } else if (!shouldRemainOpen) { onDismiss(); } + + return new Promise((resolve) => { + animation + .onFinish( + () => { + if (shouldRemainOpen) { + /** + * Once the snapping animation completes, + * we need to reset the animation to go + * from 0 to 1 so users can swipe in any direction. + * We then set the animation offset to the current + * breakpoint so that it starts at the snapped position. + */ + if (wrapperAnimation && backdropAnimation) { + raf(() => { + wrapperAnimation.keyframes([...SheetDefaults.WRAPPER_KEYFRAMES]); + backdropAnimation.keyframes([...SheetDefaults.BACKDROP_KEYFRAMES]); + animation.progressStart(true, 1 - snapToBreakpoint); + currentBreakpoint = snapToBreakpoint; + onBreakpointChange(currentBreakpoint); + + /** + * If the sheet is fully expanded, we can safely + * enable scrolling again. + */ + if (contentEl && currentBreakpoint === breakpoints[breakpoints.length - 1]) { + contentEl.scrollY = true; + } + + /** + * Backdrop should become enabled + * after the backdropBreakpoint value + */ + const shouldEnableBackdrop = currentBreakpoint > backdropBreakpoint; + if (shouldEnableBackdrop) { + enableBackdrop(); + } else { + disableBackdrop(); + } + + gesture.enable(true); + resolve(); + }); + } else { + gesture.enable(true); + resolve(); + } + } else { + resolve(); + } + + /** + * This must be a one time callback + * otherwise a new callback will + * be added every time onEnd runs. + */ + }, + { oneTimeCallback: true } + ) + .progressEnd(1, 0, 500); + }); }; const gesture = createGesture({ diff --git a/core/src/components/modal/modal-interface.ts b/core/src/components/modal/modal-interface.ts index d0e635e750f..187e3c92bf2 100644 --- a/core/src/components/modal/modal-interface.ts +++ b/core/src/components/modal/modal-interface.ts @@ -48,3 +48,8 @@ export interface ModalCustomEvent extends CustomEvent { * @deprecated - Use { [key: string]: any } directly instead. */ export type ModalAttributes = { [key: string]: any }; + +/** + * The behavior setting for modals when the handle is pressed. + */ +export type ModalHandleBehavior = 'none' | 'cycle'; diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index c7406e2e9e2..72bab0e5b50 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -50,7 +50,8 @@ contain: strict; } -.modal-wrapper, ion-backdrop { +.modal-wrapper, +ion-backdrop { pointer-events: auto; } @@ -124,9 +125,30 @@ */ transform: translateZ(0); + border: 0; + background: var(--ion-color-step-350, #c0c0be); + cursor: pointer; + z-index: 11; + + &::before { + /** + * Adds a 4px tap area to the perimeter + * of the handle. + */ + @include padding(4px, 4px, 4px, 4px); + + position: absolute; + + width: 36px; + height: 5px; + + transform: translate(-50%, -50%); + + content: ""; + } } /** diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 873a4148188..12056b52048 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -12,6 +12,7 @@ import type { Gesture, ModalAttributes, ModalBreakpointChangeEventDetail, + ModalHandleBehavior, OverlayEventDetail, OverlayInterface, } from '../../interface'; @@ -56,6 +57,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private modalId?: string; private coreDelegate: FrameworkDelegate = CoreDelegate(); private currentTransition?: Promise; + private sheetTransition?: Promise; private destroyTriggerInteraction?: () => void; private isSheetModal = false; private currentBreakpoint?: number; @@ -63,7 +65,7 @@ export class Modal implements ComponentInterface, OverlayInterface { private backdropEl?: HTMLIonBackdropElement; private sortedBreakpoints?: number[]; private keyboardOpenCallback?: () => void; - private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => void; + private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise; private inline = false; private workingDelegate?: FrameworkDelegate; @@ -140,6 +142,17 @@ export class Modal implements ComponentInterface, OverlayInterface { */ @Prop() handle?: boolean; + /** + * The interaction behavior for the sheet modal when the handle is pressed. + * + * Defaults to `"none"`, which means the modal will not change size or position when the handle is pressed. + * Set to `"cycle"` to let the modal cycle between available breakpoints when pressed. + * + * Handle behavior is unavailable when the `handle` property is set to `false` or + * when the `breakpoints` property is not set (using a fullscreen or card modal). + */ + @Prop() handleBehavior?: ModalHandleBehavior = 'none'; + /** * The component to display inside of the modal. * @internal @@ -758,11 +771,13 @@ export class Modal implements ComponentInterface, OverlayInterface { } if (moveSheetToBreakpoint) { - moveSheetToBreakpoint({ + this.sheetTransition = moveSheetToBreakpoint({ breakpoint, breakpointOffset: 1 - currentBreakpoint!, canDismiss: canDismiss !== undefined && canDismiss !== true && breakpoints![0] === 0, }); + await this.sheetTransition; + this.sheetTransition = undefined; } } @@ -774,7 +789,55 @@ export class Modal implements ComponentInterface, OverlayInterface { return this.currentBreakpoint; } + private async moveToNextBreakpoint() { + const { breakpoints, currentBreakpoint } = this; + + if (!breakpoints || currentBreakpoint == null) { + /** + * If the modal does not have breakpoints and/or the current + * breakpoint is not set, we can't move to the next breakpoint. + */ + return false; + } + + const allowedBreakpoints = breakpoints.filter((b) => b !== 0); + const currentBreakpointIndex = allowedBreakpoints.indexOf(currentBreakpoint); + const nextBreakpointIndex = (currentBreakpointIndex + 1) % allowedBreakpoints.length; + const nextBreakpoint = allowedBreakpoints[nextBreakpointIndex]; + + /** + * Sets the current breakpoint to the next available breakpoint. + * If the current breakpoint is the last breakpoint, we set the current + * breakpoint to the first non-zero breakpoint to avoid dismissing the sheet. + */ + await this.setCurrentBreakpoint(nextBreakpoint); + return true; + } + + private onHandleClick = () => { + const { sheetTransition, handleBehavior } = this; + if (handleBehavior !== 'cycle' || sheetTransition !== undefined) { + /** + * The sheet modal should not advance to the next breakpoint + * if the handle behavior is not `cycle` or if the handle + * is clicked while the sheet is moving to a breakpoint. + */ + return; + } + this.moveToNextBreakpoint(); + }; + private onBackdropTap = () => { + const { sheetTransition } = this; + if (sheetTransition !== undefined) { + /** + * When the handle is double clicked at the largest breakpoint, + * it will start to move to the first breakpoint. While transitioning, + * the backdrop will often receive the second click. We prevent the + * backdrop from dismissing the modal while moving between breakpoints. + */ + return; + } this.dismiss(undefined, BACKDROP); }; @@ -792,12 +855,13 @@ export class Modal implements ComponentInterface, OverlayInterface { }; render() { - const { handle, isSheetModal, presentingElement, htmlAttributes } = this; + const { handle, isSheetModal, presentingElement, htmlAttributes, handleBehavior } = this; const showHandle = handle !== false && isSheetModal; const mode = getIonMode(this); const { modalId } = this; const isCardModal = presentingElement !== undefined && mode === 'ios'; + const isHandleCycle = handleBehavior === 'cycle'; return ( } diff --git a/core/src/components/modal/test/sheet/index.html b/core/src/components/modal/test/sheet/index.html index 2731b5e84dd..6ac6b24488b 100644 --- a/core/src/components/modal/test/sheet/index.html +++ b/core/src/components/modal/test/sheet/index.html @@ -115,6 +115,14 @@ Present Sheet Modal (Custom Height) + + + Present Sheet Modal (HandleBehavior: Cycle) + Present Sheet Modal (Custom Handle) diff --git a/core/src/components/modal/test/sheet/modal.e2e.ts b/core/src/components/modal/test/sheet/modal.e2e.ts index e65a2268dc8..9f580605d0e 100644 --- a/core/src/components/modal/test/sheet/modal.e2e.ts +++ b/core/src/components/modal/test/sheet/modal.e2e.ts @@ -195,3 +195,78 @@ test.describe('sheet modal: setting the breakpoint', () => { expect(updatedBreakpoint).toBe(0.5); }); }); + +test.describe('sheet modal: clicking the handle', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/modal/test/sheet'); + }); + + test('should advance to the next breakpoint when handleBehavior is cycle', async ({ page }) => { + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const modal = page.locator('ion-modal'); + + await page.click('#handle-behavior-cycle-modal'); + await ionModalDidPresent.next(); + + const handle = page.locator('ion-modal .modal-handle'); + + await handle.click(); + await ionBreakpointDidChange.next(); + + await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.5); + + await handle.click(); + await ionBreakpointDidChange.next(); + + await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.75); + + await handle.click(); + await ionBreakpointDidChange.next(); + + await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(1); + + await handle.click(); + await ionBreakpointDidChange.next(); + + // Advancing from the last breakpoint should change the breakpoint to the first non-zero breakpoint + await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.25); + }); + + test('should not advance the breakpoint when handleBehavior is none', async ({ page }) => { + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const modal = page.locator('ion-modal'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const handle = page.locator('ion-modal .modal-handle'); + + await handle.click(); + + await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.25); + }); + + test('should not dismiss the modal when backdrop is clicked and breakpoint is moving', async ({ page }) => { + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const modal = page.locator('ion-modal'); + + await page.click('#handle-behavior-cycle-modal'); + await ionModalDidPresent.next(); + + const handle = page.locator('ion-modal .modal-handle'); + const backdrop = page.locator('ion-modal ion-backdrop'); + + await handle.click(); + backdrop.click(); + + await ionBreakpointDidChange.next(); + + await handle.click(); + + await ionBreakpointDidChange.next(); + + await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.75); + }); +}); diff --git a/packages/vue/src/components/Overlays.ts b/packages/vue/src/components/Overlays.ts index a5f9d104a9e..1f02542841f 100644 --- a/packages/vue/src/components/Overlays.ts +++ b/packages/vue/src/components/Overlays.ts @@ -29,7 +29,7 @@ export const IonPicker = /*@__PURE__*/ defineOverlayContainer('io export const IonToast = /*@__PURE__*/ defineOverlayContainer('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'position', 'translucent'], toastController); -export const IonModal = /*@__PURE__*/ defineOverlayContainer('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'swipeToClose', 'trigger']); +export const IonModal = /*@__PURE__*/ defineOverlayContainer('ion-modal', defineIonModalCustomElement, ['animated', 'backdropBreakpoint', 'backdropDismiss', 'breakpoints', 'canDismiss', 'enterAnimation', 'handle', 'handleBehavior', 'htmlAttributes', 'initialBreakpoint', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'presentingElement', 'showBackdrop', 'swipeToClose', 'trigger']); export const IonPopover = /*@__PURE__*/ defineOverlayContainer('ion-popover', defineIonPopoverCustomElement, ['alignment', 'animated', 'arrow', 'backdropDismiss', 'component', 'componentProps', 'dismissOnSelect', 'enterAnimation', 'event', 'htmlAttributes', 'isOpen', 'keepContentsMounted', 'keyboardClose', 'leaveAnimation', 'mode', 'reference', 'showBackdrop', 'side', 'size', 'translucent', 'trigger', 'triggerAction']);