Spaces:
Sleeping
Sleeping
| import type { ActionReturn } from 'svelte/action'; | |
| /// placement of the floating element around the anchor element | |
| export type Placement = | |
| | 'left' | |
| | 'right' | |
| | 'top' | |
| | 'bottom' | |
| | 'auto' | |
| | 'prefer-left' | |
| | 'prefer-right' | |
| | 'prefer-top' | |
| | 'prefer-bottom'; | |
| /// placement axis | |
| export type Axis = 'x' | 'y'; | |
| /// alignment of the floating element against the anchor element | |
| 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; /// preferred placement of the floating element | |
| alignment?: Alignment; /// preferred alignment of the floating element | |
| hitZoneXMargin?: number; /// page x margin before hitting the edge of the screen | |
| hitZoneYMargin?: number; /// page y margin before hitting the edge of the screen | |
| shift?: boolean; /// shift the floating element to center it against the anchor element | |
| arrowPadding?: number; /// padding for the arrow | |
| arrowSize?: number; /// size of the arrow | |
| minMargin?: number; /// minimum margin around the floating element in "screen" alignment | |
| } | |
| /** | |
| * Compute the best placement for the floating element based on | |
| * the anchor element position, the page size and a preferred placement. | |
| */ | |
| 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') { | |
| /// check if the anchor is closer to the top or bottom of the page | |
| computedPlacement = anchorBBox.top > pageHeight / 2 ? 'top' : 'bottom'; | |
| } else if ( | |
| preferredPlacement === 'prefer-top' || | |
| floatBBox.width + opts.hitZoneXMargin >= pageWidth | |
| ) { | |
| /// check if the toast has enough space to be placed above the anchor | |
| computedPlacement = | |
| anchorBBox.top > floatBBox.height + opts.hitZoneYMargin ? 'top' : 'bottom'; | |
| } else if (preferredPlacement === 'prefer-bottom') { | |
| /// check if the toast has enough space to be placed below the anchor | |
| computedPlacement = | |
| anchorBBox.top + anchorBBox.height + floatBBox.height + opts.hitZoneYMargin > pageHeight | |
| ? 'top' | |
| : 'bottom'; | |
| } else if (preferredPlacement === 'prefer-left') { | |
| /// check if the toast has enough space to be placed on the left of the anchor | |
| computedPlacement = | |
| anchorBBox.left > floatBBox.width + opts.hitZoneXMargin ? 'left' : 'right'; | |
| } else if (preferredPlacement === 'prefer-right') { | |
| /// check if the toast has enough space to be placed on the right of the anchor | |
| computedPlacement = | |
| anchorBBox.left + anchorBBox.width + floatBBox.width + opts.hitZoneXMargin > pageWidth | |
| ? 'left' | |
| : 'right'; | |
| } | |
| } | |
| return computedPlacement; | |
| } | |
| /** | |
| * Compute the best alignment for the floating element based on | |
| * the anchor element position, the page size, the placement axis and a preferred alignment. | |
| */ | |
| 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) { | |
| /// on mobile, large popovers should be centered and screen wide | |
| computedAlignment = 'screen'; | |
| } else if ( | |
| axis === 'y' && | |
| anchorBBox.left + floatingBBox.width > pageWidth - opts.hitZoneXMargin && | |
| anchorBBox.left - floatingBBox.width - opts.hitZoneXMargin < 0 | |
| ) { | |
| /// if the floating element is too wide we center it | |
| computedAlignment = 'center'; | |
| } else if ( | |
| axis === 'y' && | |
| anchorBBox.left + floatingBBox.width > pageWidth - opts.hitZoneXMargin | |
| ) { | |
| /// align at the end of the anchor when the right edge of the page is too close | |
| computedAlignment = 'end'; | |
| } else if (axis === 'y' && anchorBBox.left - floatingBBox.width - opts.hitZoneXMargin < 0) { | |
| /// align at the start of the anchor when the left edge of the page is too close | |
| computedAlignment = 'start'; | |
| } else if ( | |
| axis === 'x' && | |
| anchorBBox.top + floatingBBox.height > pageHeight - opts.hitZoneYMargin | |
| ) { | |
| /// when placed horizontally, align at the end of the anchor when the top edge of the page is too close | |
| computedAlignment = 'end'; | |
| } else { | |
| /// start by default except if prefer-center is set | |
| computedAlignment = preferredAlignment === 'prefer-center' ? 'center' : 'start'; | |
| } | |
| } | |
| return computedAlignment; | |
| } | |
| /** | |
| * Compute the position of the floating element | |
| */ | |
| 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 | |
| } | |
| ); | |
| /// position of the anchor element | |
| 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') { | |
| /// shift the floating element so the arrow is exactly at the middle of the anchor | |
| 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 { | |
| /// shift the floating element so the arrow is exactly at the middle of the anchor | |
| 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 | |
| }; | |
| /** | |
| * Tooltip svelte action, | |
| * | |
| * use it with the tooltip text content as a string. | |
| * ```html | |
| * <div use:tooltip={"Tooltip content"} /> | |
| * ``` | |
| * or with the tooltip options as an object. | |
| * ```html | |
| * <div use:tooltip={{ content: "Tooltip content", opts: { placement: "left", alignment: "end" } }} /> | |
| * ``` | |
| */ | |
| export function tooltip( | |
| node: HTMLElement, | |
| parameter: | |
| | string | |
| | { | |
| content: string | undefined; | |
| opts?: PositionOptions; | |
| showOn?: 'click' | 'hover' | 'hoverTouch' | 'always'; | |
| disabled?: boolean; | |
| } | |
| | undefined | |
| ): ActionReturn { | |
| /// if the tooltip is disabled, or without content, we do nothing | |
| 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'; | |
| } | |
| /// create elements | |
| 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(); | |
| // eslint-disable-next-line github/prefer-observers | |
| document.addEventListener('scroll', updatePositions); | |
| // eslint-disable-next-line github/prefer-observers | |
| 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); | |
| } | |
| } | |
| }; | |
| } | |