melt
Version:
The next generation of Melt UI. Built for Svelte 5.
292 lines (291 loc) • 11.2 kB
JavaScript
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]: "",
});
}
}