|
import { usePopper } from '../../internal/actions/index.js'; |
|
import { addMeltEventListener, makeElement, createElHelpers, derivedVisible, effect, executeCallbacks, getPortalDestination, getTabbableNodes, isBrowser, isElement, isFocusVisible, isHTMLElement, isTouch, noop, overridable, sleep, styleToString, toWritableStores, portalAttr, } from '../../internal/helpers/index.js'; |
|
import { safeOnMount } from '../../internal/helpers/lifecycle.js'; |
|
import { withGet } from '../../internal/helpers/withGet.js'; |
|
import { writable } from 'svelte/store'; |
|
import { generateIds } from '../../internal/helpers/id.js'; |
|
import { omit } from '../../internal/helpers/object.js'; |
|
import { tick } from 'svelte'; |
|
const { name } = createElHelpers('hover-card'); |
|
const defaults = { |
|
defaultOpen: false, |
|
openDelay: 1000, |
|
closeDelay: 100, |
|
positioning: { |
|
placement: 'bottom', |
|
}, |
|
arrowSize: 8, |
|
closeOnOutsideClick: true, |
|
forceVisible: false, |
|
portal: undefined, |
|
closeOnEscape: true, |
|
onOutsideClick: undefined, |
|
}; |
|
export const linkPreviewIdParts = ['trigger', 'content']; |
|
export function createLinkPreview(props = {}) { |
|
const withDefaults = { ...defaults, ...props }; |
|
const openWritable = withDefaults.open ?? writable(withDefaults.defaultOpen); |
|
const open = overridable(openWritable, withDefaults?.onOpenChange); |
|
const hasSelection = withGet.writable(false); |
|
const isPointerDownOnContent = withGet.writable(false); |
|
const containSelection = writable(false); |
|
const activeTrigger = writable(null); |
|
|
|
|
|
const options = toWritableStores(omit(withDefaults, 'ids')); |
|
const { openDelay, closeDelay, positioning, arrowSize, closeOnOutsideClick, forceVisible, portal, closeOnEscape, onOutsideClick, } = options; |
|
const ids = toWritableStores({ ...generateIds(linkPreviewIdParts), ...withDefaults.ids }); |
|
let timeout = null; |
|
let originalBodyUserSelect; |
|
const handleOpen = withGet.derived(openDelay, ($openDelay) => { |
|
return () => { |
|
if (timeout) { |
|
window.clearTimeout(timeout); |
|
timeout = null; |
|
} |
|
timeout = window.setTimeout(() => { |
|
open.set(true); |
|
}, $openDelay); |
|
}; |
|
}); |
|
const handleClose = withGet.derived([closeDelay, isPointerDownOnContent, hasSelection], ([$closeDelay, $isPointerDownOnContent, $hasSelection]) => { |
|
return () => { |
|
if (timeout) { |
|
window.clearTimeout(timeout); |
|
timeout = null; |
|
} |
|
if (!$isPointerDownOnContent && !$hasSelection) { |
|
timeout = window.setTimeout(() => { |
|
open.set(false); |
|
}, $closeDelay); |
|
} |
|
}; |
|
}); |
|
const trigger = makeElement(name('trigger'), { |
|
stores: [open, ids.trigger, ids.content], |
|
returned: ([$open, $triggerId, $contentId]) => { |
|
return { |
|
role: 'button', |
|
'aria-haspopup': 'dialog', |
|
'aria-expanded': $open, |
|
'data-state': $open ? 'open' : 'closed', |
|
'aria-controls': $contentId, |
|
id: $triggerId, |
|
}; |
|
}, |
|
action: (node) => { |
|
const unsub = executeCallbacks(addMeltEventListener(node, 'pointerenter', (e) => { |
|
if (isTouch(e)) |
|
return; |
|
handleOpen.get()(); |
|
}), addMeltEventListener(node, 'pointerleave', (e) => { |
|
if (isTouch(e)) |
|
return; |
|
handleClose.get()(); |
|
}), addMeltEventListener(node, 'focus', (e) => { |
|
if (!isElement(e.currentTarget) || !isFocusVisible(e.currentTarget)) |
|
return; |
|
handleOpen.get()(); |
|
}), addMeltEventListener(node, 'blur', () => handleClose.get()())); |
|
return { |
|
destroy: unsub, |
|
}; |
|
}, |
|
}); |
|
const isVisible = derivedVisible({ open, forceVisible, activeTrigger }); |
|
const content = makeElement(name('content'), { |
|
stores: [isVisible, portal, ids.content], |
|
returned: ([$isVisible, $portal, $contentId]) => { |
|
return { |
|
hidden: $isVisible ? undefined : true, |
|
tabindex: -1, |
|
style: styleToString({ |
|
'pointer-events': $isVisible ? undefined : 'none', |
|
opacity: $isVisible ? 1 : 0, |
|
userSelect: 'text', |
|
WebkitUserSelect: 'text', |
|
}), |
|
id: $contentId, |
|
'data-state': $isVisible ? 'open' : 'closed', |
|
'data-portal': portalAttr($portal), |
|
}; |
|
}, |
|
action: (node) => { |
|
let unsub = noop; |
|
const unsubTimers = () => { |
|
if (timeout) { |
|
window.clearTimeout(timeout); |
|
} |
|
}; |
|
let unsubPopper = noop; |
|
const unsubDerived = effect([isVisible, activeTrigger, positioning, closeOnOutsideClick, portal, closeOnEscape], ([$isVisible, $activeTrigger, $positioning, $closeOnOutsideClick, $portal, $closeOnEscape,]) => { |
|
unsubPopper(); |
|
if (!$isVisible || !$activeTrigger) |
|
return; |
|
tick().then(() => { |
|
const popper = usePopper(node, { |
|
anchorElement: $activeTrigger, |
|
open: open, |
|
options: { |
|
floating: $positioning, |
|
modal: { |
|
closeOnInteractOutside: $closeOnOutsideClick, |
|
onClose: () => { |
|
open.set(false); |
|
$activeTrigger.focus(); |
|
}, |
|
shouldCloseOnInteractOutside: (e) => { |
|
onOutsideClick.get()?.(e); |
|
if (e.defaultPrevented) |
|
return false; |
|
if (isHTMLElement($activeTrigger) && |
|
$activeTrigger.contains(e.target)) |
|
return false; |
|
return true; |
|
}, |
|
open: $isVisible, |
|
}, |
|
portal: getPortalDestination(node, $portal), |
|
focusTrap: null, |
|
escapeKeydown: $closeOnEscape ? undefined : null, |
|
}, |
|
}); |
|
if (popper && popper.destroy) { |
|
unsubPopper = popper.destroy; |
|
} |
|
}); |
|
}); |
|
unsub = executeCallbacks(addMeltEventListener(node, 'pointerdown', (e) => { |
|
const currentTarget = e.currentTarget; |
|
const target = e.target; |
|
if (!isHTMLElement(currentTarget) || !isHTMLElement(target)) |
|
return; |
|
if (currentTarget.contains(target)) { |
|
containSelection.set(true); |
|
} |
|
hasSelection.set(false); |
|
isPointerDownOnContent.set(true); |
|
}), addMeltEventListener(node, 'pointerenter', (e) => { |
|
if (isTouch(e)) |
|
return; |
|
handleOpen.get()(); |
|
}), addMeltEventListener(node, 'pointerleave', (e) => { |
|
if (isTouch(e)) |
|
return; |
|
handleClose.get()(); |
|
}), addMeltEventListener(node, 'focusout', (e) => { |
|
e.preventDefault(); |
|
})); |
|
return { |
|
destroy() { |
|
unsub(); |
|
unsubPopper(); |
|
unsubTimers(); |
|
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)`, |
|
}), |
|
}), |
|
}); |
|
effect([containSelection], ([$containSelection]) => { |
|
if (!isBrowser || !$containSelection) |
|
return; |
|
const body = document.body; |
|
const contentElement = document.getElementById(ids.content.get()); |
|
if (!contentElement) |
|
return; |
|
|
|
originalBodyUserSelect = body.style.userSelect || body.style.webkitUserSelect; |
|
const originalContentUserSelect = contentElement.style.userSelect || contentElement.style.webkitUserSelect; |
|
body.style.userSelect = 'none'; |
|
body.style.webkitUserSelect = 'none'; |
|
contentElement.style.userSelect = 'text'; |
|
contentElement.style.webkitUserSelect = 'text'; |
|
return () => { |
|
body.style.userSelect = originalBodyUserSelect; |
|
body.style.webkitUserSelect = originalBodyUserSelect; |
|
contentElement.style.userSelect = originalContentUserSelect; |
|
contentElement.style.webkitUserSelect = originalContentUserSelect; |
|
}; |
|
}); |
|
safeOnMount(() => { |
|
const triggerEl = document.getElementById(ids.trigger.get()); |
|
if (!triggerEl) |
|
return; |
|
activeTrigger.set(triggerEl); |
|
}); |
|
effect([open], ([$open]) => { |
|
if (!isBrowser || !$open) { |
|
hasSelection.set(false); |
|
return; |
|
} |
|
const handlePointerUp = () => { |
|
containSelection.set(false); |
|
isPointerDownOnContent.set(false); |
|
sleep(1).then(() => { |
|
const isSelection = document.getSelection()?.toString() !== ''; |
|
if (isSelection) { |
|
hasSelection.set(true); |
|
} |
|
}); |
|
}; |
|
document.addEventListener('pointerup', handlePointerUp); |
|
const contentElement = document.getElementById(ids.content.get()); |
|
if (!contentElement) |
|
return; |
|
const tabbables = getTabbableNodes(contentElement); |
|
tabbables.forEach((tabbable) => tabbable.setAttribute('tabindex', '-1')); |
|
return () => { |
|
document.removeEventListener('pointerup', handlePointerUp); |
|
hasSelection.set(false); |
|
isPointerDownOnContent.set(false); |
|
}; |
|
}); |
|
return { |
|
ids, |
|
elements: { |
|
trigger, |
|
content, |
|
arrow, |
|
}, |
|
states: { |
|
open, |
|
}, |
|
options, |
|
}; |
|
} |
|
|