tooltip.svelte
1 <script lang="ts"> 2 import { createBubbler, stopPropagation } from 'svelte/legacy'; 3 4 const bubble = createBubbler(); 5 import { onMount, tick } from 'svelte'; 6 import Copyable from '../copyable/copyable.svelte'; 7 import setTabIndexRecursively from '$lib/utils/set-tab-index-recursive'; 8 import { fade } from 'svelte/transition'; 9 10 interface Props { 11 text?: string | undefined; 12 copyable?: boolean; 13 disabled?: boolean; 14 children?: import('svelte').Snippet; 15 tooltip_content?: import('svelte').Snippet; 16 } 17 18 let { 19 text = undefined, 20 copyable = false, 21 disabled = false, 22 children, 23 tooltip_content, 24 }: Props = $props(); 25 26 let tooltipElem: HTMLSpanElement | undefined = $state(); 27 let contentElem: HTMLSpanElement | undefined = $state(); 28 let expanded = $state(false); 29 30 let tooltipPos = $state({ 31 left: 0, 32 right: 0, 33 top: 0, 34 }); 35 36 const TOOLTIP_MARGIN = 0; 37 38 async function show() { 39 tooltipPos = { 40 left: 0, 41 right: 0, 42 top: 0, 43 }; 44 await tick(); 45 updatePos(); 46 expanded = true; 47 await tick(); 48 setContentFocussable(true); 49 } 50 51 function hide() { 52 if (!expanded) return; 53 54 setContentFocussable(false); 55 expanded = false; 56 } 57 58 let hoverTimeout: ReturnType<typeof setTimeout> | undefined; 59 function handleHover(hovering: boolean) { 60 clearTimeout(hoverTimeout); 61 62 if (hovering) { 63 hoverTimeout = setTimeout(show, 400); 64 } else { 65 hide(); 66 } 67 } 68 69 const MAX_WIDTH = 512; 70 71 async function updatePos() { 72 await tick(); 73 74 if (!tooltipElem || !contentElem) return; 75 76 const triggerPos = tooltipElem.getBoundingClientRect(); 77 let contentPos = contentElem.getBoundingClientRect(); 78 79 let newLeft: number; 80 let newTop: number; 81 let newRight: number; 82 83 const triggerCenter = triggerPos.left + triggerPos.width / 2; 84 const width = Math.min(contentPos.width, MAX_WIDTH); 85 newLeft = Math.max(triggerCenter - width / 2, 0); 86 newRight = Math.max(Math.abs(triggerCenter - window.innerWidth) - width / 2, 0); 87 88 // Set left & right already so that we can see how high the content will be. 89 tooltipPos = { 90 left: newLeft, 91 right: newRight, 92 top: 0, 93 }; 94 95 // Wait for render, so that we can grab the actual content height given the new left & right vals. 96 await tick(); 97 contentPos = contentElem.getBoundingClientRect(); 98 99 // Render the tooltip either above or below depending on position on screen. 100 if (triggerPos.top > contentPos.height + TOOLTIP_MARGIN * 2) { 101 newTop = triggerPos.top - contentPos.height - TOOLTIP_MARGIN; 102 } else { 103 newTop = triggerPos.bottom; 104 } 105 106 tooltipPos = { 107 left: newLeft, 108 right: newRight, 109 top: newTop, 110 }; 111 } 112 113 function setContentFocussable(canFocus: boolean) { 114 if (!contentElem) return; 115 setTabIndexRecursively(contentElem, canFocus ? '0' : '-1'); 116 } 117 118 onMount(() => { 119 const updatePosIfExpanded = () => expanded && updatePos(); 120 121 window.addEventListener('scroll', updatePosIfExpanded); 122 window.addEventListener('resize', updatePosIfExpanded); 123 124 return () => { 125 window.removeEventListener('scroll', updatePosIfExpanded); 126 window.removeEventListener('resize', updatePosIfExpanded); 127 }; 128 }); 129 </script> 130 131 <!-- svelte-ignore a11y_no_static_element_interactions --> 132 <span 133 bind:this={tooltipElem} 134 class="tooltip" 135 class:disabled 136 onpointerover={(e) => { 137 if (!disabled && e.pointerType !== 'touch') handleHover(true); 138 }} 139 onpointerleave={() => !disabled && handleHover(false)} 140 > 141 <div class="trigger">{@render children?.()}</div> 142 {#if expanded} 143 <!-- svelte-ignore a11y_no_static_element_interactions --> 144 <div 145 transition:fade={{ duration: 200 }} 146 bind:this={contentElem} 147 class="expanded-tooltip" 148 style:left={`${tooltipPos.left}px`} 149 style:right={`${tooltipPos.right}px`} 150 style:top={`${tooltipPos.top}px`} 151 onclick={stopPropagation(bubble('click'))} 152 onkeydown={stopPropagation(bubble('keydown'))} 153 > 154 <div class="target-buffer"></div> 155 <div class="tooltip-content typo-text" style:max-width={MAX_WIDTH}> 156 {#if copyable && text} 157 <Copyable alwaysVisible value={text} 158 ><div class="inner">{@render tooltip_content?.()}</div></Copyable 159 > 160 {:else} 161 <div class="inner">{@render tooltip_content?.()}</div> 162 {/if} 163 </div> 164 </div> 165 {/if} 166 </span> 167 168 <style> 169 .tooltip { 170 position: relative; 171 white-space: initial; 172 width: 100%; 173 max-width: fit-content; 174 } 175 176 .trigger { 177 user-select: none; 178 } 179 180 .expanded-tooltip { 181 position: fixed; 182 border: 8px solid transparent; 183 box-sizing: border-box; 184 max-width: fit-content; 185 z-index: 2000; 186 } 187 188 .tooltip-content { 189 z-index: 10; 190 box-shadow: var(--elevation-medium); 191 background-color: var(--color-background); 192 border-radius: 1rem 0 1rem 1rem; 193 padding: 0.5rem 0.75rem; 194 color: var(--color-foreground); 195 text-align: left; 196 max-width: fit-content; 197 overflow: hidden; 198 } 199 200 .tooltip-content .inner { 201 text-overflow: ellipsis; 202 overflow: hidden; 203 max-width: fit-content; 204 } 205 206 .target-buffer { 207 height: 0.5rem; 208 width: 100%; 209 } 210 </style>