UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

259 lines (221 loc) 6.88 kB
import { LitElement, html, css } from "lit"; import { classMap } from "lit/directives/class-map.js"; import { onceParentAvailable } from "../../utils/widgets"; const OPENNESS_Y_PCT = { "opened": 0, "half-opened": 0.7, "closed": 1 }; /** * BottomDrawer layout offers a mobile-like drawer menu, coming from bottom of the screen. * @class Panoramax.components.layout.BottomDrawer * @element pnx-bottom-drawer * @extends [lit.LitElement](https://lit.dev/docs/api/LitElement/) * @slot `default` The drawer content * @example * ```html * <pnx-bottom-drawer openness="opened"> * <p>My drawer content</p> * </pnx-bottom-drawer> * ``` */ export default class BottomDrawer extends LitElement { /** @private */ static styles = css` :host { position: fixed; bottom: 0; left: 0; width: 100%; z-index: 130; pointer-events: none; touch-action: none; } .drawer { background: var(--widget-bg); border-top-left-radius: 16px; border-top-right-radius: 16px; box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; transition: transform 0.3s ease; will-change: transform; touch-action: auto; pointer-events: auto; } .drawer.dragging { transition: none; } .drawer.closed { transform: translateY(calc(100% - 30px)); } .drawer.half-opened { transform: translateY(70%); } .drawer.half-opened .content { overflow-y: hidden; } .drawer.opened { transform: translateY(0%); } .handle { height: 30px; display: flex; align-items: center; justify-content: center; cursor: pointer; } .handle-bar { width: 40px; height: 5px; background: var(--grey-pale); border-radius: 3px; } .content { overflow: auto; flex: 1; } `; /** * Component properties. * @memberof Panoramax.components.layout.BottomDrawer# * @type {Object} * @property {string} [openness=half-opened] How much the drawer should be opened (closed, half-opened, opened) */ static properties = { _fingerY: {state: true}, _deltaFingerY: {state: true}, _drawerY: {state: true}, _isDragging: {state: true}, _drawerHeight: {state: true}, openness: {type: String, reflect: true}, }; constructor() { super(); this._isDragging = false; this.openness = "half-opened"; } /** @private */ firstUpdated() { super.firstUpdated(); this._boundTouchMove = this._onTouchMove.bind(this); this._boundTouchEnd = this._onTouchEnd.bind(this); this._drawerHeight = window.innerHeight - 30; const drawer = this._getDrawer(); if (!drawer) { return; } drawer.style.height = `${this._drawerHeight}px`; drawer.style.maxHeight = `${this._drawerHeight}px`; onceParentAvailable(this).then(() => this._parent.onceReady()).then(() => { this._parent.map?.addEventListener("click", () => this.openness = "closed"); this._parent.psv?.addEventListener("click", () => this.openness = "closed"); }); } /** @private */ attributeChangedCallback(name, old, value) { super.attributeChangedCallback(name, old, value); if(name === "openness") { // If not fully opened, force content scroll back to top if(value !== "opened") { const content = this.shadowRoot.querySelector(".content"); if(content) { content.scrollTop = 0; } } // Remove hand-defined transform const drawer = this._getDrawer(); if (drawer) { drawer.style.transform = null; } } } /** @private */ disconnectedCallback() { super.disconnectedCallback(); this._cleanupTouchListeners(); } /** @private */ _getDrawer() { return this.shadowRoot?.querySelector(".drawer"); } /** @private */ _onHandleClick() { if(this.openness === "opened") { this.openness = "closed"; } else if(this.openness === "half-opened") { this.openness = "opened"; } else if(this.openness === "closed") { this.openness = "half-opened"; } } /** @private */ _onTouchStart(e) { this._isDragging = true; this._startFingerY = e.touches[0].clientY; this._deltaFingerY = 0; this._drawerY = this._drawerHeight * OPENNESS_Y_PCT[this.openness]; window.addEventListener("touchmove", this._boundTouchMove, { passive: true }); window.addEventListener("touchend", this._boundTouchEnd); window.addEventListener("touchcancel", this._boundTouchEnd); } /** @private */ _onTouchMove(e) { if (!this._isDragging) return; // Check if content has scroll bar, then only move if top scrolled const content = this.shadowRoot.querySelector(".content"); if(content.scrollHeight > content.offsetHeight && content.scrollTop > 0) { this._updateDrawerTransform(0); return; } this._deltaFingerY = e.touches[0].clientY - this._startFingerY; this._updateDrawerTransform(this._drawerY + this._deltaFingerY); } /** @private */ _onTouchEnd(e) { // Touchend is also called when "clicking" on mobile if ((!this._isDragging || Math.abs(this._deltaFingerY) < 30) && !e.target.closest(".handle")) { return; } e.preventDefault(); this._isDragging = false; if(this._deltaFingerY === 0 && this.openness === "closed") { this.openness = "half-opened"; } else { this._updateDrawerTransform(this._drawerY + this._deltaFingerY, true); } this._cleanupTouchListeners(); this._startFingerY = null; this._deltaFingerY = null; } /** @private */ _cleanupTouchListeners() { window.removeEventListener("touchmove", this._boundTouchMove); window.removeEventListener("touchend", this._boundTouchEnd); window.removeEventListener("touchcancel", this._boundTouchCancel); } /** @private */ _updateDrawerTransform(y, snap = false) { const drawer = this._getDrawer(); if (!drawer) { return; } y = Math.max(0, Math.min(y, this._drawerHeight - 30)); // Snap to nearest static position if(snap) { // Gesture goes up if(this._deltaFingerY < 0) { if(this.openness === "closed") { // How much it goes up ? if(Math.abs(this._deltaFingerY) > this._drawerHeight * (1-OPENNESS_Y_PCT["half-opened"])) { this.openness = "opened"; } else { this.openness = "half-opened"; } } else { this.openness = "opened"; } } // Gesture goes down else { this.openness = "closed"; } this._drawerY = null; y = Math.max(0, Math.min(OPENNESS_Y_PCT[this.openness] * this._drawerHeight, this._drawerHeight - 30)); } drawer.style.transform = `translateY(${y}px)`; } /** @private */ render() { const classes = { "drawer": true, [this.openness]: true, "dragging": this._isDragging, }; return html` <div class=${classMap(classes)} @touchstart="${this._onTouchStart}" @touchmove="${this._onTouchMove}" @touchend="${this._onTouchEnd}" > <div class="handle" @click=${this._onHandleClick}> <div class="handle-bar"></div> </div> <div class="content"> <slot></slot> </div> </div> `; } } customElements.define("pnx-bottom-drawer", BottomDrawer);