UNPKG

@alegendstale/holly-components

Version:

Reusable UI components created using lit

421 lines (404 loc) 13.2 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { LitElement, html, css } from 'lit'; import { property, query } from 'lit/decorators.js'; import { EventEmitter } from '../../utils/EventEmitter.js'; import { condCustomElement } from '../../decorators/condCustomElement.js'; let BottomSheet = class BottomSheet extends LitElement { constructor() { super(...arguments); this.open = false; this.nonmodal = false; this.dragging = false; this.scrolling = false; this.snapPoints = []; this.defaultSnap = NaN; this.emitter = new EventEmitter(); this.startY = 0; this.startHeight = 0; this.previousTouch = null; this.lastScrollTop = 0; } set height(val) { const newHeight = Math.min(Math.max(val, 0), 100); this.emitter.emit('snapped', this.isSnapped(newHeight), this.shadowRoot?.activeElement); // Automatically fit content if height is infinity if (!isFinite(val)) this.style.setProperty('--height', `fit-content`); else this.style.setProperty('--height', `${Math.round(newHeight)}dvh`); } get height() { return parseInt(this.style.getPropertyValue('--height')); } connectedCallback() { super.connectedCallback(); // Make sure sheet snap doesn't break on index 2. const lengthWhen2 = this.snapPoints.length === 2 ? 0 : 1; this.defaultSnap = this.snapPoints.length > 0 ? isNaN(this.defaultSnap) ? this.snapPoints[Math.round(this.snapPoints.length / 2) - lengthWhen2] : this.defaultSnap : NaN; this.emitter.on('snapped', this.onSnapped.bind(this)); requestAnimationFrame(() => { this.showSheet(this.open); }); } disconnectedCallback() { super.disconnectedCallback(); this.emitter.clear(); } render() { requestAnimationFrame(() => { this.setDialog(this.open); }); return html ` <dialog @click=${(e) => { // Prevents clicks from reaching elements underneath sheet e.stopPropagation(); this.toggleSheet(); }} > <button id="handle" part="handle" @pointerdown=${this.pointerDown} @pointermove=${this.pointerMove} @pointerup=${this.pointerUp} @click=${(e) => { // Prevents clicks from reaching elements underneath sheet e.stopPropagation(); }} > ⸻ </button> <div id='content' part="content" tabindex='0' @touchstart=${this.touchDown} @touchend=${this.touchUp} @touchmove=${this.touchMove} @scroll=${this.contentScroll} @touchcancel=${(e) => { console.warn('User Agent touchcancelled the event.', e.target); }} @dragstart=${(e) => { // Prevent slot children from being dragged e.preventDefault(); }} @click=${(e) => { // Prevents clicks from reaching elements underneath sheet e.stopPropagation(); }} > <slot></slot> </div> </dialog> `; } /** * Find closest point index to target in array */ closestSnapPointIndex(snapPoints, target) { return snapPoints.reduce((closestIndex, value, index) => (Math.abs(value - target) < Math.abs(snapPoints[closestIndex] - target) ? index : closestIndex), 0); } /** * Sets sheet height to the closest snap * @param height percentage */ setSnap(height) { if (this.snapPoints.length === 0) return; const currentSnap = this.snapPoints[this.closestSnapPointIndex(this.snapPoints, height)]; // Hide sheet if current snap is the smallest if (currentSnap === this.snapPoints[0]) this.showSheet(false); this.height = currentSnap; } /** * @returns Whether sheet is snapped to a snap point */ isSnapped(height) { return this.snapPoints.some((val) => val === Math.round(height)); } /** * Sets the display state of the sheet */ showSheet(open) { this.open = open; this.height = isNaN(this.defaultSnap) ? Infinity : this.defaultSnap; this.setOverscroll(open); this.setDialog(open); } /** * Toggles the display state of the sheet */ toggleSheet() { this.showSheet(!this.open); } /** * Remove overscroll to prevent mobile browsers from refreshing during drag */ setOverscroll(open) { const htmlEl = document.querySelector('html'); const bodyEl = document.querySelector('body'); const behavior = open ? 'none' : 'unset'; htmlEl && htmlEl.style.setProperty('overscroll-behavior-block', behavior); bodyEl && bodyEl.style.setProperty('overscroll-behavior-block', behavior); } setDialog(open) { if (open && !this.dialog.open) { if (this.nonmodal) this.dialog.show(); else this.dialog.showModal(); } else if (open && this.dialog.open) { // If a modal dialog is open, close and open a nonmodal dialog if (this.nonmodal && this.modalDialog) { this.dialog.close(); this.dialog.show(); } // If a nonmodal dialog is open, close and open a modal dialog else if (!this.nonmodal && this.nonModalDialog) { this.dialog.close(); this.dialog.showModal(); } } else { this.dialog.close(); } } touchDown(e) { if (!(e.target instanceof HTMLElement)) return; this.startY = e.touches[0].pageY; this.startHeight = this.height; } touchMove(e) { // Ensure target has pointer capture if (!(e.target instanceof HTMLElement)) return; const touch = e.touches[0]; let movementY = NaN; if (this.previousTouch) { movementY = touch.pageY - this.previousTouch.pageY; } this.previousTouch = touch; let deltaY = this.startY - e.touches[0].pageY; const newHeight = this.startHeight + (deltaY / window.innerHeight) * 100; // Allow user to drag if (!this.scrolling && this.dragging) { this.height = newHeight; } // Allow user to scroll else if (this.scrolling && this.dragging || this.scrolling && !this.dragging) { e.target.focus({ preventScroll: true }); } /** * Allow user to scroll OR drag * Determined by first direction of movement */ else if (!this.scrolling && !this.dragging) { // Scroll Down if (movementY >= 0) { // Ensure the height of the container never goes above the middle snap point when scrolling down this.height = Math.min(newHeight, this.defaultSnap || this.snapPoints[this.snapPoints.length - 1]); } // Scroll Up else { // Allow user to scroll e.target.focus({ preventScroll: true }); } } } touchUp(e) { if (!(e.target instanceof HTMLElement)) return; this.previousTouch = null; this.setSnap(this.height); } pointerDown(e) { if (!(e.target instanceof HTMLElement)) return; e.target.setPointerCapture(e.pointerId); this.startY = e.pageY; this.startHeight = this.height; } pointerMove(e) { // Ensure target has pointer capture if (!(e.target instanceof HTMLElement && e.target.hasPointerCapture(e.pointerId))) return; // Ensure only one pointer is being tracked if (!e.isPrimary) return; // Prevents sheet drag from being interrupted e.preventDefault(); let deltaY = this.startY - e.pageY; const newHeight = this.startHeight + (deltaY / window.innerHeight) * 100; if (e.target.id === 'handle') { this.height = newHeight; } } pointerUp(e) { if (!(e.target instanceof HTMLElement)) return; e.target.releasePointerCapture(e.pointerId); this.setSnap(this.height); } onSnapped(snapped, activeElement) { this.dragging = !snapped; } contentScroll(e) { if (!(e.target instanceof HTMLElement && e.target.id === 'content')) return; const currentScrollTop = e.target.scrollTop; this.scrolling = currentScrollTop !== this.lastScrollTop; this.lastScrollTop = currentScrollTop; if (currentScrollTop === 0) this.scrolling = false; } }; BottomSheet.styles = [ css ` :host { contain: content; overscroll-behavior-block: none; --height: fit-content; } :host([dragging]) dialog { #content { cursor: grabbing; /* Important! Prevents mobile browsers from reclaiming PointerMove event on touch devices */ touch-action: none; /* Prevent weird scrollbar movement on mobile when dragging */ overflow-y: hidden; } } :host(:not([dragging])) dialog { /* Only animate if not dragging */ transition-property: overlay opacity display; transition-duration: 0.25s; transition-behavior: allow-discrete; &::backdrop { transition-property: overlay opacity display; transition-duration: 0.25s; transition-behavior: allow-discrete; } #content { /* Allow scroll */ touch-action: pan-y; } } dialog { display: none; opacity: 0; translate: 0 var(--height); height: var(--height); min-height: 0; max-height: 100%; min-width: 100%; max-width: 100%; flex-direction: column; box-sizing: border-box; padding: 0; border: 0; background-color: light-dark(#d9d9d9, #1c1c1c); /* Important! Removes default browser styling, allowing bottom to work */ inset: unset; bottom: 0; left: 0; overflow-y: hidden; /* Important! Prevents mobile browsers from reclaiming PointerMove event on touch devices */ touch-action: none; /* Prevent weird scrollbar movement on mobile when dragging */ overflow-y: hidden; &[open] { display: flex; opacity: 1; translate: 0 0; &::backdrop { opacity: 0.8; } } &::backdrop { background: black; opacity: 0; } } @starting-style { dialog[open] { opacity: 0; translate: 0 var(--height); &::backdrop { opacity: 0; } } } #handle { display: block; position: sticky; top: 0; background: transparent; border: 0; cursor: grabbing; min-height: 35px; user-select: none; touch-action: none; } div { /* display: contents; */ overflow-y: auto; scrollbar-width: none; box-sizing: border-box; min-height: 100%; min-width: 100%; } ::slotted(*) { /* Prevent slotted items from taking pointer events */ /* pointer-events: none; */ } `, ]; __decorate([ query('dialog') ], BottomSheet.prototype, "dialog", void 0); __decorate([ query('dialog:not(:modal)') ], BottomSheet.prototype, "nonModalDialog", void 0); __decorate([ query('dialog:modal') ], BottomSheet.prototype, "modalDialog", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], BottomSheet.prototype, "open", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], BottomSheet.prototype, "nonmodal", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], BottomSheet.prototype, "dragging", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], BottomSheet.prototype, "scrolling", void 0); __decorate([ property({ type: Array }) ], BottomSheet.prototype, "snapPoints", void 0); __decorate([ property({ type: Number }) ], BottomSheet.prototype, "defaultSnap", void 0); __decorate([ property({ type: Number, reflect: true }) /** * Sets the height of the sheet * Infinity can be used to fit content automatically * @param height percentage or infinity */ ], BottomSheet.prototype, "height", null); BottomSheet = __decorate([ condCustomElement('bottom-sheet') ], BottomSheet); export { BottomSheet };