Skip to content

Commit

Permalink
feat(modal): clicking handle advances to the next breakpoint (#25540)
Browse files Browse the repository at this point in the history
Resolves #24069
  • Loading branch information
sean-perkins authored Jul 6, 2022
1 parent 805dfa0 commit 7cdc388
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 62 deletions.
2 changes: 2 additions & 0 deletions angular/src/directives/overlays/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export declare interface IonModal extends Components.IonModal {
'enterAnimation',
'event',
'handle',
'handleBehavior',
'initialBreakpoint',
'isOpen',
'keyboardClose',
Expand Down Expand Up @@ -93,6 +94,7 @@ export declare interface IonModal extends Components.IonModal {
'enterAnimation',
'event',
'handle',
'handleBehavior',
'initialBreakpoint',
'isOpen',
'keyboardClose',
Expand Down
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
116 changes: 61 additions & 55 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions core/src/components/modal/modal-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
24 changes: 23 additions & 1 deletion core/src/components/modal/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
contain: strict;
}

.modal-wrapper, ion-backdrop {
.modal-wrapper,
ion-backdrop {
pointer-events: auto;
}

Expand Down Expand Up @@ -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: "";
}
}

/**
Expand Down
81 changes: 77 additions & 4 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
Gesture,
ModalAttributes,
ModalBreakpointChangeEventDetail,
ModalHandleBehavior,
OverlayEventDetail,
OverlayInterface,
} from '../../interface';
Expand Down Expand Up @@ -56,14 +57,15 @@ 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;
private wrapperEl?: HTMLElement;
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}

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

Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
8 changes: 8 additions & 0 deletions core/src/components/modal/test/sheet/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Expand Down
Loading

0 comments on commit 7cdc388

Please sign in to comment.