/ src / lib / components / tooltip / tooltip.svelte
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>