@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
259 lines (221 loc) • 6.88 kB
JavaScript
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)}
="${this._onTouchStart}"
="${this._onTouchMove}"
="${this._onTouchEnd}"
>
<div class="handle" =${this._onHandleClick}>
<div class="handle-bar"></div>
</div>
<div class="content">
<slot></slot>
</div>
</div>
`;
}
}
customElements.define("pnx-bottom-drawer", BottomDrawer);