@melt-ui/svelte
Version:

280 lines (279 loc) • 11.3 kB
JavaScript
import { addEventListener, addMeltEventListener, makeElement, createElHelpers, effect, executeCallbacks, getPortalDestination, isBrowser, isDocument, isElement, isTouch, makeHullFromElements, noop, omit, overridable, styleToString, toWritableStores, portalAttr, isPointerInGraceArea, } from '../../internal/helpers/index.js';
import { useEscapeKeydown, useFloating, useInteractOutside, usePortal, } from '../../internal/actions/index.js';
import { derived, writable } from 'svelte/store';
import { generateIds } from '../../internal/helpers/id.js';
import { tick } from 'svelte';
const defaults = {
positioning: {
placement: 'bottom',
},
arrowSize: 8,
defaultOpen: false,
closeOnPointerDown: true,
openDelay: 1000,
closeDelay: 0,
forceVisible: false,
portal: 'body',
escapeBehavior: 'close',
disableHoverableContent: false,
group: undefined,
};
const { name } = createElHelpers('tooltip');
// Store a global map to get the currently open tooltip.
const groupMap = new Map();
export const tooltipIdParts = ['trigger', 'content'];
export function createTooltip(props) {
const withDefaults = { ...defaults, ...props };
const options = toWritableStores(omit(withDefaults, 'open', 'ids'));
const { positioning, arrowSize, closeOnPointerDown, openDelay, closeDelay, forceVisible, portal, escapeBehavior, disableHoverableContent, group, } = options;
const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen);
const open = overridable(openWritable, withDefaults?.onOpenChange);
const openReason = writable(null);
const ids = toWritableStores({ ...generateIds(tooltipIdParts), ...withDefaults.ids });
let clickedTrigger = false;
let isPointerInsideTrigger = false;
let isPointerInsideContent = false;
const getEl = (part) => {
if (!isBrowser)
return null;
return document.getElementById(ids[part].get());
};
let openTimeout = null;
let closeTimeout = null;
function openTooltip(reason) {
if (closeTimeout) {
window.clearTimeout(closeTimeout);
closeTimeout = null;
}
if (!openTimeout) {
openTimeout = window.setTimeout(() => {
open.set(true);
// Don't override the reason if it's already set.
openReason.update((prev) => prev ?? reason);
openTimeout = null;
}, openDelay.get());
}
}
function closeTooltip(isBlur) {
if (openTimeout) {
window.clearTimeout(openTimeout);
openTimeout = null;
}
if (isBlur && isMouseInTooltipArea) {
// Normally when blurring the trigger, we want to close the tooltip.
// The exception is when the mouse is still in the tooltip area.
// In that case, we have to set the openReason to pointer, so that
// it can close when the mouse leaves the tooltip area.
openReason.set('pointer');
return;
}
if (!closeTimeout) {
closeTimeout = window.setTimeout(() => {
open.set(false);
openReason.set(null);
if (isBlur)
clickedTrigger = false;
closeTimeout = null;
}, closeDelay.get());
}
}
const isVisible = derived([open, forceVisible], ([$open, $forceVisible]) => {
return $open || $forceVisible;
});
const trigger = makeElement(name('trigger'), {
stores: [ids.content, ids.trigger, open],
returned: ([$contentId, $triggerId, $open]) => {
return {
'aria-describedby': $contentId,
id: $triggerId,
'data-state': $open ? 'open' : 'closed',
};
},
action: (node) => {
const unsub = executeCallbacks(addMeltEventListener(node, 'pointerdown', () => {
const $closeOnPointerDown = closeOnPointerDown.get();
if (!$closeOnPointerDown)
return;
open.set(false);
clickedTrigger = true;
if (openTimeout) {
window.clearTimeout(openTimeout);
openTimeout = null;
}
}), addMeltEventListener(node, 'pointerenter', (e) => {
isPointerInsideTrigger = true;
if (isTouch(e))
return;
openTooltip('pointer');
}), addMeltEventListener(node, 'pointerleave', (e) => {
isPointerInsideTrigger = false;
if (isTouch(e))
return;
if (openTimeout) {
window.clearTimeout(openTimeout);
openTimeout = null;
}
}), addMeltEventListener(node, 'focus', () => {
if (clickedTrigger)
return;
openTooltip('focus');
}), addMeltEventListener(node, 'blur', () => closeTooltip(true)));
return {
destroy() {
unsub();
isPointerInsideTrigger = false;
},
};
},
});
const content = makeElement(name('content'), {
stores: [isVisible, open, portal, ids.content],
returned: ([$isVisible, $open, $portal, $contentId]) => {
return {
role: 'tooltip',
hidden: $isVisible ? undefined : true,
tabindex: -1,
style: $isVisible ? undefined : styleToString({ display: 'none' }),
id: $contentId,
'data-portal': portalAttr($portal),
'data-state': $open ? 'open' : 'closed',
};
},
action: (node) => {
let unsubFloating = noop;
let unsubPortal = noop;
let unsubInteractOutside = noop;
let unsubEscapeKeydown = noop;
const unsubDerived = effect([isVisible, positioning, portal], ([$isVisible, $positioning, $portal]) => {
unsubPortal();
unsubFloating();
unsubInteractOutside();
unsubEscapeKeydown();
const triggerEl = getEl('trigger');
if (!$isVisible || !triggerEl)
return;
tick().then(() => {
unsubPortal();
unsubFloating();
unsubInteractOutside();
unsubEscapeKeydown();
const portalDest = getPortalDestination(node, $portal);
if (portalDest !== null) {
unsubPortal = usePortal(node, portalDest).destroy;
}
unsubFloating = useFloating(triggerEl, node, $positioning).destroy;
unsubInteractOutside = useInteractOutside(node).destroy;
const onEscapeKeyDown = () => {
if (openTimeout) {
window.clearTimeout(openTimeout);
openTimeout = null;
}
open.set(false);
};
unsubEscapeKeydown = useEscapeKeydown(node, {
behaviorType: escapeBehavior,
handler: onEscapeKeyDown,
}).destroy;
});
});
/**
* We don't want the tooltip to remain open if the user starts scrolling
* while their pointer is over the tooltip, so we close it.
*/
function handleScroll(e) {
if (!open.get())
return;
const target = e.target;
if (!isElement(target) && !isDocument(target))
return;
const triggerEl = getEl('trigger');
if (triggerEl && target.contains(triggerEl)) {
closeTooltip();
}
}
const unsubEvents = executeCallbacks(addMeltEventListener(node, 'pointerenter', () => {
isPointerInsideContent = true;
openTooltip('pointer');
}), addMeltEventListener(node, 'pointerleave', () => {
isPointerInsideContent = false;
}), addMeltEventListener(node, 'pointerdown', () => openTooltip('pointer')), addEventListener(window, 'scroll', handleScroll, { capture: true }));
return {
destroy() {
isPointerInsideContent = false;
unsubEvents();
unsubPortal();
unsubFloating();
unsubDerived();
unsubInteractOutside();
},
};
},
});
const arrow = makeElement(name('arrow'), {
stores: arrowSize,
returned: ($arrowSize) => ({
'data-arrow': true,
style: styleToString({
position: 'absolute',
width: `var(--arrow-size, ${$arrowSize}px)`,
height: `var(--arrow-size, ${$arrowSize}px)`,
}),
}),
});
let isMouseInTooltipArea = false;
effect(open, ($open) => {
const currentGroup = group.get();
if (currentGroup === undefined || currentGroup === false) {
return;
}
if (!$open) {
if (groupMap.get(currentGroup) === open) {
// Tooltip is no longer open
groupMap.delete(currentGroup);
}
return;
}
// Close the currently open tooltip in the same group
// and set this tooltip as the open one.
const currentOpen = groupMap.get(currentGroup);
currentOpen?.set(false);
groupMap.set(currentGroup, open);
});
effect([open, openReason], ([$open, $openReason]) => {
if (!$open || !isBrowser)
return;
return executeCallbacks(addEventListener(document, 'mousemove', (e) => {
const contentEl = getEl('content');
const triggerEl = getEl('trigger');
if (!contentEl || !triggerEl)
return;
const polygonElements = disableHoverableContent.get()
? [triggerEl]
: [triggerEl, contentEl];
const polygon = makeHullFromElements(polygonElements);
/**
* This takes into account the potential discrepancy between the
* pointer's coordinates (`clientX` and `clientY`) and the
* exact boundaries of the trigger element's rectangle due to
* sub-pixel rendering and rounding errors.
*/
isMouseInTooltipArea =
isPointerInsideTrigger || isPointerInsideContent || isPointerInGraceArea(e, polygon);
if ($openReason !== 'pointer')
return;
if (!isMouseInTooltipArea) {
closeTooltip();
}
}));
});
return {
ids,
elements: {
trigger,
content,
arrow,
},
states: { open },
options,
};
}