feat(modal): clicking handle advances to the next breakpoint (#25540)

Resolves #24069
This commit is contained in:
Sean Perkins
2022-07-06 10:30:08 -04:00
committed by GitHub
parent 805dfa0566
commit 7cdc388b78
10 changed files with 262 additions and 62 deletions

View File

@@ -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',

View File

@@ -773,6 +773,7 @@ ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false
ion-modal,prop,canDismiss,(() => Promise<boolean>) | boolean | undefined,undefined,false,false
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-modal,prop,handle,boolean | undefined,undefined,false,false
ion-modal,prop,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

View File

@@ -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.

View File

@@ -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<void>((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({

View File

@@ -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';

View File

@@ -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: "";
}
}
/**

View File

@@ -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<any>;
private sheetTransition?: Promise<any>;
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<void>;
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 (
<Host
@@ -833,7 +897,16 @@ export class Modal implements ComponentInterface, OverlayInterface {
{mode === 'ios' && <div class="modal-shadow"></div>}
<div role="dialog" class="modal-wrapper ion-overlay-wrapper" part="content" ref={(el) => (this.wrapperEl = el)}>
{showHandle && <div class="modal-handle" part="handle"></div>}
{showHandle && (
<button
class="modal-handle"
// Prevents the handle from receiving keyboard focus when it does not cycle
tabIndex={!isHandleCycle ? -1 : 0}
aria-label="Activate to adjust the size of the dialog overlaying the screen"
onClick={isHandleCycle ? this.onHandleClick : undefined}
part="handle"
></button>
)}
<slot></slot>
</div>
</Host>

View File

@@ -115,6 +115,14 @@
<ion-button id="custom-height-modal" onclick="presentModal({ cssClass: 'custom-height' })"
>Present Sheet Modal (Custom Height)</ion-button
>
<ion-button
id="handle-behavior-cycle-modal"
onclick="presentModal({ handleBehavior: 'cycle', initialBreakpoint: 0.25, breakpoints: [0, 0.25, 0.5, 0.75, 1] })"
>
Present Sheet Modal (HandleBehavior: Cycle)</ion-button
>
<ion-button id="custom-handle-modal" onclick="presentModal({ cssClass: 'custom-handle' })"
>Present Sheet Modal (Custom Handle)</ion-button
>

View File

@@ -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);
});
});

View File

@@ -29,7 +29,7 @@ export const IonPicker = /*@__PURE__*/ defineOverlayContainer<JSX.IonPicker>('io
export const IonToast = /*@__PURE__*/ defineOverlayContainer<JSX.IonToast>('ion-toast', defineIonToastCustomElement, ['animated', 'buttons', 'color', 'cssClass', 'duration', 'enterAnimation', 'header', 'htmlAttributes', 'icon', 'keyboardClose', 'leaveAnimation', 'message', 'mode', 'position', 'translucent'], toastController);
export const IonModal = /*@__PURE__*/ defineOverlayContainer<JSX.IonModal>('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<JSX.IonModal>('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<JSX.IonPopover>('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']);