|
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'); |
|
|
|
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); |
|
|
|
openReason.update((prev) => prev ?? reason); |
|
openTimeout = null; |
|
}, openDelay.get()); |
|
} |
|
} |
|
function closeTooltip(isBlur) { |
|
if (openTimeout) { |
|
window.clearTimeout(openTimeout); |
|
openTimeout = null; |
|
} |
|
if (isBlur && isMouseInTooltipArea) { |
|
|
|
|
|
|
|
|
|
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; |
|
}); |
|
}); |
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
groupMap.delete(currentGroup); |
|
} |
|
return; |
|
} |
|
|
|
|
|
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, |
|
}; |
|
} |
|
|