Files

585 lines
15 KiB
TypeScript

import { Build, Component, ComponentInterface, Element, Event, EventEmitter, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import { Gesture, GestureDetail, IonicAnimation, MenuChangeEventDetail, MenuI, Side } from '../../interface';
import { Point, getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
import { GESTURE_CONTROLLER } from '../../utils/gesture';
import { assert, isEndSide as isEnd } from '../../utils/helpers';
import { menuController } from '../../utils/menu-controller';
@Component({
tag: 'ion-menu',
styleUrls: {
ios: 'menu.ios.scss',
md: 'menu.md.scss'
},
shadow: true
})
export class Menu implements ComponentInterface, MenuI {
private animation?: any;
private lastOnEnd = 0;
private gesture?: Gesture;
private blocker = GESTURE_CONTROLLER.createBlocker({ disableScroll: true });
private mode = getIonMode(this);
isAnimating = false;
width!: number; // TODO
_isOpen = false;
backdropEl?: HTMLElement;
menuInnerEl?: HTMLElement;
contentEl?: HTMLElement;
@Element() el!: HTMLIonMenuElement;
@State() isPaneVisible = false;
@State() isEndSide = false;
/**
* The content's id the menu should use.
*/
@Prop() contentId?: string;
/**
* An id for the menu.
*/
@Prop() menuId?: string;
/**
* The display type of the menu.
* Available options: `"overlay"`, `"reveal"`, `"push"`.
*/
@Prop({ mutable: true }) type?: string;
@Watch('type')
typeChanged(type: string, oldType: string | undefined) {
const contentEl = this.contentEl;
if (contentEl) {
if (oldType !== undefined) {
contentEl.classList.remove(`menu-content-${oldType}`);
}
contentEl.classList.add(`menu-content-${type}`);
contentEl.removeAttribute('style');
}
if (this.menuInnerEl) {
// Remove effects of previous animations
this.menuInnerEl.removeAttribute('style');
}
this.animation = undefined;
}
/**
* If `true`, the menu is disabled.
*/
@Prop({ mutable: true }) disabled = false;
@Watch('disabled')
protected disabledChanged() {
this.updateState();
this.ionMenuChange.emit({
disabled: this.disabled,
open: this._isOpen
});
}
/**
* Which side of the view the menu should be placed.
*/
@Prop({ reflectToAttr: true }) side: Side = 'start';
@Watch('side')
protected sideChanged() {
this.isEndSide = isEnd(this.side);
}
/**
* If `true`, swiping the menu is enabled.
*/
@Prop() swipeGesture = true;
@Watch('swipeGesture')
protected swipeGestureChanged() {
this.updateState();
}
/**
* The edge threshold for dragging the menu open.
* If a drag/swipe happens over this value, the menu is not triggered.
*/
@Prop() maxEdgeStart = 50;
/**
* Emitted when the menu is about to be opened.
*/
@Event() ionWillOpen!: EventEmitter<void>;
/**
* Emitted when the menu is about to be closed.
*/
@Event() ionWillClose!: EventEmitter<void>;
/**
* Emitted when the menu is open.
*/
@Event() ionDidOpen!: EventEmitter<void>;
/**
* Emitted when the menu is closed.
*/
@Event() ionDidClose!: EventEmitter<void>;
/**
* Emitted when the menu state is changed.
* @internal
*/
@Event() protected ionMenuChange!: EventEmitter<MenuChangeEventDetail>;
async connectedCallback() {
if (this.type === undefined) {
this.type = config.get('menuType', this.mode === 'ios' ? 'reveal' : 'overlay');
}
if (!Build.isBrowser) {
this.disabled = true;
return;
}
const el = this.el;
const parent = el.parentNode as any;
const content = this.contentId !== undefined
? document.getElementById(this.contentId)
: parent && parent.querySelector && parent.querySelector('[main]');
if (!content || !content.tagName) {
// requires content element
console.error('Menu: must have a "content" element to listen for drag events on.');
return;
}
this.contentEl = content as HTMLElement;
// add menu's content classes
content.classList.add('menu-content');
this.typeChanged(this.type!, undefined);
this.sideChanged();
// register this menu with the app's menu controller
menuController._register(this);
this.gesture = (await import('../../utils/gesture')).createGesture({
el: document,
gestureName: 'menu-swipe',
gesturePriority: 30,
threshold: 10,
canStart: ev => this.canStart(ev),
onWillStart: () => this.onWillStart(),
onStart: () => this.onStart(),
onMove: ev => this.onMove(ev),
onEnd: ev => this.onEnd(ev),
});
this.updateState();
}
async componentDidLoad() {
this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen });
this.updateState();
}
disconnectedCallback() {
this.blocker.destroy();
menuController._unregister(this);
if (this.animation) {
this.animation.destroy();
}
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}
this.animation = undefined;
this.contentEl = this.backdropEl = this.menuInnerEl = undefined;
}
@Listen('ionSplitPaneVisible', { target: 'body' })
onSplitPaneChanged(ev: CustomEvent) {
this.isPaneVisible = ev.detail.isPane(this.el);
this.updateState();
}
@Listen('click', { capture: true })
onBackdropClick(ev: any) {
if (this._isOpen && this.lastOnEnd < ev.timeStamp - 100) {
const shouldClose = (ev.composedPath)
? !ev.composedPath().includes(this.menuInnerEl)
: false;
if (shouldClose) {
ev.preventDefault();
ev.stopPropagation();
this.close();
}
}
}
/**
* Returns `true` is the menu is open.
*/
@Method()
isOpen(): Promise<boolean> {
return Promise.resolve(this._isOpen);
}
/**
* Returns `true` is the menu is active.
*
* A menu is active when it can be opened or closed, meaning it's enabled
* and it's not part of a `ion-split-pane`.
*/
@Method()
isActive(): Promise<boolean> {
return Promise.resolve(this._isActive());
}
/**
* Opens the menu. If the menu is already open or it can't be opened,
* it returns `false`.
*/
@Method()
open(animated = true): Promise<boolean> {
return this.setOpen(true, animated);
}
/**
* Closes the menu. If the menu is already closed or it can't be closed,
* it returns `false`.
*/
@Method()
close(animated = true): Promise<boolean> {
return this.setOpen(false, animated);
}
/**
* Toggles the menu. If the menu is already open, it will try to close, otherwise it will try to open it.
* If the operation can't be completed successfully, it returns `false`.
*/
@Method()
toggle(animated = true): Promise<boolean> {
return this.setOpen(!this._isOpen, animated);
}
/**
* Opens or closes the button.
* If the operation can't be completed successfully, it returns `false`.
*/
@Method()
setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
return menuController._setOpen(this, shouldOpen, animated);
}
async _setOpen(shouldOpen: boolean, animated = true): Promise<boolean> {
// If the menu is disabled or it is currently being animated, let's do nothing
if (!this._isActive() || this.isAnimating || shouldOpen === this._isOpen) {
return false;
}
this.beforeAnimation(shouldOpen);
await this.loadAnimation();
await this.startAnimation(shouldOpen, animated);
this.afterAnimation(shouldOpen);
return true;
}
private async loadAnimation(): Promise<void> {
// Menu swipe animation takes the menu's inner width as parameter,
// If `offsetWidth` changes, we need to create a new animation.
const width = this.menuInnerEl!.offsetWidth;
if (width === this.width && this.animation !== undefined) {
return;
}
this.width = width;
// Destroy existing animation
if (this.animation) {
this.animation.destroy();
this.animation = undefined;
}
// Create new animation
this.animation = await menuController._createAnimation(this.type!, this);
if (!config.getBoolean('animated', true)) {
this.animation.duration(0);
}
this.animation.fill('both');
}
private async startAnimation(shouldOpen: boolean, animated: boolean): Promise<void> {
const isReversed = !shouldOpen;
const ani = (this.animation as IonicAnimation)!
.direction((isReversed) ? 'reverse' : 'normal')
.easing((isReversed) ? 'cubic-bezier(0.4, 0.0, 0.6, 1)' : 'cubic-bezier(0.0, 0.0, 0.2, 1)');
if (animated) {
await ani.playAsync();
} else {
ani.playSync();
}
}
private _isActive() {
return !this.disabled && !this.isPaneVisible;
}
private canSwipe(): boolean {
return this.swipeGesture && !this.isAnimating && this._isActive();
}
private canStart(detail: GestureDetail): boolean {
if (!this.canSwipe()) {
return false;
}
if (this._isOpen) {
return true;
// TODO error
} else if (menuController._getOpenSync()) {
return false;
}
return checkEdgeSide(
window,
detail.currentX,
this.isEndSide,
this.maxEdgeStart
);
}
private onWillStart(): Promise<void> {
this.beforeAnimation(!this._isOpen);
return this.loadAnimation();
}
private onStart() {
if (!this.isAnimating || !this.animation) {
assert(false, 'isAnimating has to be true');
return;
}
// the cloned animation should not use an easing curve during seek
(this.animation as IonicAnimation)
.direction((this._isOpen) ? 'reverse' : 'normal')
.progressStart(true);
}
private onMove(detail: GestureDetail) {
if (!this.isAnimating || !this.animation) {
assert(false, 'isAnimating has to be true');
return;
}
const delta = computeDelta(detail.deltaX, this._isOpen, this.isEndSide);
const stepValue = delta / this.width;
this.animation.progressStep(stepValue);
}
private onEnd(detail: GestureDetail) {
if (!this.isAnimating || !this.animation) {
assert(false, 'isAnimating has to be true');
return;
}
const isOpen = this._isOpen;
const isEndSide = this.isEndSide;
const delta = computeDelta(detail.deltaX, isOpen, isEndSide);
const width = this.width;
const stepValue = delta / width;
const velocity = detail.velocityX;
const z = width / 2.0;
const shouldCompleteRight =
velocity >= 0 && (velocity > 0.2 || detail.deltaX > z);
const shouldCompleteLeft =
velocity <= 0 && (velocity < -0.2 || detail.deltaX < -z);
const shouldComplete = isOpen
? isEndSide ? shouldCompleteRight : shouldCompleteLeft
: isEndSide ? shouldCompleteLeft : shouldCompleteRight;
let shouldOpen = !isOpen && shouldComplete;
if (isOpen && !shouldComplete) {
shouldOpen = true;
}
this.lastOnEnd = detail.timeStamp;
// Account for rounding errors in JS
let newStepValue = (shouldComplete) ? 0.001 : -0.001;
/**
* TODO: stepValue can sometimes return a negative
* value, but you can't have a negative time value
* for the cubic bezier curve (at least with web animations)
* Not sure if the negative step value is an error or not
*/
const adjustedStepValue = (stepValue <= 0) ? 0.01 : stepValue;
/**
* Animation will be reversed here, so need to
* reverse the easing curve as well
*
* Additionally, we need to account for the time relative
* to the new easing curve, as `stepValue` is going to be given
* in terms of a linear curve.
*/
newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(0.4, 0), new Point(0.6, 1), new Point(1, 1), adjustedStepValue);
this.animation
.easing('cubic-bezier(0.4, 0.0, 0.6, 1)')
.onFinish(
() => this.afterAnimation(shouldOpen),
{ oneTimeCallback: true })
.progressEnd(shouldComplete ? 1 : 0, newStepValue, 300);
}
private beforeAnimation(shouldOpen: boolean) {
assert(!this.isAnimating, '_before() should not be called while animating');
// this places the menu into the correct location before it animates in
// this css class doesn't actually kick off any animations
this.el.classList.add(SHOW_MENU);
if (this.backdropEl) {
this.backdropEl.classList.add(SHOW_BACKDROP);
}
this.blocker.block();
this.isAnimating = true;
if (shouldOpen) {
this.ionWillOpen.emit();
} else {
this.ionWillClose.emit();
}
}
private afterAnimation(isOpen: boolean) {
assert(this.isAnimating, '_before() should be called while animating');
// keep opening/closing the menu disabled for a touch more yet
// only add listeners/css if it's enabled and isOpen
// and only remove listeners/css if it's not open
// emit opened/closed events
this._isOpen = isOpen;
this.isAnimating = false;
if (!this._isOpen) {
this.blocker.unblock();
}
if (isOpen) {
// add css class
if (this.contentEl) {
this.contentEl.classList.add(MENU_CONTENT_OPEN);
}
// emit open event
this.ionDidOpen.emit();
} else {
// remove css classes
this.el.classList.remove(SHOW_MENU);
if (this.contentEl) {
this.contentEl.classList.remove(MENU_CONTENT_OPEN);
}
if (this.backdropEl) {
this.backdropEl.classList.remove(SHOW_BACKDROP);
}
if (this.animation) {
this.animation.stop();
}
// emit close event
this.ionDidClose.emit();
}
}
private updateState() {
const isActive = this._isActive();
if (this.gesture) {
this.gesture.setDisabled(!isActive || !this.swipeGesture);
}
// Close menu immediately
if (!isActive && this._isOpen) {
// close if this menu is open, and should not be enabled
this.forceClosing();
}
if (!this.disabled) {
menuController._setActiveMenu(this);
}
assert(!this.isAnimating, 'can not be animating');
}
private forceClosing() {
assert(this._isOpen, 'menu cannot be closed');
this.isAnimating = true;
const ani = (this.animation as IonicAnimation)!.direction('reverse');
ani.playSync();
this.afterAnimation(false);
}
render() {
const { isEndSide, type, disabled, mode, isPaneVisible } = this;
return (
<Host
role="navigation"
class={{
[mode]: true,
[`menu-type-${type}`]: true,
'menu-enabled': !disabled,
'menu-side-end': isEndSide,
'menu-side-start': !isEndSide,
'menu-pane-visible': isPaneVisible
}}
>
<div
class="menu-inner"
ref={el => this.menuInnerEl = el}
>
<slot></slot>
</div>
<ion-backdrop
ref={el => this.backdropEl = el}
class="menu-backdrop"
tappable={false}
stopPropagation={false}
/>
</Host>
);
}
}
const computeDelta = (
deltaX: number,
isOpen: boolean,
isEndSide: boolean
): number => {
return Math.max(0, isOpen !== isEndSide ? -deltaX : deltaX);
};
const checkEdgeSide = (
win: Window,
posX: number,
isEndSide: boolean,
maxEdgeStart: number
): boolean => {
if (isEndSide) {
return posX >= win.innerWidth - maxEdgeStart;
} else {
return posX <= maxEdgeStart;
}
};
const SHOW_MENU = 'show-menu';
const SHOW_BACKDROP = 'show-backdrop';
const MENU_CONTENT_OPEN = 'menu-content-open';