import { addEventListener, addMeltEventListener, makeElement, createElHelpers, effect, executeCallbacks, getPortalDestination, isBrowser, isDocument, isElement, isTouch, kbd, makeHullFromElements, noop, omit, overridable, pointInPolygon, styleToString, toWritableStores, removeUndefined, portalAttr, } from '../../internal/helpers/index.js'; import { useFloating, 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: undefined, closeOnEscape: true, 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, closeOnEscape, 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; 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 keydownHandler = (e) => { if (closeOnEscape.get() && e.key === kbd.ESCAPE) { if (openTimeout) { window.clearTimeout(openTimeout); openTimeout = null; } open.set(false); } }; 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) => { if (isTouch(e)) return; openTooltip('pointer'); }), addMeltEventListener(node, 'pointerleave', (e) => { if (isTouch(e)) return; if (openTimeout) { window.clearTimeout(openTimeout); openTimeout = null; } }), addMeltEventListener(node, 'focus', () => { if (clickedTrigger) return; openTooltip('focus'); }), addMeltEventListener(node, 'blur', () => closeTooltip(true)), addMeltEventListener(node, 'keydown', keydownHandler), addEventListener(document, 'keydown', keydownHandler)); return { destroy: unsub, }; }, }); const content = makeElement(name('content'), { stores: [isVisible, open, portal, ids.content], returned: ([$isVisible, $open, $portal, $contentId]) => { return removeUndefined({ 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; const unsubDerived = effect([isVisible, positioning, portal], ([$isVisible, $positioning, $portal]) => { const triggerEl = getEl('trigger'); if (!$isVisible || !triggerEl) { unsubPortal(); unsubFloating(); return; } tick().then(() => { if ($portal === null) { unsubPortal(); } else { const portalDest = getPortalDestination(node, $portal); if (portalDest) { const portalReturn = usePortal(node, portalDest); if (portalReturn && portalReturn.destroy) { unsubPortal = portalReturn.destroy; } } } const floatingReturn = useFloating(triggerEl, node, $positioning); unsubFloating = floatingReturn.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', () => openTooltip('pointer')), addMeltEventListener(node, 'pointerdown', () => openTooltip('pointer')), addEventListener(window, 'scroll', handleScroll, { capture: true })); return { destroy() { unsubEvents(); unsubPortal(); unsubFloating(); unsubDerived(); }, }; }, }); 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); isMouseInTooltipArea = pointInPolygon({ x: e.clientX, y: e.clientY, }, polygon); if ($openReason !== 'pointer') return; if (!isMouseInTooltipArea) { closeTooltip(); } })); }); return { ids, elements: { trigger, content, arrow, }, states: { open }, options, }; }