Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 22 commits into from
Jul 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4f1d4f7
feat(modal): clicking handle advances to the next breakpoint
sean-perkins Jun 27, 2022
c51e98f
chore(angular): update modal wrapper
sean-perkins Jun 27, 2022
333119a
chore(): update build api
sean-perkins Jun 27, 2022
0e08033
chore(vue): update modal wrapper
sean-perkins Jun 27, 2022
077ded7
refactor(): use running state of animation
sean-perkins Jun 27, 2022
b08fa9f
chore(): revert using state of animations
sean-perkins Jun 27, 2022
1e12616
chore(): documentation
sean-perkins Jun 30, 2022
f47013e
fix(): use currentTransition, remove aria-controls
sean-perkins Jun 30, 2022
6427644
Merge remote-tracking branch 'origin/FW-262-a' into FW-262-a
sean-perkins Jun 30, 2022
04dde05
chore(): remove onAnimationFinish
sean-perkins Jun 30, 2022
5d7f294
Merge branch 'main' into FW-262-a
sean-perkins Jul 1, 2022
c0d92a5
fix(): modal handle cursor styling
sean-perkins Jul 1, 2022
eb789c6
Merge remote-tracking branch 'origin/feature-6.2' into FW-262-a
sean-perkins Jul 1, 2022
93535c0
refactor(): store sheet transition to separate prop
sean-perkins Jul 1, 2022
ef834af
chore(): conditionally apply click listener
sean-perkins Jul 1, 2022
3513a6d
refactor(): move sheet to breakpoint returns a promise
sean-perkins Jul 5, 2022
6fb2073
test(modal): keyboard focus test for chrome
sean-perkins Jul 5, 2022
8753992
Merge remote-tracking branch 'origin/feature-6.2' into FW-262-a
sean-perkins Jul 5, 2022
a4969ec
fix(): types, doc handle behavior and set aria-label
sean-perkins Jul 5, 2022
01dd9b6
chore(): document when handle behavior is unavailable
sean-perkins Jul 5, 2022
2243a78
fix(): resolve promise even if sheet should not remain open
sean-perkins Jul 5, 2022
3f127c8
chore(): remove focus trap test
sean-perkins Jul 5, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
sean-perkins marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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.
sean-perkins marked this conversation as resolved.
Show resolved Hide resolved
*
* 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;
sean-perkins marked this conversation as resolved.
Show resolved Hide resolved
}

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}
sean-perkins marked this conversation as resolved.
Show resolved Hide resolved
sean-perkins marked this conversation as resolved.
Show resolved Hide resolved
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