@alegendstale/holly-components
Version:
Reusable UI components created using lit
421 lines (404 loc) • 13.2 kB
JavaScript
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
=${(e) => {
// Prevents clicks from reaching elements underneath sheet
e.stopPropagation();
this.toggleSheet();
}}
>
<button
id="handle"
part="handle"
=${this.pointerDown}
=${this.pointerMove}
=${this.pointerUp}
=${(e) => {
// Prevents clicks from reaching elements underneath sheet
e.stopPropagation();
}}
>
⸻
</button>
<div
id='content'
part="content"
tabindex='0'
=${this.touchDown}
=${this.touchUp}
=${this.touchMove}
=${this.contentScroll}
=${(e) => {
console.warn('User Agent touchcancelled the event.', e.target);
}}
=${(e) => {
// Prevent slot children from being dragged
e.preventDefault();
}}
=${(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;
}
}
-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 };