| 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); |
| } |
| } |
| }; |
| } |
|
|