UNPKG

melt

Version:

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

292 lines (291 loc) 11.2 kB
import { Synced } from "../Synced.svelte"; import { dataAttr, styleAttr } from "../utils/attribute"; import { extract } from "../utils/extract"; import { createBuilderMetadata, createId } from "../utils/identifiers"; import { isFunction, isHtmlElement } from "../utils/is"; import { autoOpenPopover, safelyHidePopover } from "../utils/popover"; import { useFloating, } from "../utils/use-floating.svelte"; import { size } from "@floating-ui/dom"; import { dequal } from "dequal"; import { watch } from "runed"; import { createAttachmentKey } from "svelte/attachments"; import { on } from "svelte/events"; import * as focusTrap from "focus-trap"; // ESM import { untrack } from "svelte"; const { dataAttrs, dataSelectors } = createBuilderMetadata("popover", [ "trigger", "content", "arrow", ]); export const isCloseOnOutsideClickCheck = (value) => isFunction(value) && value.length === 1; function getHtmlElement(el) { const elm = typeof el === "string" ? document.querySelector(el) : el; if (!isHtmlElement(elm)) return undefined; return elm; } export class BasePopover { /* Props */ #props; forceVisible = $derived(extract(this.#props.forceVisible, false)); closeOnEscape = $derived(extract(this.#props.closeOnEscape, true)); sameWidth = $derived(extract(this.#props.sameWidth, false)); closeOnOutsideClick = $derived(extract(this.#props.closeOnOutsideClick, true)); floatingConfig = $derived.by(() => { const config = extract(this.#props.floatingConfig, {}); const sameWidth = extract(this.#props.sameWidth); if (sameWidth !== undefined) { config.sameWidth = sameWidth; } config.computePosition = { ...config.computePosition, middleware: [ ...(config.computePosition?.middleware ?? []), size({ apply: ({ availableWidth, availableHeight }) => { this.availableWidth = availableWidth; this.availableHeight = availableHeight; }, }), { name: "grabInvokerRect", fn: ({ rects }) => { const prev = $state.snapshot(this.invokerRect); const curr = rects.reference; if (dequal(prev, curr)) return {}; this.invokerRect = rects.reference; return {}; }, }, ], }; return config; }); focus = $derived.by(() => ({ onOpen: extract(this.#props.focus?.onOpen, `#${this.ids.popover}`), onClose: extract(this.#props.focus?.onClose, this.triggerEl), trap: extract(this.#props.focus?.trap, false), })); /* State */ ids = $state({ popover: createId() }); invokerRect = $state(); availableWidth = $state(); availableHeight = $state(); triggerEl = $state(null); #open; constructor(props = {}) { this.#open = new Synced({ value: props.open, onChange: props.onOpenChange, defaultValue: false, }); this.#props = props; } get open() { return this.#open.current; } set open(value) { this.#open.current = value; } #shouldClose(el) { if (this.closeOnOutsideClick === false) return false; if (isFunction(this.closeOnOutsideClick)) { return isCloseOnOutsideClickCheck(this.closeOnOutsideClick) ? this.closeOnOutsideClick(el) // Pass target if it's the correct type : this.closeOnOutsideClick(); // Otherwise, call without arguments } return true; } get sharedProps() { // Track the last focused element before any potential deletion let lastFocusedElement = null; return { onfocus: (event) => { // Update our tracked element whenever focus happens lastFocusedElement = event.target; }, onfocusout: async (event) => { if (!this.triggerEl) return; await new Promise((r) => setTimeout(r, 0)); const contentEl = document.getElementById(this.ids.popover); const relatedTarget = event.relatedTarget; // Use the related target from the event when possible const newFocusElement = relatedTarget || document.activeElement; // If we can't determine where focus went, use our tracked element const targetElement = newFocusElement === document.body ? lastFocusedElement : newFocusElement; if (!targetElement || contentEl?.contains(targetElement) || this.triggerEl?.contains(targetElement) || !this.#shouldClose(targetElement)) { return; } this.open = false; }, style: styleAttr({ "--melt-invoker-width": `${this.invokerRect?.width ?? 0}px`, "--melt-invoker-height": `${this.invokerRect?.height ?? 0}px`, "--melt-invoker-x": `${this.invokerRect?.x ?? 0}px`, "--melt-invoker-y": `${this.invokerRect?.y ?? 0}px`, "--melt-popover-available-width": `${this.availableWidth ?? 0}px`, "--melt-popover-available-height": `${this.availableHeight ?? 0}px`, }), }; } #triggerAttachmentKey = createAttachmentKey(); #triggerAttachment = (node) => { if (untrack(() => this.triggerEl)) return; this.triggerEl = node; return () => { if (this.triggerEl !== node) return; this.triggerEl = null; }; }; /** The trigger that toggles the value. */ getInvoker() { return { // @ts-expect-error - we're a bit more permissive here popovertarget: this.ids.popover, onclick: (e) => { e.preventDefault(); this.triggerEl = e.currentTarget; this.open = !this.open; }, ...this.sharedProps, [this.#triggerAttachmentKey]: this.#triggerAttachment, }; } #popoverAttachmentKey = createAttachmentKey(); #popoverAttachment = (node) => { // Show and hide popover based on open state const isVisible = $derived(this.open || this.forceVisible); $effect(() => { const el = document.getElementById(this.ids.popover); if (!isHtmlElement(el)) { return; } if (isVisible) { return autoOpenPopover({ el }); } else { safelyHidePopover(el); } }); watch(() => this.open, (open) => { setTimeout(() => { const selector = open ? this.focus.onOpen : this.focus.onClose; if (!selector) return; const el = getHtmlElement(selector); el?.focus(); }); }, { lazy: true }); const trap = focusTrap.createFocusTrap(node, { allowOutsideClick: true, clickOutsideDeactivates: true, }); $effect(() => { if (!this.open || !this.focus.trap) return; trap.activate(); return () => trap.deactivate(); }); $effect(() => { const contentEl = document.getElementById(this.ids.popover); const triggerEl = this.triggerEl; if (!isHtmlElement(contentEl) || !isHtmlElement(triggerEl) || !this.open) { return; } useFloating({ node: () => triggerEl, floating: () => contentEl, config: () => this.floatingConfig, }); }); const offs = [ on(document, "keydown", (e) => { if (!this.closeOnEscape) return; const el = document.getElementById(this.ids.popover); if (e.key !== "Escape" || !this.open || !isHtmlElement(el)) return; e.preventDefault(); const openPopovers = [...el.querySelectorAll("[popover]")].filter((child) => { if (!isHtmlElement(child)) return false; // If child is a Melt popover, check if it's open if (child.matches(dataSelectors.content)) return child.dataset.open !== undefined; return child.matches(":popover-open"); }); if (openPopovers.length) return; // Set timeout to give time to all event listeners to run setTimeout(() => (this.open = false)); }), on(document, "click", (e) => { if (!this.open) return; // Exit early if not open const contentEl = document.getElementById(this.ids.popover); const triggerEl = this.triggerEl; if (!contentEl || !triggerEl) return; // Exit if elements are missing const target = e.target; const isInsideContent = contentEl.contains(target); const isInsideTrigger = triggerEl.contains(target); if (isInsideContent || isInsideTrigger) return; // Exit if clicked inside if (this.#shouldClose(target)) this.open = false; }), ]; return () => offs.forEach((off) => off()); }; getPopover() { return { id: this.ids.popover, popover: "manual", ontoggle: (e) => { const newOpen = e.newState === "open"; if (this.open !== newOpen && newOpen === false) { this.open = newOpen; } }, // Needed so it receives focus on click, but not on tab, because of focus out tabindex: -1, inert: !this.open, "data-open": dataAttr(this.open), [this.#popoverAttachmentKey]: this.#popoverAttachment, ...this.sharedProps, }; } get arrow() { return { [dataAttrs.arrow]: "", "data-arrow": "", "aria-hidden": true, "data-open": dataAttr(this.open), }; } } export class Popover extends BasePopover { constructor(props = {}) { super({ ...props }); this.ids = { ...this.ids, content: this.ids.popover }; } /** The trigger that toggles the value. */ get trigger() { return Object.assign(this.getInvoker(), { [dataAttrs.trigger]: "", }); } get content() { return Object.assign(this.getPopover(), { [dataAttrs.content]: "", }); } }