Files
Liam DeBeasi 487ffca11e chore: fix typo with close watcher const (#29146)
The variable was spelled incorrectly.
2024-03-12 21:52:38 +00:00

833 lines
24 KiB
TypeScript

import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core';
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { GESTURE_CONTROLLER } from '@utils/gesture';
import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { menuController } from '@utils/menu-controller';
import { getPresentedOverlay } from '@utils/overlays';
import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
import type { Animation, Gesture, GestureDetail } from '../../interface';
import type { MenuChangeEventDetail, MenuI, Side } from './menu-interface';
const iosEasing = 'cubic-bezier(0.32,0.72,0,1)';
const mdEasing = 'cubic-bezier(0.0,0.0,0.2,1)';
const iosEasingReverse = 'cubic-bezier(1, 0, 0.68, 0.28)';
const mdEasingReverse = 'cubic-bezier(0.4, 0, 0.6, 1)';
const focusableQueryString =
'[tabindex]:not([tabindex^="-"]), input:not([type=hidden]):not([tabindex^="-"]), textarea:not([tabindex^="-"]), button:not([tabindex^="-"]), select:not([tabindex^="-"]), .ion-focusable:not([tabindex^="-"])';
/**
* @part container - The container for the menu content.
* @part backdrop - The backdrop that appears over the main content when the menu is open.
*/
@Component({
tag: 'ion-menu',
styleUrls: {
ios: 'menu.ios.scss',
md: 'menu.md.scss',
},
shadow: true,
})
export class Menu implements ComponentInterface, MenuI {
private animation?: Animation;
private lastOnEnd = 0;
private gesture?: Gesture;
private blocker = GESTURE_CONTROLLER.createBlocker({ disableScroll: true });
private didLoad = false;
/**
* Flag used to determine if an open/close
* operation was cancelled. For example, if
* an app calls "menu.open" then disables the menu
* part way through the animation, then this would
* be considered a cancelled operation.
*/
private operationCancelled = false;
isAnimating = false;
width!: number;
_isOpen = false;
backdropEl?: HTMLElement;
menuInnerEl?: HTMLElement;
contentEl?: HTMLElement;
lastFocus?: HTMLElement;
private inheritedAttributes: Attributes = {};
private handleFocus = (ev: FocusEvent) => {
/**
* Overlays have their own focus trapping listener
* so we do not want the two listeners to conflict
* with each other. If the top-most overlay that is
* open does not contain this ion-menu, then ion-menu's
* focus trapping should not run.
*/
const lastOverlay = getPresentedOverlay(document);
if (lastOverlay && !lastOverlay.contains(this.el)) {
return;
}
this.trapKeyboardFocus(ev, document);
};
@Element() el!: HTMLIonMenuElement;
@State() isPaneVisible = false;
@State() isEndSide = false;
/**
* The `id` of the main content. When using
* a router this is typically `ion-router-outlet`.
* When not using a router, this is typically
* your main view's `ion-content`. This is not the
* id of the `ion-content` inside of your `ion-menu`.
*/
@Prop({ reflect: true }) contentId?: string;
/**
* An id for the menu.
*/
@Prop({ reflect: true }) 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({ reflect: true }) side: Side = 'start';
@Watch('side')
protected sideChanged() {
this.isEndSide = isEnd(this.side);
/**
* Menu direction animation is calculated based on the document direction.
* If the document direction changes, we need to create a new animation.
*/
this.animation = undefined;
}
/**
* 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() {
// TODO: connectedCallback is fired in CE build
// before WC is defined. This needs to be fixed in Stencil.
if (typeof customElements !== 'undefined' && customElements != null) {
await customElements.whenDefined('ion-menu');
}
if (this.type === undefined) {
this.type = config.get('menuType', 'overlay');
}
if (!Build.isBrowser) {
return;
}
const content = this.contentId !== undefined ? document.getElementById(this.contentId) : null;
if (content === null) {
console.error('Menu: must have a "content" element to listen for drag events on.');
return;
}
if (this.el.contains(content)) {
console.error(
`Menu: "contentId" should refer to the main view's ion-content, not the ion-content inside of the ion-menu.`
);
}
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.menuChanged();
this.gesture = (await import('../../utils/gesture')).createGesture({
el: document,
gestureName: 'menu-swipe',
gesturePriority: 30,
threshold: 10,
blurOnStart: true,
canStart: (ev) => this.canStart(ev),
onWillStart: () => this.onWillStart(),
onStart: () => this.onStart(),
onMove: (ev) => this.onMove(ev),
onEnd: (ev) => this.onEnd(ev),
});
this.updateState();
}
componentWillLoad() {
this.inheritedAttributes = inheritAriaAttributes(this.el);
}
async componentDidLoad() {
this.didLoad = true;
this.menuChanged();
this.updateState();
}
private menuChanged() {
/**
* Inform dependent components such as ion-menu-button
* that the menu is ready. Note that we only want to do this
* once the menu has been rendered which is why we check for didLoad.
*/
if (this.didLoad) {
this.ionMenuChange.emit({ disabled: this.disabled, open: this._isOpen });
}
}
async disconnectedCallback() {
/**
* The menu should be closed when it is
* unmounted from the DOM.
* This is an async call, so we need to wait for
* this to finish otherwise contentEl
* will not have MENU_CONTENT_OPEN removed.
*/
await this.close(false);
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 = undefined;
}
@Listen('ionSplitPaneVisible', { target: 'body' })
onSplitPaneChanged(ev: CustomEvent) {
const { target } = ev;
const closestSplitPane = this.el.closest('ion-split-pane');
/**
* Menu listens on the body for "ionSplitPaneVisible".
* However, this means the callback will run any time
* a SplitPane changes visibility. As a result, we only want
* Menu's visibility state to update if its parent SplitPane
* changes visibility.
*/
if (target !== closestSplitPane) {
return;
}
this.isPaneVisible = ev.detail.isPane(this.el);
this.updateState();
}
@Listen('click', { capture: true })
onBackdropClick(ev: any) {
// TODO(FW-2832): type (CustomEvent triggers errors which should be sorted)
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();
}
}
}
onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
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);
}
private focusFirstDescendant() {
const { el } = this;
const firstInput = el.querySelector(focusableQueryString) as HTMLElement | null;
if (firstInput) {
firstInput.focus();
} else {
el.focus();
}
}
private focusLastDescendant() {
const { el } = this;
const inputs = Array.from(el.querySelectorAll<HTMLElement>(focusableQueryString));
const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
if (lastInput) {
lastInput.focus();
} else {
el.focus();
}
}
private trapKeyboardFocus(ev: Event, doc: Document) {
const target = ev.target as HTMLElement | null;
if (!target) {
return;
}
/**
* If the target is inside the menu contents, let the browser
* focus as normal and keep a log of the last focused element.
*/
if (this.el.contains(target)) {
this.lastFocus = target;
} else {
/**
* Otherwise, we are about to have focus go out of the menu.
* Wrap the focus to either the first or last element.
*/
/**
* Once we call `focusFirstDescendant`, another focus event
* will fire, which will cause `lastFocus` to be updated
* before we can run the code after that. We cache the value
* here to avoid that.
*/
this.focusFirstDescendant();
/**
* If the cached last focused element is the same as the now-
* active element, that means the user was on the first element
* already and pressed Shift + Tab, so we need to wrap to the
* last descendant.
*/
if (this.lastFocus === doc.activeElement) {
this.focusLastDescendant();
}
}
}
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);
/**
* If the animation was cancelled then
* return false because the operation
* did not succeed.
*/
if (this.operationCancelled) {
this.operationCancelled = false;
return false;
}
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;
/**
* Menu direction animation is calculated based on the document direction.
* If the document direction changes, we need to create a new animation.
*/
const isEndSide = isEnd(this.side);
if (width === this.width && this.animation !== undefined && isEndSide === this.isEndSide) {
return;
}
this.width = width;
this.isEndSide = isEndSide;
// Destroy existing animation
if (this.animation) {
this.animation.destroy();
this.animation = undefined;
}
// Create new animation
const animation = (this.animation = await menuController._createAnimation(this.type!, this));
if (!config.getBoolean('animated', true)) {
animation.duration(0);
}
animation.fill('both');
}
private async startAnimation(shouldOpen: boolean, animated: boolean): Promise<void> {
const isReversed = !shouldOpen;
const mode = getIonMode(this);
const easing = mode === 'ios' ? iosEasing : mdEasing;
const easingReverse = mode === 'ios' ? iosEasingReverse : mdEasingReverse;
const ani = (this.animation as Animation)!
.direction(isReversed ? 'reverse' : 'normal')
.easing(isReversed ? easingReverse : easing);
if (animated) {
await ani.play();
} else {
ani.play({ sync: true });
}
/**
* We run this after the play invocation
* instead of using ani.onFinish so that
* multiple onFinish callbacks do not get
* run if an animation is played, stopped,
* and then played again.
*/
if (ani.getDirection() === 'reverse') {
ani.direction('normal');
}
}
private _isActive() {
return !this.disabled && !this.isPaneVisible;
}
private canSwipe(): boolean {
return this.swipeGesture && !this.isAnimating && this._isActive();
}
private canStart(detail: GestureDetail): boolean {
// Do not allow swipe gesture if a modal is open
const isModalPresented = !!document.querySelector('ion-modal.show-modal');
if (isModalPresented || !this.canSwipe()) {
return false;
}
if (this._isOpen) {
return true;
} 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 Animation).progressStart(true, this._isOpen ? 1 : 0);
}
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(this._isOpen ? 1 - stepValue : 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.currentTime;
// Account for rounding errors in JS
let newStepValue = shouldComplete ? 0.001 : -0.001;
/**
* 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)
*/
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([0, 0], [0.4, 0], [0.6, 1], [1, 1], clamp(0, adjustedStepValue, 0.9999))[0] || 0;
const playTo = this._isOpen ? !shouldComplete : shouldComplete;
this.animation
.easing('cubic-bezier(0.4, 0.0, 0.6, 1)')
.onFinish(() => this.afterAnimation(shouldOpen), { oneTimeCallback: true })
.progressEnd(playTo ? 1 : 0, this._isOpen ? 1 - newStepValue : 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);
/**
* We add a tabindex here so that focus trapping
* still works even if the menu does not have
* any focusable elements slotted inside. The
* focus trapping utility will fallback to focusing
* the menu so focus does not leave when the menu
* is open.
*/
this.el.setAttribute('tabindex', '0');
if (this.backdropEl) {
this.backdropEl.classList.add(SHOW_BACKDROP);
}
// add css class and hide content behind menu from screen readers
if (this.contentEl) {
this.contentEl.classList.add(MENU_CONTENT_OPEN);
/**
* When the menu is open and overlaying the main
* content, the main content should not be announced
* by the screenreader as the menu is the main
* focus. This is useful with screenreaders that have
* "read from top" gestures that read the entire
* page from top to bottom when activated.
* This should be done before the animation starts
* so that users cannot accidentally scroll
* the content while dragging a menu open.
*/
this.contentEl.setAttribute('aria-hidden', 'true');
}
this.blocker.block();
this.isAnimating = true;
if (shouldOpen) {
this.ionWillOpen.emit();
} else {
this.ionWillClose.emit();
}
}
private afterAnimation(isOpen: boolean) {
// 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) {
// emit open event
this.ionDidOpen.emit();
/**
* Move focus to the menu to prepare focus trapping, as long as
* it isn't already focused. Use the host element instead of the
* first descendant to avoid the scroll position jumping around.
*/
const focusedMenu = document.activeElement?.closest('ion-menu');
if (focusedMenu !== this.el) {
this.el.focus();
}
// start focus trapping
document.addEventListener('focus', this.handleFocus, true);
} else {
// remove css classes and unhide content from screen readers
this.el.classList.remove(SHOW_MENU);
/**
* Remove tabindex from the menu component
* so that is cannot be tabbed to.
*/
this.el.removeAttribute('tabindex');
if (this.contentEl) {
this.contentEl.classList.remove(MENU_CONTENT_OPEN);
/**
* Remove aria-hidden so screen readers
* can announce the main content again
* now that the menu is not the main focus.
*/
this.contentEl.removeAttribute('aria-hidden');
}
if (this.backdropEl) {
this.backdropEl.classList.remove(SHOW_BACKDROP);
}
if (this.animation) {
this.animation.stop();
}
// emit close event
this.ionDidClose.emit();
// undo focus trapping so multiple menus don't collide
document.removeEventListener('focus', this.handleFocus, true);
}
}
private updateState() {
const isActive = this._isActive();
if (this.gesture) {
this.gesture.enable(isActive && this.swipeGesture);
}
/**
* If the menu is disabled but it is still open
* then we should close the menu immediately.
* Additionally, if the menu is in the process
* of animating {open, close} and the menu is disabled
* then it should still be closed immediately.
*/
if (!isActive) {
/**
* It is possible to disable the menu while
* it is mid-animation. When this happens, we
* need to set the operationCancelled flag
* so that this._setOpen knows to return false
* and not run the "afterAnimation" callback.
*/
if (this.isAnimating) {
this.operationCancelled = true;
}
/**
* If the menu is disabled then we should
* forcibly close the menu even if it is open.
*/
this.afterAnimation(false);
}
}
render() {
const { type, disabled, isPaneVisible, inheritedAttributes, side } = this;
const mode = getIonMode(this);
/**
* If the Close Watcher is enabled then
* the ionBackButton listener in the menu controller
* will handle closing the menu when Escape is pressed.
*/
return (
<Host
onKeyDown={shouldUseCloseWatcher() ? null : this.onKeydown}
role="navigation"
aria-label={inheritedAttributes['aria-label'] || 'menu'}
class={{
[mode]: true,
[`menu-type-${type}`]: true,
'menu-enabled': !disabled,
[`menu-side-${side}`]: true,
'menu-pane-visible': isPaneVisible,
}}
>
<div class="menu-inner" part="container" ref={(el) => (this.menuInnerEl = el)}>
<slot></slot>
</div>
<ion-backdrop
ref={(el) => (this.backdropEl = el)}
class="menu-backdrop"
tappable={false}
stopPropagation={false}
part="backdrop"
/>
</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';