UNPKG

@ivteplo/html-sheet-element

Version:
399 lines (398 loc) 18.1 kB
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 };