|
| 1 | +<script lang="ts"> |
| 2 | + import { type PopoverProps as Props, popover } from '.'; |
| 3 | + import { onDestroy } from 'svelte'; |
| 4 | + import { type ParamsType } from '$lib/types'; |
| 5 | + import { fade } from 'svelte/transition'; |
| 6 | + import { linear } from 'svelte/easing'; |
| 7 | +
|
| 8 | + let { children, titleSlot, color = 'default', arrow = true, offset = 0, triggeredBy, position = 'top', class: className, reference, transition = fade, params, ...restProps }: Props = $props(); |
| 9 | +
|
| 10 | + let { base, title, h3, arrowBase } = $derived(popover({ color, arrow, position })); |
| 11 | +
|
| 12 | + const defaultParams: ParamsType = { duration: 100, easing: linear }; |
| 13 | +
|
| 14 | + let tooltipElement: HTMLElement | null = $state(null); |
| 15 | + let triggerElement: HTMLElement | null = null; |
| 16 | + let referenceElement: HTMLElement | null = null; |
| 17 | + let arrowEl: HTMLElement | null = $state(null); |
| 18 | + let visible = $state(false); |
| 19 | + let positioned = $state(false); |
| 20 | +
|
| 21 | + // Change the type to accommodate both browser and Node.js environments |
| 22 | + let hideTimeoutId: ReturnType<typeof setTimeout> | undefined; |
| 23 | +
|
| 24 | + const showTooltip = () => { |
| 25 | + visible = true; |
| 26 | + setTimeout(() => { |
| 27 | + positionTooltip(); |
| 28 | + positioned = true; |
| 29 | + }, 0); |
| 30 | + }; |
| 31 | +
|
| 32 | + const hideTooltip = () => { |
| 33 | + visible = false; |
| 34 | + positioned = false; |
| 35 | + }; |
| 36 | +
|
| 37 | + const onPopoverEnter = () => { |
| 38 | + if (hideTimeoutId !== undefined) { |
| 39 | + clearTimeout(hideTimeoutId); |
| 40 | + hideTimeoutId = undefined; |
| 41 | + } |
| 42 | + }; |
| 43 | +
|
| 44 | + // Handle mouse leave on popover |
| 45 | + const onPopoverLeave = () => { |
| 46 | + hideTooltip(); |
| 47 | + }; |
| 48 | +
|
| 49 | + const onTriggerLeave = () => { |
| 50 | + // Set a timeout to hide the tooltip, allowing time to move to the popover |
| 51 | + hideTimeoutId = setTimeout(hideTooltip, 100); |
| 52 | + }; |
| 53 | +
|
| 54 | + const positionTooltip = () => { |
| 55 | + if (!tooltipElement || !triggerElement) return; |
| 56 | + const triggerRect = triggerElement.getBoundingClientRect(); |
| 57 | + const referenceRect = reference && referenceElement ? referenceElement.getBoundingClientRect() : triggerRect; |
| 58 | + const tooltipRect = tooltipElement.getBoundingClientRect(); |
| 59 | + const arrowRect = arrow && arrowEl ? arrowEl.getBoundingClientRect() : null; |
| 60 | +
|
| 61 | + const scrollX = window.scrollX || document.documentElement.scrollLeft; |
| 62 | + const scrollY = window.scrollY || document.documentElement.scrollTop; |
| 63 | +
|
| 64 | + let top, left, arrowTop, arrowLeft; |
| 65 | +
|
| 66 | + switch (position) { |
| 67 | + case 'top': |
| 68 | + top = referenceRect.top + scrollY - tooltipRect.height - 10 - offset; |
| 69 | + left = referenceRect.left + scrollX + referenceRect.width / 2 - tooltipRect.width / 2; |
| 70 | + if (arrowRect && offset === 0) { |
| 71 | + arrowTop = tooltipRect.height - 5; |
| 72 | + arrowLeft = tooltipRect.width / 2 - arrowRect.width / 2; |
| 73 | + } |
| 74 | + break; |
| 75 | + case 'top-start': |
| 76 | + top = referenceRect.top + scrollY - tooltipRect.height - 10 - offset; |
| 77 | + left = referenceRect.left + scrollX; |
| 78 | + if (arrowRect && offset === 0) { |
| 79 | + arrowTop = tooltipRect.height - 5; |
| 80 | + arrowLeft = referenceRect.width / 2 - arrowRect.width / 2; |
| 81 | + } |
| 82 | + break; |
| 83 | + case 'top-end': |
| 84 | + top = referenceRect.top + scrollY - tooltipRect.height - 10 - offset; |
| 85 | + left = referenceRect.right + scrollX - tooltipRect.width; |
| 86 | + if (arrowRect && offset === 0) { |
| 87 | + arrowTop = tooltipRect.height - 5; |
| 88 | + arrowLeft = tooltipRect.width - referenceRect.width / 2 - arrowRect.width / 2; |
| 89 | + } |
| 90 | + break; |
| 91 | + case 'bottom': |
| 92 | + top = referenceRect.bottom + scrollY + 10 + offset; |
| 93 | + left = referenceRect.left + scrollX + referenceRect.width / 2 - tooltipRect.width / 2; |
| 94 | + if (arrowRect && offset === 0) { |
| 95 | + arrowTop = -arrowRect.height + 9; |
| 96 | + arrowLeft = tooltipRect.width / 2 - arrowRect.width / 2; |
| 97 | + } |
| 98 | + break; |
| 99 | + case 'bottom-start': |
| 100 | + top = referenceRect.bottom + scrollY + 10 + offset; |
| 101 | + left = referenceRect.left + scrollX; |
| 102 | + if (arrowRect && offset === 0) { |
| 103 | + arrowTop = -arrowRect.height + 9; |
| 104 | + arrowLeft = referenceRect.width / 2 - arrowRect.width / 2; |
| 105 | + } |
| 106 | + break; |
| 107 | + case 'bottom-end': |
| 108 | + top = referenceRect.bottom + scrollY + 10 + offset; |
| 109 | + left = referenceRect.right + scrollX - tooltipRect.width; |
| 110 | + if (arrowRect && offset === 0) { |
| 111 | + arrowTop = -arrowRect.height + 9; |
| 112 | + arrowLeft = tooltipRect.width - referenceRect.width / 2 - arrowRect.width / 2; |
| 113 | + } |
| 114 | + break; |
| 115 | + case 'left': |
| 116 | + top = referenceRect.top + scrollY + referenceRect.height / 2 - tooltipRect.height / 2; |
| 117 | + left = referenceRect.left + scrollX - tooltipRect.width - 10 - offset; |
| 118 | + if (arrowRect && offset === 0) { |
| 119 | + arrowTop = tooltipRect.height / 2 - arrowRect.height / 2; |
| 120 | + arrowLeft = tooltipRect.width - 5; |
| 121 | + } |
| 122 | + break; |
| 123 | + case 'left-start': |
| 124 | + top = referenceRect.top + scrollY; |
| 125 | + left = referenceRect.left + scrollX - tooltipRect.width - 10 - offset; |
| 126 | + if (arrowRect && offset === 0) { |
| 127 | + arrowTop = arrowRect.height; |
| 128 | + arrowLeft = tooltipRect.width - 5; |
| 129 | + } |
| 130 | + break; |
| 131 | + case 'left-end': |
| 132 | + top = referenceRect.bottom + scrollY - tooltipRect.height; |
| 133 | + left = referenceRect.left + scrollX - tooltipRect.width - 10 - offset; |
| 134 | + if (arrowRect && offset === 0) { |
| 135 | + arrowTop = tooltipRect.height - arrowRect.height * 2; |
| 136 | + arrowLeft = tooltipRect.width - 5; |
| 137 | + } |
| 138 | + break; |
| 139 | + case 'right': |
| 140 | + top = referenceRect.top + scrollY + referenceRect.height / 2 - tooltipRect.height / 2; |
| 141 | + left = referenceRect.right + scrollX + 10 + offset; |
| 142 | + if (arrowRect && offset === 0) { |
| 143 | + arrowTop = tooltipRect.height / 2 - arrowRect.height / 2; |
| 144 | + arrowLeft = -arrowRect.width / 2 + 2; |
| 145 | + } |
| 146 | + break; |
| 147 | + case 'right-start': |
| 148 | + top = referenceRect.top + scrollY; |
| 149 | + left = referenceRect.right + scrollX + 10 + offset; |
| 150 | + if (arrowRect && offset === 0) { |
| 151 | + arrowTop = arrowRect.height; |
| 152 | + arrowLeft = -arrowRect.width / 2 + 2; |
| 153 | + } |
| 154 | + break; |
| 155 | + case 'right-end': |
| 156 | + top = referenceRect.bottom + scrollY - tooltipRect.height; |
| 157 | + left = referenceRect.right + scrollX + 10 + offset; |
| 158 | + if (arrowRect && offset === 0) { |
| 159 | + arrowTop = tooltipRect.height - arrowRect.height * 2; |
| 160 | + arrowLeft = -arrowRect.width / 2 + 2; |
| 161 | + } |
| 162 | + break; |
| 163 | + } |
| 164 | +
|
| 165 | + tooltipElement.style.top = `${top}px`; |
| 166 | + tooltipElement.style.left = `${left}px`; |
| 167 | +
|
| 168 | + if (arrowEl && arrowRect) { |
| 169 | + arrowEl.style.top = `${arrowTop}px`; |
| 170 | + arrowEl.style.left = `${arrowLeft}px`; |
| 171 | + } |
| 172 | + }; |
| 173 | +
|
| 174 | + $effect(() => { |
| 175 | + triggerElement = document.querySelector(triggeredBy); |
| 176 | + referenceElement = reference ? document.querySelector(reference) : triggerElement; |
| 177 | +
|
| 178 | + if (triggerElement) { |
| 179 | + triggerElement.addEventListener('mouseenter', showTooltip); |
| 180 | + triggerElement.addEventListener('mouseleave', onTriggerLeave); |
| 181 | + } |
| 182 | +
|
| 183 | + const handlePositionUpdate = () => { |
| 184 | + if (visible) { |
| 185 | + positionTooltip(); |
| 186 | + } |
| 187 | + }; |
| 188 | +
|
| 189 | + window.addEventListener('resize', handlePositionUpdate); |
| 190 | + window.addEventListener('scroll', handlePositionUpdate, true); |
| 191 | +
|
| 192 | + onDestroy(() => { |
| 193 | + if (triggerElement) { |
| 194 | + triggerElement.removeEventListener('mouseenter', showTooltip); |
| 195 | + triggerElement.removeEventListener('mouseleave', onTriggerLeave); |
| 196 | + } |
| 197 | + window.removeEventListener('resize', handlePositionUpdate); |
| 198 | + window.removeEventListener('scroll', handlePositionUpdate, true); |
| 199 | + if (hideTimeoutId !== undefined) { |
| 200 | + clearTimeout(hideTimeoutId); |
| 201 | + } |
| 202 | + }); |
| 203 | + }); |
| 204 | +</script> |
| 205 | + |
| 206 | +{#if transition && visible} |
| 207 | + <div transition:transition={params || defaultParams} role="tooltip" bind:this={tooltipElement} class={`${base({ className })} ${positioned ? 'visible opacity-100' : 'invisible opacity-0'} transition-opacity duration-200`} onmouseenter={onPopoverEnter} onmouseleave={onPopoverLeave} {...restProps}> |
| 208 | + {#if typeof titleSlot === 'string'} |
| 209 | + <div class={title()}> |
| 210 | + <h3 class={h3()}>{titleSlot}</h3> |
| 211 | + </div> |
| 212 | + {:else if titleSlot} |
| 213 | + {@render titleSlot()} |
| 214 | + {/if} |
| 215 | + {@render children()} |
| 216 | + {#if arrow}<div bind:this={arrowEl} class={arrowBase({ arrow, position })}></div>{/if} |
| 217 | + </div> |
| 218 | +{:else if visible} |
| 219 | + <div role="tooltip" bind:this={tooltipElement} class={`${base({ className })} ${positioned ? 'visible opacity-100' : 'invisible opacity-0'} transition-opacity duration-200`} onmouseenter={onPopoverEnter} onmouseleave={onPopoverLeave} {...restProps}> |
| 220 | + {#if typeof titleSlot === 'string'} |
| 221 | + <div class={title()}> |
| 222 | + <h3 class={h3()}>{titleSlot}</h3> |
| 223 | + </div> |
| 224 | + {:else if titleSlot} |
| 225 | + {@render titleSlot()} |
| 226 | + {/if} |
| 227 | + {@render children()} |
| 228 | + {#if arrow}<div bind:this={arrowEl} class={arrowBase({ arrow, position })}></div>{/if} |
| 229 | + </div> |
| 230 | +{/if} |
| 231 | + |
| 232 | +<!-- |
| 233 | +@component |
| 234 | +[Go to docs](https://svelte-5-ui-lib.codewithshin.com/) |
| 235 | +## Props |
| 236 | +@prop children |
| 237 | +@prop titleSlot |
| 238 | +@prop color = 'default' |
| 239 | +@prop arrow = true |
| 240 | +@prop offset = 0 |
| 241 | +@prop triggeredBy |
| 242 | +@prop position = 'top' |
| 243 | +@prop class: className |
| 244 | +@prop reference |
| 245 | +@prop transition = fade |
| 246 | +@prop params |
| 247 | +@prop ...restProps |
| 248 | +--> |
0 commit comments