🏵️

Svelte5のTooltipコンポーネント作成メモ

2024/12/05に公開


title属性で代用しようと思っていたのですが、無視できない頻度でツールチップが表示されない場合があったのでTooltipコンポーネントを作成することにしました。ただ、フォーカス時に表示するようにしていない等があるので厳密にはツールチップの要件を満たしていません。微妙に手間がかかったので最低限のスタイリング以外を省いたものを備忘として残しておきます。

作成したコンポーネント

REPL

https://svelte.dev/playground/hello-world#H4sIAAAAAAAACqVXWW_cNhD-K2PFD1Kj1W4M9MHSyobRuEiBBA1aF21gGYi8GnnZcCmBpPaIqv9e8NC569Rt_WBbnOub4VysHZZu0Amdd0hpAbuC0wxczIjEzHN8JycUhRPe1448lIpPHTh-K3VTloHYIpXq7DEVeOp8VTCJTAondJZixUkpYVNkFUWgKXuKE0eKxLlKWCLJpiy4hLuioJKUPtTS_NdAzosNJE4wtzSrPXGihC3nRqtSwZYZ2WpdS1GmDCqBoVUS14kj10QAEWCP3iROo5kTqf1_c3amRedK9sVaLiZaLiZalnOLiS0tepjrz4QthTxQ1MIZ2UJttGRElDQ9hJBT3Efm7M9KSJIfZjaYIYgyXeHsEeUOkWmmRv3SeK2ex3T15YkXFctmq4IWPIRXi8Wi5V3OjXHHdyTupRNKXmHjP3PT47iPb_uI9tIbB1C24DdGiZDIIAbXg_gKtgXJIkVeFUxIeHv7_uYTxPDGoG-Pf_7xx19v7yCG7weHZSGIJIXSVatTAKS4CaGGfQgLHw4hLKDxDWlVcaHCcoLYDHRK3JTvNULkYYf2_gFiuH_QfBQlSLLBopI_ZRDDojtdc2QZcojhXMhUolsDLVZfQshTKtCHLRHkkWL3ra4ihMRJnMaLdJJI3OuqyCu20p7ZtHNZkWEI7-4-vL-luEEmW2khOWFPXgg3WuAXlBVnbTiUVJBykr5FczUmWEoyslHRTlf9pRRM2_IhcTZFJRCZRJ44Poh1sbvdIpOWrpR4nlXDrVnIUEheHEJYUUxZVbqtak9H2SQuS2Tn4Fjtt110cRvCB4VKS3Tp07prUbi41aQa5nNIVTpWAsE4ovlAW3W1B1F7NLz4oKzE2p2GgmK6RRWKNckMAtfz_o2CTWHkJU9XX37Q-TjQ0Menj05viWQhsGrziNzvruuGc9U5-iT9LzFSJlwvgiYahcs4ewRoBP3F1uSaF1JSdC8W_jPXo2NjI0lycG0tBbZmPKitsgialq-t_8DUdrCHGHAbrChBJv-InmM7DNk-tdH32vADjJLTHSVh65iC2DeBsziGhYKosp7fmfOe7nWYW6-UznEhDjuKQNnqMD1yEgwlySv0TbM0-TMGbi61BfscqmPLi2iMszeoW1YrMcz0vOC36Wrt7hXQveudgjNtBn3Oqhx6NkdNmrTX2Aq7fcnZ3O0-LWrVdFvIfW1pUIPNo4aK6XSGbuUYLBoDtoJNOeaosl1MGfWAG_XhqVyqiUdytjxgsPtUktBu8ZnuPWezF_4YdjuWpxuYGllmYKqm-5Zsbd-Fv6BiGeaEYdbPMq8bc2lZYjqYcjrOpnwUWecMoUSqOjvPkJMtZtN6hmtInJ4zbGdjlDgQTkhrkmXIIvg6IyzDfQizy8vLyyhxzNAEOMc8x5UMSo62YOppBtscsTduudJMbVofbXvoUtf8-ZZSlWAnNYp1UdHsRgdopG_aVgZcw5YyNHCi5XWRV-U_KVWb9B1lXIFTV4dGz_6515o1QZCvqhXUsCOZXIc6ea6DIs8Fyt_VEVxfq9VqjeRpLcf0d_pMM9hqHDRmxai7946wrNgFhDHkWuPsuMcvNY5gZwweD4HZgByemBGvwbXb5Hdw4Z2EcphAMeCPsBxaLGvr3PGomQ3px2gO8Nqutu2d_Z9Cr1-R3OaIntrqfQR68Y_rz63pEHKyxywCirkM4bye3EJT7iOQRXlMOmjSed3XZvO5AbVezkyJqubCK0wceCQsC9XTKa6VaPtkqofzz0C076V6TvLmZa-TQWccP03GhJe_RI92btuOl3eAe4ksE-AGQZDyJxFCyg73D6Z7sMOVS9RWuU1pv5zlLIQ7NdY6kY8pTzcokYvl3dVo2CWyf07wVsWw_UY9D02F7FjMpFYkU5lI01Lobm360NtUYsCKnevBTEuOuHmln1_PobPAAHIWrFJKXVZR6oP1x4tastIL8cCWpTTmTzvFvxGHzpJuQ0qf7j0Vc42lYRsyjEerDO_x6M9T61NnxhiywXI9uIqhvcCJ4c5g43csMINOtPN18MJu63acxg_N39J3TxB4EQAA

コード全体

code
App.svelte
<script module lang="ts">
  import Tooltip, {tooltip} from "./Tooltip.svelte";
</script>

<div>
  <span use:tooltip={"this is tooltip1"}>
    Hello1!!
  </span>
  <span use:tooltip={"this is tooltip2"}>
    Hello2!!
  </span>
</div>

<Tooltip />


<style>
  div {
    display: flex;
    justify-content: space-between;
  }
  span {
    background-color: #000;
  }
</style>
Tooltip.svelte
<script module lang="ts">
  type Unlisten = () => void;
  const DELAY = 1000;
  const OFFSET = 5;
  const position = {
    elem: { x: 0, y: 0 },
    cursor: { x: 0, y: 0 },
  };
  const tempListener: Unlisten[] = [];
  let timeoutId = 0;
  let hrender = $state({ lock: false, visible: false, text: ""});

  export function tooltip(node: HTMLElement, text: string): ActionReturn {
    node.ariaDescription = text;
    const unlisten = on(node, "mouseenter", showEvent(node, text));
    return { destroy: cleanup(unlisten) };
  }

  function showEvent(node: HTMLElement, text: string): (ev: MouseEvent) => void {
    return (ev) => { // at mouse enter
      show(text);
      tempListener.push(on(node, "mouseleave", hideEvent()));
      tempListener.push(on(node, "mousemove", trackCursor()));
    };
  }
  function hideEvent(id: number, unlistenArray: Unlisten[]): (ev: MouseEvent) => void {
    return (ev) => { hide(); }; // at mouse leave
  }
  function trackCursor(): (ev: MouseEvent) => void {
    return throttle(20, (ev) => { // at mouse move
      if (hrender.visible) { return; }
      position.cursor.x = ev.clientX;
      position.cursor.y = ev.clientY;
    });
  }
  function show(text: string) {
    if (timeoutId !== 0) { clearTimeout(timeoutId); }
    hrender.text = text;
    timeoutId = setTimeout(() => hrender.visible = true, DELAY);
  }
  function hide() {
    clearTimeout(timeoutId);
    timeoutId = 0;
    hrender.visible = false;
    tempListener.forEach(x => x());
  }
  function cleanup(unlisten: Unlisten): () => void {
    return () => {
      unlisten();
      hide();
      hrender.lock = false
    };
  }

  import { untrack } from "svelte";
  import { on } from "svelte/events";
  import { type ActionReturn } from "svelte/action";
  import { throttle } from "./util.svelte"
</script>

<!---------------------------------------->

<script lang="ts">
  let elem: HTMLDivElement | undefined = $state();
  let appear = $state(false);
  let visibility = $derived(hrender.visible ? "visibility: visible;" : "visibility: hidden; z-index: -9999;");

  $effect.pre(() => { hrender.visible;
    untrack(() => adjustPosition());
  });
  $effect.pre(() => { hrender.lock;
    untrack(() => shouldAppear());
  });

  function shouldAppear() {
    if (hrender.lock) { return; }
    appear = true;
    hrender.lock = true;
  }
  function adjustPosition() {
    if (!hrender.visible) { return; }
    const size = { width: elem?.offsetWidth ?? 0, height: elem?.offsetHeight ?? 0 };
    position.elem.x = window.innerWidth-position.cursor.x < size.width ? position.cursor.x-size.width : position.cursor.x + (OFFSET * 2);
    position.elem.y = window.innerHeight-position.cursor.y < size.height ? position.cursor.y-size.height : position.cursor.y + OFFSET;
  }
</script>

<!---------------------------------------->

{#if appear}
  <div style={`position: fixed; left: ${position.elem.x}px; top: ${position.elem.y}px; ${visibility}`} aria-hidden="true" bind:this={elem}>
    {hrender.text}
  </div>
{/if}
util.svelte
<script module lang="ts">
  export function throttle<T extends (...args: any[]) => any>(interval: number, fn: T): (...args: Parameters<T>) => void {
    let timer: number | undefined;
    let last: number = 0;
    const elapsed = () => Date.now() - last;
    const run = (args: Parameters<T>) => {
      fn.call(null, ...args);
      last = Date.now();
    }
    return (...args: Parameters<T>) => {
      if (!last) { run(args); return; }
      clearTimeout(timer);
      timer = setTimeout(() => {
        if (elapsed() >= interval) { run(args); }
      }, interval - elapsed());
    };
  }
</script>

簡単な解説

単一要素の使い回し

ツールチップは一度に一つだけ表示される前提であると、ツールチップを設定した要素毎に追加でツールチップ要素がDOMツリーに現れるのはさすがに無駄が多い。そのため、ツールチップとして表示するための要素(div)は1つだけにするようにし、それを表示時に内容変更と場所調整して使い回す仕様にした。そのような仕様のためほぼ全ての変数をモジュール単位で管理している。

表示ディレイ

実装を簡素化するためにsetTimeoutを用いてツールチップを設定した要素のmouseenter時から固定時間後(上記コードでは1秒後)に表示する仕様にした。固定時間内にマウスが出た場合は、clearTimeoutで表示をキャンセルするようにしている。

カーソル座標から表示位置計算

カーソルの座標はイベントハンドラ内でしか取得できないため、mouseenterからmouseleaveまでの間は一定間隔でカーソル座標をトラッキングする仕様にした。ツールチップを表示するタイミングでカーソル座標とツールチップ要素サイズ、表示領域の大きさの3つの要素からツールチップが見切れないように表示位置を調整している。ツールチップ要素はdisplay: noneを設定するとサイズ取得ができないため、visibilityz-index設定で表示制御をしつつサイズ取得を可能にしている。

要素重複の防止

ルートの+layout.svelte<Tooltip />を1つ設置すれば良いだけだが、コンポーネントのため意図せずツールチップコンポーネントが複数設置されることが想定される。これらを適切に表示するためロック機構を用いて常に一つだけがレンダリングされるようにした。

アクセシビリティ

単一要素を使い回す仕様のため、標準に沿ったrole="tooltip"設定等は使用しないことにした。ツールチップ要素にはaria-hidden="true"を設定し、何の意味もない単なる視覚要素とする一方で、その内容を基の要素のaria-description属性に付加してアクセシビリティ情報を補うようにした。

参考文献

Discussion