ember-mobile-menu
Version:
A draggable sidebar menu for Ember.
426 lines (423 loc) • 14.5 kB
JavaScript
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