|
import type { ActionReturn } from 'svelte/action'; |
|
|
|
|
|
export type Placement = |
|
| 'left' |
|
| 'right' |
|
| 'top' |
|
| 'bottom' |
|
| 'auto' |
|
| 'prefer-left' |
|
| 'prefer-right' |
|
| 'prefer-top' |
|
| 'prefer-bottom'; |
|
|
|
|
|
export type Axis = 'x' | 'y'; |
|
|
|
|
|
export type Alignment = 'start' | 'center' | 'end' | 'screen' | 'auto' | 'prefer-center'; |
|
|
|
export interface AbsolutePosition { |
|
left: string; |
|
top: string; |
|
right: string; |
|
bottom: string; |
|
} |
|
|
|
export interface PositionOptions { |
|
placement?: Placement; |
|
alignment?: Alignment; |
|
hitZoneXMargin?: number; |
|
hitZoneYMargin?: number; |
|
shift?: boolean; |
|
arrowPadding?: number; |
|
arrowSize?: number; |
|
minMargin?: number; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
export function computePlacement( |
|
anchorBBox: DOMRect, |
|
floatBBox: DOMRect, |
|
preferredPlacement: Placement = 'auto', |
|
pageWidth: number, |
|
pageHeight: number, |
|
opts: { |
|
hitZoneXMargin: number; |
|
hitZoneYMargin: number; |
|
} = { |
|
hitZoneXMargin: 0, |
|
hitZoneYMargin: 0 |
|
} |
|
): Placement { |
|
let computedPlacement = preferredPlacement === 'auto' ? 'bottom' : preferredPlacement; |
|
if (pageHeight > 0 && pageWidth > 0) { |
|
if (preferredPlacement === 'auto') { |
|
|
|
computedPlacement = anchorBBox.top > pageHeight / 2 ? 'top' : 'bottom'; |
|
} else if ( |
|
preferredPlacement === 'prefer-top' || |
|
floatBBox.width + opts.hitZoneXMargin >= pageWidth |
|
) { |
|
|
|
computedPlacement = |
|
anchorBBox.top > floatBBox.height + opts.hitZoneYMargin ? 'top' : 'bottom'; |
|
} else if (preferredPlacement === 'prefer-bottom') { |
|
|
|
computedPlacement = |
|
anchorBBox.top + anchorBBox.height + floatBBox.height + opts.hitZoneYMargin > pageHeight |
|
? 'top' |
|
: 'bottom'; |
|
} else if (preferredPlacement === 'prefer-left') { |
|
|
|
computedPlacement = |
|
anchorBBox.left > floatBBox.width + opts.hitZoneXMargin ? 'left' : 'right'; |
|
} else if (preferredPlacement === 'prefer-right') { |
|
|
|
computedPlacement = |
|
anchorBBox.left + anchorBBox.width + floatBBox.width + opts.hitZoneXMargin > pageWidth |
|
? 'left' |
|
: 'right'; |
|
} |
|
} |
|
return computedPlacement; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
export function computeAlignment( |
|
anchorBBox: DOMRect, |
|
floatingBBox: DOMRect, |
|
preferredAlignment: Alignment = 'auto', |
|
axis: Axis = 'y', |
|
pageWidth: number, |
|
pageHeight: number, |
|
opts: { |
|
hitZoneXMargin: number; |
|
hitZoneYMargin: number; |
|
} = { |
|
hitZoneXMargin: 0, |
|
hitZoneYMargin: 0 |
|
} |
|
): Alignment { |
|
let computedAlignment = |
|
preferredAlignment === 'auto' ? (axis === 'y' ? 'center' : 'start') : preferredAlignment; |
|
if (['prefer-center', 'auto'].includes(preferredAlignment) && pageWidth > 0) { |
|
if (floatingBBox.width + opts.hitZoneXMargin * 2 >= pageWidth) { |
|
|
|
computedAlignment = 'screen'; |
|
} else if ( |
|
axis === 'y' && |
|
anchorBBox.left + floatingBBox.width > pageWidth - opts.hitZoneXMargin && |
|
anchorBBox.left - floatingBBox.width - opts.hitZoneXMargin < 0 |
|
) { |
|
|
|
computedAlignment = 'center'; |
|
} else if ( |
|
axis === 'y' && |
|
anchorBBox.left + floatingBBox.width > pageWidth - opts.hitZoneXMargin |
|
) { |
|
|
|
computedAlignment = 'end'; |
|
} else if (axis === 'y' && anchorBBox.left - floatingBBox.width - opts.hitZoneXMargin < 0) { |
|
|
|
computedAlignment = 'start'; |
|
} else if ( |
|
axis === 'x' && |
|
anchorBBox.top + floatingBBox.height > pageHeight - opts.hitZoneYMargin |
|
) { |
|
|
|
computedAlignment = 'end'; |
|
} else { |
|
|
|
computedAlignment = preferredAlignment === 'prefer-center' ? 'center' : 'start'; |
|
} |
|
} |
|
return computedAlignment; |
|
} |
|
|
|
|
|
|
|
|
|
export function computePosition( |
|
anchorBBox: DOMRect, |
|
floatBBox: DOMRect, |
|
{ |
|
placement = 'auto', |
|
alignment = 'auto', |
|
hitZoneXMargin = 0, |
|
hitZoneYMargin = 0, |
|
shift = false, |
|
arrowPadding = 10, |
|
arrowSize = 8, |
|
minMargin = 20 |
|
}: PositionOptions = {} |
|
): { float: AbsolutePosition; arrow: AbsolutePosition } { |
|
const axis: Axis = ['auto', 'top', 'bottom', 'prefer-top', 'prefer-bottom'].includes(placement) |
|
? 'y' |
|
: 'x'; |
|
|
|
const computedAlignment = computeAlignment( |
|
anchorBBox, |
|
floatBBox, |
|
alignment, |
|
axis, |
|
window.innerWidth, |
|
window.innerHeight, |
|
{ hitZoneXMargin, hitZoneYMargin } |
|
); |
|
const computedPlacement = computePlacement( |
|
anchorBBox, |
|
floatBBox, |
|
placement, |
|
window.innerWidth, |
|
window.innerHeight, |
|
{ |
|
hitZoneXMargin, |
|
hitZoneYMargin |
|
} |
|
); |
|
|
|
|
|
const left = anchorBBox.left + window.scrollX; |
|
const width = anchorBBox.width; |
|
const height = anchorBBox.height; |
|
|
|
const halfArrowSize = arrowSize / 2; |
|
|
|
let floatingLeft: string = ''; |
|
let floatingTop: string = ''; |
|
let floatingRight: string = ''; |
|
let floatingBottom: string = ''; |
|
let arrowLeft: string = ''; |
|
let arrowTop: string = ''; |
|
let arrowRight: string = ''; |
|
let arrowBottom: string = ''; |
|
|
|
switch (computedPlacement) { |
|
case 'top': { |
|
floatingBottom = `calc(100% + ${arrowSize}px)`; |
|
arrowTop = `calc(100% - ${halfArrowSize}px)`; |
|
break; |
|
} |
|
case 'bottom': { |
|
floatingTop = `calc(100% + ${arrowSize}px)`; |
|
arrowBottom = `calc(100% - ${halfArrowSize}px)`; |
|
break; |
|
} |
|
case 'left': { |
|
console.log('left'); |
|
floatingRight = `calc(100% + ${arrowSize}px)`; |
|
arrowRight = `-${halfArrowSize}px`; |
|
break; |
|
} |
|
case 'right': { |
|
floatingLeft = `calc(100% + ${arrowSize}px)`; |
|
arrowLeft = `-${halfArrowSize}px`; |
|
break; |
|
} |
|
default: { |
|
break; |
|
} |
|
} |
|
if (axis === 'y') { |
|
|
|
const shiftFloating = shift ? width / 2 - halfArrowSize - arrowPadding : 0; |
|
switch (computedAlignment) { |
|
case 'start': { |
|
floatingLeft = `${shiftFloating}px`; |
|
floatingRight = 'auto'; |
|
arrowLeft = `${arrowPadding}px`; |
|
break; |
|
} |
|
case 'center': { |
|
floatingLeft = `-${floatBBox.width / 2 - width / 2}px`; |
|
floatingRight = 'auto'; |
|
arrowLeft = `calc(50% - ${halfArrowSize}px)`; |
|
break; |
|
} |
|
case 'end': { |
|
floatingLeft = 'auto'; |
|
floatingRight = `${shiftFloating}px`; |
|
arrowRight = `${arrowPadding}px`; |
|
break; |
|
} |
|
case 'screen': { |
|
floatingLeft = `${minMargin - left}px`; |
|
floatingRight = 'auto'; |
|
arrowLeft = `${left - minMargin + width / 2 - halfArrowSize}px`; |
|
break; |
|
} |
|
default: |
|
break; |
|
} |
|
} else { |
|
|
|
const popoverShift = shift ? height / 2 - halfArrowSize - arrowPadding : 0; |
|
switch (computedAlignment) { |
|
case 'start': { |
|
floatingTop = `${popoverShift}px`; |
|
arrowTop = `${floatBBox.height < arrowPadding * 2 ? floatBBox.height / 2 : arrowPadding}px`; |
|
break; |
|
} |
|
case 'center': { |
|
floatingTop = `-${floatBBox.height / 2 - height / 2}px`; |
|
arrowTop = `calc(50% - ${halfArrowSize}px)`; |
|
break; |
|
} |
|
case 'end': { |
|
floatingBottom = `${popoverShift}px`; |
|
arrowBottom = `${floatBBox.height < arrowPadding * 2 ? floatBBox.height / 2 : arrowPadding}px`; |
|
break; |
|
} |
|
case 'screen': { |
|
floatingLeft = `${minMargin - left}px`; |
|
floatingRight = `auto`; |
|
arrowLeft = `${left - minMargin + width / 2 - halfArrowSize}px`; |
|
break; |
|
} |
|
default: |
|
break; |
|
} |
|
} |
|
return { |
|
arrow: { left: arrowLeft, top: arrowTop, right: arrowRight, bottom: arrowBottom }, |
|
float: { left: floatingLeft, top: floatingTop, right: floatingRight, bottom: floatingBottom } |
|
}; |
|
} |
|
|
|
const defaultOptions: PositionOptions = { |
|
placement: 'prefer-top', |
|
alignment: 'prefer-center', |
|
hitZoneXMargin: 20, |
|
hitZoneYMargin: 20, |
|
shift: true, |
|
arrowPadding: 10, |
|
arrowSize: 8, |
|
minMargin: 10 |
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function tooltip( |
|
node: HTMLElement, |
|
parameter: |
|
| string |
|
| { |
|
content: string | undefined; |
|
opts?: PositionOptions; |
|
showOn?: 'click' | 'hover' | 'hoverTouch' | 'always'; |
|
disabled?: boolean; |
|
} |
|
| undefined |
|
): ActionReturn { |
|
|
|
if ( |
|
parameter === undefined || |
|
(typeof parameter === 'string' && !parameter) || |
|
(typeof parameter === 'object' && (!parameter.content || parameter.disabled === true)) |
|
) { |
|
return {}; |
|
} |
|
|
|
let content: string; |
|
const opts: PositionOptions = { ...defaultOptions }; |
|
let showOn = 'hover'; |
|
if (typeof parameter === 'string') { |
|
content = parameter; |
|
} else { |
|
content = parameter.content as string; |
|
Object.assign(opts, parameter.opts); |
|
showOn = parameter.showOn ?? 'hover'; |
|
} |
|
|
|
|
|
const tooltipMask = document.createElement('div'); |
|
tooltipMask.className = 'tooltip-mask hidden'; |
|
|
|
const tooltipElt = document.createElement('div'); |
|
tooltipElt.className = 'tooltip'; |
|
tooltipElt.setAttribute('role', 'tooltip'); |
|
|
|
const arrowElt = document.createElement('div'); |
|
arrowElt.className = 'tooltip-arrow'; |
|
|
|
tooltipElt.appendChild(arrowElt); |
|
tooltipElt.appendChild(document.createTextNode(content)); |
|
tooltipMask.appendChild(tooltipElt); |
|
document.body.appendChild(tooltipMask); |
|
|
|
function updateElementPosition( |
|
element: HTMLElement, |
|
position: AbsolutePosition | { top: string; left?: string; width?: string; height?: string } |
|
) { |
|
for (const [key, value] of Object.entries(position)) { |
|
element.style[key] = value; |
|
} |
|
} |
|
|
|
function updatePositions() { |
|
updateElementPosition(tooltipMask, { |
|
top: `${node.getBoundingClientRect().top + window.scrollY}px`, |
|
left: `${node.getBoundingClientRect().left + window.scrollX}px`, |
|
width: `${node.getBoundingClientRect().width}px`, |
|
height: `${node.getBoundingClientRect().height}px` |
|
}); |
|
|
|
const positions = computePosition( |
|
node.getBoundingClientRect(), |
|
tooltipElt.getBoundingClientRect(), |
|
opts |
|
); |
|
|
|
updateElementPosition(tooltipElt, positions.float); |
|
updateElementPosition(arrowElt, positions.arrow); |
|
} |
|
|
|
function show() { |
|
tooltipMask.classList.remove('hidden'); |
|
updatePositions(); |
|
|
|
|
|
document.addEventListener('scroll', updatePositions); |
|
|
|
document.addEventListener('resize', updatePositions); |
|
} |
|
|
|
function hide() { |
|
tooltipMask.classList.add('hidden'); |
|
document.removeEventListener('scroll', updatePositions); |
|
document.removeEventListener('resize', updatePositions); |
|
} |
|
|
|
switch (showOn) { |
|
case 'click': { |
|
node.addEventListener('click', show); |
|
break; |
|
} |
|
case 'hoverTouch': { |
|
node.addEventListener('mouseenter', show); |
|
node.addEventListener('mouseleave', hide); |
|
node.addEventListener('touchstart', show); |
|
node.addEventListener('touchend', hide); |
|
break; |
|
} |
|
case 'hover': { |
|
node.addEventListener('mouseenter', show); |
|
node.addEventListener('mouseleave', hide); |
|
break; |
|
} |
|
case 'always': { |
|
show(); |
|
break; |
|
} |
|
} |
|
|
|
return { |
|
destroy() { |
|
document.removeEventListener('scroll', updatePositions); |
|
document.removeEventListener('resize', updatePositions); |
|
|
|
switch (showOn) { |
|
case 'click': { |
|
node.removeEventListener('click', show); |
|
break; |
|
} |
|
case 'hoverTouch': { |
|
node.removeEventListener('mouseenter', show); |
|
node.removeEventListener('mouseleave', hide); |
|
node.removeEventListener('touchstart', show); |
|
node.removeEventListener('touchend', hide); |
|
break; |
|
} |
|
case 'hover': { |
|
node.removeEventListener('mouseenter', show); |
|
node.removeEventListener('mouseleave', hide); |
|
break; |
|
} |
|
} |
|
if (showOn === 'always') { |
|
tooltipElt.style.opacity = '0'; |
|
setTimeout(() => document.body.removeChild(tooltipMask), 150); |
|
} else { |
|
document.body.removeChild(tooltipMask); |
|
} |
|
} |
|
}; |
|
} |
|
|