@ivteplo/html-sheet-element
Version:
HTML Custom Element for Sheets
399 lines (398 loc) • 18.1 kB
JavaScript
const isFocused = (element) => document.activeElement === element;
const touchPosition = (event) => event.type === "touchend" ? event.changedTouches[0] : event.type.startsWith("touch") ? event.touches[0] : event;
function getCSSVariableValue(element, variableName) {
return getComputedStyle(element).getPropertyValue(variableName);
}
function elementContains(child, element) {
return element.contains(child) || element.shadowRoot?.contains(child);
}
const eventHandlerAttributePattern = /^on([A-Z][a-zA-Z]*)$/;
function createElement(tagName, attributes, ...children) {
const element = document.createElement(tagName);
attributes ??= {};
for (const [attribute, value] of Object.entries(attributes)) {
const eventHandlerPatternMatches = attribute.match(eventHandlerAttributePattern);
if (eventHandlerPatternMatches) {
const event = eventHandlerPatternMatches[1].toLowerCase();
element.addEventListener(event, value);
} else if (attribute === "reference") {
value?.(element);
} else {
element.setAttribute(attribute, value);
}
}
for (const child of children) {
switch (typeof child) {
case "undefined":
break;
case "string":
case "number":
case "bigint":
element.appendChild(document.createTextNode(String(child)));
break;
case "object":
if (child === null) {
break;
}
if (child instanceof HTMLElement) {
element.appendChild(child);
break;
}
// Note: if the child is not of type HTMLElement,
// we will go into the default handler too
default:
throw new Error("Unexpected type of child: " + typeof child);
}
}
return element;
}
function mapNumber(number, currentRange, newRange) {
const currentRangeSize = currentRange[1] - currentRange[0];
const newRangeSize = newRange[1] - newRange[0];
return (number - currentRange[0]) / currentRangeSize * newRangeSize + newRange[0];
}
function isFocusable(element) {
if (element.tabIndex < 0 || element.disabled) {
return false;
}
if (element instanceof HTMLAnchorElement) {
return Boolean(element.href);
}
if (element instanceof HTMLInputElement) {
return element.type !== "hidden";
}
return element instanceof HTMLElement;
}
function focusOnFirstDescendantOf(element) {
for (const child of element.childNodes) {
if (isFocusable(child)) {
child.focus();
return true;
}
if (focusOnFirstDescendantOf(child)) {
return true;
}
}
return false;
}
const styles = "/*\n Copyright (c) 2022-2025 Ivan Teplov\n Licensed under the Apache license 2.0\n*/\n\n:host {\n --_sheet-foreground-color: var(--sheet-foreground-color, inherit);\n --_sheet-background-color: var(--sheet-background-color, #fff);\n\n --_sheet-border-radius: var(--sheet-border-radius, 1rem);\n\n --_sheet-min-width: var(--sheet-min-width, 18rem);\n --_sheet-width: var(--sheet-width, 90vw);\n --_sheet-max-width: var(--sheet-max-width, auto);\n\n --_sheet-min-height: var(--sheet-min-height, 30vh);\n --_sheet-height: var(--sheet-height, auto);\n --_sheet-max-height: var(--sheet-max-height, 100vh);\n\n --_sheet-scale-down-to: 0.5;\n --_sheet-z-index: var(--sheet-z-index, 1);\n --_sheet-transition-duration: var(--sheet-transition-duration, 0.5s);\n\n --_sheet-backdrop-color: var(--sheet-backdrop-color, #88888880);\n\n --_sheet-header-padding: var(--sheet-header-padding, 0 0 0 1rem);\n --_sheet-title-margin: var(--sheet-title-margin, 0.5rem 0);\n --_sheet-body-padding: var(--sheet-body-padding, 0 1rem 1rem 1rem);\n\n --_sheet-handle-width: var(--sheet-handle-width, 3rem);\n --_sheet-handle-height: var(--sheet-handle-height, 0.25rem);\n --_sheet-handle-color: var(--sheet-handle-color, #eee);\n --_sheet-handle-border-radius: var(--sheet-handle-border-radius, 0.125rem);\n --_sheet-handle-container-padding: var(--sheet-handle-container-padding, 1rem);\n}\n\n@media (prefers-color-scheme: dark) {\n :host {\n --_sheet-background-color: var(--sheet-background-color, black);\n --_sheet-foreground-color: var(--sheet-foreground-color, white);\n --_sheet-handle-color: var(--sheet-handle-color, #333333);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n :host {\n --_sheet-transition-duration: var(--sheet-transition-duration, 0.1s);\n }\n}\n\n/* tablet */\n@media (min-width: 48rem) {\n :host {\n --_sheet-width: var(--sheet-width, auto);\n --_sheet-max-width: var(--sheet-max-width, 48rem);\n --_sheet-max-height: var(--sheet-max-height, 32rem);\n }\n}\n\n:host {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: flex-end;\n\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: var(--_sheet-z-index);\n\n transition:\n opacity var(--_sheet-transition-duration),\n visibility var(--_sheet-transition-duration);\n}\n\n:host(:not([open])) {\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n}\n\n/* ::backdrop is not supported :( */\n.sheet-backdrop {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background-color: var(--_sheet-backdrop-color);\n}\n\n.sheet-contents {\n display: flex;\n flex-direction: column;\n\n border-radius: var(--_sheet-border-radius) var(--_sheet-border-radius) 0 0;\n\n background: var(--_sheet-background-color);\n\n overflow-y: hidden;\n\n transform: translateY(0) scale(1);\n\n min-width: var(--_sheet-min-width);\n width: var(--_sheet-width);\n max-width: var(--_sheet-max-width);\n\n min-height: var(--_sheet-min-height);\n height: var(--_sheet-height);\n max-height: var(--_sheet-max-height);\n\n box-sizing: border-box;\n\n transition:\n transform var(--_sheet-transition-duration),\n border-radius var(--_sheet-transition-duration);\n}\n\n:host(:not([open])) .sheet-contents {\n transform: translateY(100%) scale(var(--_sheet-scale-down-to));\n}\n\n.sheet-contents.is-resized {\n user-select: none;\n}\n\n.sheet-controls {\n display: grid;\n grid-template-columns: 1fr auto 1fr;\n align-items: stretch;\n padding: var(--_sheet-header-padding);\n}\n\n.sheet-title-area {\n display: flex;\n justify-content: flex-start;\n}\n\n.sheet-title-area:not(:empty) {\n padding: var(--_sheet-title-margin);\n}\n\n.sheet-handle-container {\n display: flex;\n flex-direction: column;\n justify-content: center;\n\n min-height: var(--_sheet-handle-height);\n height: 100%;\n\n padding: var(--_sheet-handle-container-padding);\n box-sizing: border-box;\n\n margin: auto;\n cursor: grab;\n}\n\n.sheet-handle {\n width: var(--_sheet-handle-width);\n height: var(--_sheet-handle-height);\n background: var(--_sheet-handle-color);\n border-radius: var(--_sheet-handle-border-radius);\n}\n\n.sheet-button-area {\n display: flex;\n justify-content: flex-end;\n}\n\n.sheet-close-button {\n border: none;\n padding: 0.7rem;\n background: transparent;\n cursor: pointer;\n color: inherit;\n font-weight: 500;\n}\n\n.sheet-body {\n flex-grow: 1;\n height: 100%;\n\n display: flex;\n flex-direction: column;\n\n overflow-y: auto;\n\n padding: var(--_sheet-body-padding);\n box-sizing: border-box;\n}\n\n/* tablet */\n@media (min-width: 48rem) {\n :host {\n justify-content: center;\n }\n\n .sheet-contents {\n border-radius: var(--_sheet-border-radius);\n }\n\n .sheet-handle-container {\n display: none;\n }\n\n .sheet-controls {\n grid-template-columns: 1fr auto auto;\n }\n}\n";
const styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(styles);
class SheetElement extends HTMLElement {
/**
* Function to define the sheet element in the HTML Custom Element Registry
* @param {string} tag - the tag name for the sheet element
* @example
* import SheetElement from "@ivteplo/html-sheet-element"
* SheetElement.defineAs("ui-sheet")
*/
static defineAs(tag) {
customElements.define(tag, this, {});
}
/**
* Inner wrapper
* @type {HTMLDivElement}
*/
#sheet;
/**
* Gray area on the top of the sheet to resize the sheet
* @type {HTMLElement}
*/
#handle;
#scaleDownTo;
/** Just methods with 'this' binded */
#eventListeners = {
onDragMove: this.#onDragMove.bind(this),
onDragStart: this.#onDragStart.bind(this),
onDragEnd: this.#onDragEnd.bind(this),
onKeyUp: this.#onKeyUp.bind(this),
onCloseButtonClick: this.#onCloseButtonClick.bind(this),
onBackdropClick: this.#onBackdropClick.bind(this),
onSubmit: this.#onSubmit.bind(this)
};
/**
* Options for behavior customization
*
* @example <caption>Make the sheet <i>not</i> close on backdrop click</caption>
* <ui-sheet ignore-backdrop-click>
* ...
* </ui-sheet>
*
* @example <caption>Make the sheet <i>not</i> close when pressing Escape</caption>
* <ui-sheet ignore-escape-key>
* ...
* </ui-sheet>
*
* @example <caption>Make the sheet <i>not</i> close when dragging it down</caption>
* <ui-sheet ignore-dragging-down>
* ...
* </ui-sheet>
*/
options = {
closeOnBackdropClick: true,
closeOnEscapeKey: true,
closeOnDraggingDown: true
};
/**
* Gets or sets the return value for the sheet, usually to indicate which button the user pressed to close it.
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/returnValue
* @type {string}
*/
returnValue = "";
#performedInitialization = false;
constructor() {
super();
const shadowRoot = this.attachShadow({
mode: "open"
});
shadowRoot.adoptedStyleSheets = [styleSheet];
shadowRoot.append(
/* @__PURE__ */ createElement("div", { class: "sheet-backdrop", onClick: this.#eventListeners.onBackdropClick }),
/* @__PURE__ */ createElement("div", { class: "sheet-contents", reference: (sheet) => this.#sheet = sheet }, /* @__PURE__ */ createElement("header", { class: "sheet-controls" }, /* @__PURE__ */ createElement("div", { class: "sheet-title-area" }, /* @__PURE__ */ createElement("slot", { name: "title-area" })), /* @__PURE__ */ createElement(
"div",
{
class: "sheet-handle-container",
reference: (area) => this.#handle = area,
onMouseDown: this.#eventListeners.onDragStart,
onTouchStart: this.#eventListeners.onDragStart
},
/* @__PURE__ */ createElement("div", { class: "sheet-handle" })
), /* @__PURE__ */ createElement("div", { class: "sheet-button-area" }, /* @__PURE__ */ createElement("slot", { name: "button-area" }, /* @__PURE__ */ createElement(
"button",
{
type: "button",
"aria-controls": this?.id ?? "",
class: "sheet-close-button",
onClick: this.#eventListeners.onCloseButtonClick,
title: "Close the sheet"
},
"×"
)))), /* @__PURE__ */ createElement("main", { class: "sheet-body" }, /* @__PURE__ */ createElement("slot", null)))
);
}
/**
* Attaches event listeners to the window when the sheet is mounted
* @ignore
*/
connectedCallback() {
if (!this.#performedInitialization) {
this.role = "dialog";
this.ariaModal = true;
this.addEventListener("submit", this.#eventListeners.onSubmit);
Object.defineProperties(this.options, {
closeOnBackdropClick: {
get: () => [null, "false"].includes(
this.getAttribute("ignore-backdrop-click")
),
set: (value) => Boolean(value) ? this.removeAttribute("ignore-backdrop-click") : this.setAttribute("ignore-backdrop-click", true)
},
closeOnEscapeKey: {
get: () => [null, "false"].includes(
this.getAttribute("ignore-escape-key")
),
set: (value) => Boolean(value) ? this.removeAttribute("ignore-escape-key") : this.setAttribute("ignore-escape-key", true)
},
closeOnDraggingDown: {
get: () => [null, "false"].includes(
this.getAttribute("ignore-dragging-down")
),
set: (value) => Boolean(value) ? this.removeAttribute("ignore-dragging-down") : this.setAttribute("ignore-dragging-down", true)
}
});
this.#performedInitialization = true;
}
window.addEventListener("keyup", this.#eventListeners.onKeyUp);
window.addEventListener("mousemove", this.#eventListeners.onDragMove);
window.addEventListener("touchmove", this.#eventListeners.onDragMove);
window.addEventListener("mouseup", this.#eventListeners.onDragEnd);
window.addEventListener("touchend", this.#eventListeners.onDragEnd);
}
/**
* Removes all the event listeners when the sheet is no longer mounted
* @ignore
*/
disconnectedCallback() {
window.removeEventListener("keyup", this.#eventListeners.onKeyUp);
window.removeEventListener("mousemove", this.#eventListeners.onDragMove);
window.removeEventListener("touchmove", this.#eventListeners.onDragMove);
window.removeEventListener("mouseup", this.#eventListeners.onDragEnd);
window.removeEventListener("touchend", this.#eventListeners.onDragEnd);
}
/**
* Open the sheet
*/
showModal() {
if (!this.hasAttribute("open")) {
this.setAttribute("open", true);
focusOnFirstDescendantOf(this);
const event = new CustomEvent("open", {
bubbles: false,
cancelable: false
});
this.dispatchEvent(event);
}
}
/**
* Open the sheet
*/
show() {
this.showModal();
}
/**
* Collapse the sheet
*/
close() {
if (!this.hasAttribute("open")) {
return;
}
this.removeAttribute("open");
const event = new CustomEvent("close", {
bubbles: false,
cancelable: false
});
this.dispatchEvent(event);
}
/**
* Close the sheet when the form hasn't been submitted
*/
#cancelAndCloseIfApplicable() {
if (!this.hasAttribute("open")) {
return;
}
const event = new CustomEvent("cancel", {
bubbles: false,
cancelable: true
});
const isDefaultBehaviorNotPrevented = this.dispatchEvent(event);
if (isDefaultBehaviorNotPrevented) {
this.close();
}
}
/**
* Check if the sheet is open
* @returns {boolean}
*/
get open() {
return this.hasAttribute("open");
}
/**
* An alternative way to open or close the sheet
* @param {boolean} value
* @returns {boolean}
* @example
* sheet.open = true // the same as executing sheet.show()
* sheet.open = false // the same as executing sheet.close()
*/
set open(value) {
if (value === false || value === void 0) {
this.close();
return false;
} else {
this.show();
return true;
}
}
/**
* On submit of a form inside of the sheet
* @param {SubmitEvent} event
* @returns {void}
*/
#onSubmit(event) {
const form = event.target;
const button = event.submitter;
if (form?.method === "dialog" || button?.formMethod === "dialog") {
event.stopImmediatePropagation();
event.preventDefault();
this.returnValue = button?.value ?? "";
this.close();
}
}
/**
* Hide the sheet when clicking at the backdrop
* @returns {void}
*/
#onBackdropClick() {
if (this.options.closeOnBackdropClick) {
this.#cancelAndCloseIfApplicable();
}
}
/**
* Hide the sheet when clicking at the 'close' button
* @returns {void}
*/
#onCloseButtonClick() {
this.#cancelAndCloseIfApplicable();
}
/**
* Hide the sheet when pressing Escape if the target element is not an input field
* @param {KeyboardEvent} event
* @returns {void}
*/
#onKeyUp(event) {
const isSheetElementFocused = elementContains(event.target, this) && isFocused(event.target);
if (event.key === "Escape" && !isSheetElementFocused && this.options.closeOnEscapeKey) {
this.#cancelAndCloseIfApplicable();
}
}
#dragPosition;
/**
* Function that changes sheet's size and location during the dragging process
* @param {number} distanceToTheBottomInPercents - percents relative to the height of the sheet
*/
#dragSheet(distanceToTheBottomInPercents) {
const translateY = 100 - distanceToTheBottomInPercents;
const scale = mapNumber(distanceToTheBottomInPercents, [0, 100], [this.#scaleDownTo, 1]);
this.#sheet.style.transform = `translateY(${translateY}%) scale(${scale})`;
this.#sheet.style.transition = "none";
}
/**
* Gets called when the user starts grabbing the 'sheet thumb'
* @param {MouseEvent|TouchEvent} event
* @returns {void}
*/
#onDragStart(event) {
this.#dragPosition = touchPosition(event).pageY;
this.#sheet.classList.add("is-resized");
this.#handle.style.cursor = document.body.style.cursor = "grabbing";
this.#scaleDownTo = +getCSSVariableValue(this.#sheet, "--scale-down-to");
}
/**
* Distance from the cursor to the bottom of the sheet in percents (relative to the sheet height)
*/
#getDistanceToTheBottomInPercents(y) {
const deltaY = this.#dragPosition - y;
const distanceToTheBottomInPercents = 100 + deltaY / this.#sheet.clientHeight * 100;
return Math.max(0, Math.min(100, distanceToTheBottomInPercents));
}
/**
* Gets called when the user is moving the 'sheet thumb'.
* Updates the height of the sheet
* @param {MouseEvent|TouchEvent} event
* @returns {void}
*/
#onDragMove(event) {
if (this.#dragPosition === void 0) return;
this.#dragSheet(this.#getDistanceToTheBottomInPercents(touchPosition(event).pageY));
}
/**
* Get called when the user stops grabbing the sheet
* @param {MouseEvent|TouchEvent} event
* @returns {void}
*/
#onDragEnd(event) {
if (this.#dragPosition === void 0) return;
const distanceToTheBottomInPercents = this.#getDistanceToTheBottomInPercents(touchPosition(event).pageY);
if (distanceToTheBottomInPercents < 75 && this.options.closeOnDraggingDown) {
this.close();
}
this.#handle.style.cursor = document.body.style.cursor = "";
this.#dragPosition = void 0;
this.#sheet.classList.remove("is-resized");
this.#sheet.style.transform = "";
this.#sheet.style.transition = "";
}
}
export {
SheetElement,
SheetElement as default
};