UNPKG

melt

Version:

The next generation of Melt UI. Built for Svelte 5.

142 lines (141 loc) 4.91 kB
import { Synced } from "../Synced.svelte"; import { dataAttr } from "../utils/attribute"; import { extract } from "../utils/extract"; import { createBuilderMetadata } from "../utils/identifiers"; import { useScrollLock } from "../utils/scroll-lock.svelte"; import { tick } from "svelte"; import { createAttachmentKey } from "svelte/attachments"; import { on } from "svelte/events"; const { dataAttrs, createReferences } = createBuilderMetadata("dialog", [ "trigger", "content", "overlay", ]); export class Dialog { /* Props */ #props; closeOnEscape = $derived(extract(this.#props.closeOnEscape, true)); closeOnOutsideClick = $derived(extract(this.#props.closeOnOutsideClick, true)); scrollLock = $derived(extract(this.#props.scrollLock, true)); #ak = createAttachmentKey(); constructor(props = {}) { this.#props = props; this.#open = new Synced({ value: props.open, onChange: props.onOpenChange, defaultValue: false, }); } /* State */ refs = createReferences(); #open; // prettier-ignore get open() { return this.#open.current; } set open(v) { const el = this.refs.get("content"); const overlay = this.refs.get("overlay"); // Shouldn't happen, but... if (!(el instanceof HTMLDialogElement)) { this.#open.current = v; return; } const styles = getComputedStyle(el); const hasTransition = styles.transitionDuration !== "0s" && parseFloat(styles.transitionDuration) > 0; if (v) { overlay?.showPopover(); el.showModal(); // to allow for transitions to happen tick().then(() => (this.#open.current = v)); } else { this.#open.current = v; // if there is a transition, transitionend will deal with it if (!hasTransition) { el.close(); overlay?.hidePopover(); } } } get sharedProps() { return { "data-open": dataAttr(this.open), }; } /** The trigger element. */ get trigger() { return { [dataAttrs.trigger]: "", [this.refs.key]: this.refs.attach("trigger"), onclick: () => { this.open = !this.open; }, type: "button", ...this.sharedProps, }; } #contentAttachment = (node) => { useScrollLock(this.scrollLock && this.open); let prevSel = window.getSelection()?.toString(); const offs = [ on(node, "transitionend", () => { if (!this.open) { node.close(); this.refs.get("overlay")?.hidePopover(); } }), on(node, "cancel", (e) => { e.preventDefault(); if (!this.closeOnEscape) return; this.open = false; }), on(node, "pointerdown", () => { prevSel = window.getSelection()?.toString(); }), on(document, "pointerup", (e) => { if (!this.open || !this.closeOnOutsideClick) return; // Exit early if not open const currSel = window.getSelection()?.toString(); const hasNewSel = (currSel?.length ?? 0) > 0 && currSel !== prevSel; // Don't close if text is selected if (hasNewSel) return; const hasChildDialogsOpen = node.querySelector("dialog[open]"); if (hasChildDialogsOpen) return; // check if click was on backdrop const rect = node.getBoundingClientRect(); const isInDialog = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; if (isInDialog) return; this.open = false; }), ]; return () => offs.forEach((off) => off()); }; /** The element for the dialog itself. */ get content() { return { [dataAttrs.content]: "", [this.#ak]: this.#contentAttachment, onclose: () => { this.open = false; }, [this.refs.key]: this.refs.attach("content"), ...this.sharedProps, }; } /** Optional overlay element, to replace dialog::backdrop for animation support */ get overlay() { return { [dataAttrs.overlay]: "", popover: "manual", "aria-hidden": true, [this.refs.key]: this.refs.attach("overlay"), ...this.sharedProps, }; } }