UNPKG

ember-mobile-menu

Version:
426 lines (423 loc) 14.5 kB
import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { TrackedSet } from 'tracked-built-ins'; import MobileMenu from './mobile-menu.js'; import normalizeCoordinates, { scaleCorrection } from '../utils/normalize-coordinates.js'; import { getOwner } from '@ember/application'; import { assert } from '@ember/debug'; import { waitFor } from '@ember/test-waiters'; import { task } from 'ember-concurrency'; import Spring from '../spring.js'; import './mobile-menu-wrapper.css'; import didInsert from '@ember/render-modifiers/modifiers/did-insert'; import onResize from 'ember-on-resize-modifier/modifiers/on-resize'; import setBodyClass from 'ember-set-body-class/helpers/set-body-class'; import { hash } from '@ember/helper'; import MobileMenuToggle from './mobile-menu-toggle.js'; import ContentComponent from './mobile-menu-wrapper/content.js'; import { precompileTemplate } from '@ember/template-compilation'; import { setComponentTemplate } from '@ember/component'; import { g, i, n } from 'decorator-transforms/runtime'; const isIOSDevice = typeof window !== 'undefined' && window.navigator?.platform && (/iP(ad|hone|od)/.test(window.navigator.platform) || window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1); /** * Wrapper component for menu's. Provides pan recognition and management. * * @class MobileMenuWrapper * @yield {Hash} wrapper * @yield {MobileMenu component} wrapper.MobileMenu * @yield {Content component} wrapper.Content * @yield {MobileMenuToggle component} wrapper.Toggle * @yield {Hash} wrapper.actions * @yield {number} position Current position of the active menu in px. * @yield {number} relativePosition Current position of the active menu between 0 and 1. * @yield {Action} wrapper.actions.toggle * @yield {Action} wrapper.actions.close * @public */ class MobileMenuWrapper extends Component { get fastboot() { return getOwner(this).lookup('service:fastboot'); } get isFastBoot() { return !!this.fastboot?.isFastBoot; } /** * Current BoundingClientRect of the mobile menu wrapper root element * * @property boundingClientRect * @type {DOMRect} * @default null * @private */ static { g(this.prototype, "boundingClientRect", [tracked], function () { return null; }); } #boundingClientRect = (i(this, "boundingClientRect"), void 0); static { g(this.prototype, "children", [tracked], function () { return new TrackedSet(); }); } #children = (i(this, "children"), void 0); static { g(this.prototype, "position", [tracked], function () { return 0; }); } #position = (i(this, "position"), void 0); static { g(this.prototype, "dragging", [tracked], function () { return false; }); } #dragging = (i(this, "dragging"), void 0); fromPosition = 0; fromOpen = false; defaultMenuDx = 0; preservedVelocity = 0; _activeMenu = null; /** * Horizontal width of the detection zone in pixels. Set to -1 to use full width. * * @argument openDetectionWidth * @type Number * @default 15 */ get openDetectionWidth() { return this.args.openDetectionWidth ?? 15; } /** * If true the capture phase will be used for the event, giving it precedence over events in the (default) * bubble phase. This is handy for menus as they are usually defined high in the dom, are opened with edge gestures * and thus must take precedence over deeper nested elements by using the capture phase. * * See <https://www.w3.org/TR/DOM-Level-3-Events/#event-flow> for more details. * * @argument capture * @type Boolean * @default true */ get capture() { return this.args.capture ?? true; } /** * If true, the component tries to prevent scroll when a menu is open * * @argument preventScroll * @type Boolean * @default false */ get preventScroll() { return this.args.preventScroll ?? true; } /** * @argument embed * @type Boolean * @default false */ get embed() { return this.args.embed ?? false; } get triggerVelocity() { return this.args.triggerVelocity ?? 0.3; } /** * The currently active menu component. * * @property activeMenu * @type MobileMenu * @default null * @private */ get activeMenu() { if (this.isFastBoot && !this.children.length && this._activeMenu) { return this._activeMenu; } if (this.leftMenu && this.position > 0) { return this.leftMenu; } else if (this.rightMenu && this.position < 0) { return this.rightMenu; } else { return null; } } get isOpen() { return !!this.activeMenu?.state.open; } get isNotClosed() { return this.activeMenu && !this.activeMenu.state.closed; } get mode() { return this.activeMenu?.mode; } get contentShadowEnabled() { return this.activeMenu?.shadowEnabled && ['reveal', 'ios', 'squeeze-reveal'].includes(this.mode); } get requiresUpdatedPosition() { return this.mode !== 'default'; } registerChild(component) { assert('component was already registered as a child', !this.children.has(component)); this.children.add(component); } static { n(this.prototype, "registerChild", [action]); } unregisterChild(component) { this.children.delete(component); } static { n(this.prototype, "unregisterChild", [action]); } get childMenus() { return [...this.children].filter(view => view instanceof MobileMenu); } get leftMenu() { return this.childMenus.find(menu => menu.isLeft); } get rightMenu() { return this.childMenus.find(menu => menu.isRight); } get preventBodyScroll() { return this.preventScroll && !this.embed && this.isNotClosed && this.activeMenu?.maskEnabled; } get relativePosition() { return this.activeMenu ? Math.abs(this.position) / this.activeMenu._width : 0; } toggle(target) { let targetMenu = this.leftMenu; if (target === 'right') { targetMenu = this.rightMenu; } else if (target === 'left') { targetMenu = this.leftMenu; } else if (this.rightMenu && !this.leftMenu) { targetMenu = this.rightMenu; } if (targetMenu) { this.close(); if (this.activeMenu !== targetMenu) { this.open(targetMenu); } } } static { n(this.prototype, "toggle", [action]); } updatePosition(pan) { const { initial: { x: initialX }, current: { distanceX } } = pan; let distance = distanceX + this.fromPosition; if (this.dragging && this.fromOpen) { const menu = this.fromMenu; // default menu dx correction if (this.mode === 'default') { if (menu.isLeft && initialX > menu._width) { this.defaultMenuDx = initialX - menu._width; if (initialX + distanceX > menu._width) { return; } } else if (menu.isRight && initialX < this.boundingClientRect.width - menu._width) { this.defaultMenuDx = initialX - (this.boundingClientRect.width - menu._width); if (initialX + distanceX < this.boundingClientRect.width - menu._width) { return; } } else { this.defaultMenuDx = 0; } distance += this.defaultMenuDx; } if (menu.isLeft) { this.position = Math.min(Math.max(distance, 0), menu._width); } else { this.position = Math.max(Math.min(distance, 0), -1 * menu._width); } } else if (this.dragging && (this.leftMenu && distance > 0 || this.rightMenu && distance < 0)) { const menu = distance > 0 ? this.leftMenu : this.rightMenu; this.position = Math.min(Math.max(Math.abs(distance), 0), menu._width) * (distance > 0 ? 1 : -1); } else if (this.position !== 0) { this.position = 0; } } static { n(this.prototype, "updatePosition", [action]); } didPanStart(e) { if (this.finishTransitionTask.isRunning) { this.finishTransitionTask.cancelAll(); this.preservedVelocity = 0; } // don't conflict with iOS browser's drag to go back/forward functionality if (this._isIOSbrowser && (e.initial.x < 15 || e.initial.x > this._windowWidth - 15)) { return; } const fromOpen = this.isOpen; const pan = scaleCorrection(normalizeCoordinates(e, this.boundingClientRect), this.scaleX, this.scaleY); if (fromOpen || this.openDetectionWidth < 0 || this.leftMenu && pan.initial.x <= this.openDetectionWidth || this.rightMenu && pan.initial.x >= this.boundingClientRect.width - this.openDetectionWidth) { this.fromOpen = fromOpen; this.fromMenu = this.activeMenu; this.fromPosition = this.position; this.dragging = true; this.updatePosition(pan); } } static { n(this.prototype, "didPanStart", [action]); } didPan(e) { if (this.dragging) { this.updatePosition(scaleCorrection(normalizeCoordinates(e, this.boundingClientRect), this.scaleX, this.scaleY)); } } static { n(this.prototype, "didPan", [action]); } didPanEnd(e) { if (this.dragging) { this.dragging = false; const pan = scaleCorrection(normalizeCoordinates(e, this.boundingClientRect), this.scaleX, this.scaleY); const menu = this.activeMenu; if (menu) { const { current: { distanceX, velocityX } } = pan; const isLeft = menu.isLeft; const width = menu._width; const condition = isLeft && !this.fromOpen || this.fromOpen && !isLeft; const vx = condition ? velocityX : -velocityX; let dx = condition ? distanceX : -distanceX; // default menu dx correction if (this.fromOpen && this.mode === 'default') { if (isLeft) { dx -= this.defaultMenuDx; } else { dx += this.defaultMenuDx; } } // the pan action is over, cleanup and set the correct final menu position if (!this.fromOpen) { if (vx > this.triggerVelocity || dx > width / 2) { this.open(menu, velocityX); } else { this.close(menu, velocityX); } } else { if (this.mode === 'default' ? vx > this.triggerVelocity && dx > 0 || dx > width / 2 : vx > this.triggerVelocity || dx > width / 2) { this.close(menu, velocityX); } else { this.open(menu, velocityX); } } } } } static { n(this.prototype, "didPanEnd", [action]); } *finishTransitionTask(menu, targetPosition = 'open', currentVelocity = 0, animate = true) { const fromValue = this.position; const toValue = targetPosition === 'close' ? 0 : (menu.isLeft ? 1 : -1) * menu._width; if (fromValue !== toValue && animate) { const spring = new Spring(s => this.position = s.currentValue, { stiffness: 1000, mass: 3, damping: 500, overshootClamping: true, fromValue, toValue, initialVelocity: this.preservedVelocity || currentVelocity }); try { yield spring.start(); } finally { spring.stop(); this.preservedVelocity = spring.currentVelocity; } } else { this.position = toValue; this.preservedVelocity = 0; } } static { n(this.prototype, "finishTransitionTask", [waitFor, task({ restartable: true })]); } open(menu = this.activeMenu, currentVelocity, animate) { this.finishTransitionTask.perform(menu, 'open', currentVelocity, animate); } static { n(this.prototype, "open", [action]); } close(menu = this.activeMenu, currentVelocity, animate) { this.finishTransitionTask.perform(menu, 'close', currentVelocity, animate); } static { n(this.prototype, "close", [action]); } scaleX = 1; scaleY = 1; onInsert(element) { this.boundingClientRect = element.getBoundingClientRect(); this.updateScale(element); } static { n(this.prototype, "onInsert", [action]); } onResize({ target }) { this.boundingClientRect = target.getBoundingClientRect(); this.updateScale(target); } static { n(this.prototype, "onResize", [action]); } updateScale(element) { this.scaleX = this.boundingClientRect.width / element.clientWidth; this.scaleY = this.boundingClientRect.height / element.clientHeight; } /** * Detect if the user is using the app from a browser on iOS * * @method _isIOSbrowser * @return {Boolean} Returns true when the user is using iOS and is inside a browser * @private */ static { n(this.prototype, "updateScale", [action]); } get _isIOSbrowser() { return isIOSDevice && !window.navigator.standalone; } get _windowWidth() { return window.innerWidth; } static { setComponentTemplate(precompileTemplate("\n {{#if this.preventBodyScroll}}\n {{setBodyClass \"mobile-menu--prevent-scroll\"}}\n {{/if}}\n\n <div class=\"mobile-menu-wrapper\n {{if this.embed \"mobile-menu-wrapper--embedded\"}}\" {{didInsert this.onInsert}} {{onResize this.onResize}} ...attributes>\n {{yield (hash MobileMenu=(component MobileMenuComponent isDragging=this.dragging position=this.position embed=this.embed parentBoundingClientRect=this.boundingClientRect parent=this register=this.registerChild unregister=this.unregisterChild onClose=this.close onOpen=this.open onPanStart=this.didPanStart onPan=this.didPan onPanEnd=this.didPanEnd capture=this.capture preventScroll=this.preventScroll) Toggle=(component ToggleComponent onClick=this.toggle) Content=(component ContentComponent shadowEnabled=this.contentShadowEnabled position=this.position mode=this.mode isOpen=this.activeMenu maskEnabled=this.activeMenu.maskEnabled onPanStart=this.didPanStart onPan=this.didPan onPanEnd=this.didPanEnd capture=this.capture preventScroll=this.preventScroll onClose=this.close) position=this.position relativePosition=this.relativePosition actions=(hash toggle=this.toggle close=this.close))}}\n </div>\n ", { strictMode: true, scope: () => ({ setBodyClass, didInsert, onResize, hash, MobileMenuComponent: MobileMenu, ToggleComponent: MobileMenuToggle, ContentComponent }) }), this); } } export { MobileMenuWrapper as default }; //# sourceMappingURL=mobile-menu-wrapper.js.map