Tooltip mouseout handling improved: use relatedTarget instead of :hover

shoelace-style/webawesome

Tooltip が Chrome でスロットされた子要素上にマウスを移動した際に誤って閉じてしまうバグを、handleMouseOutevent.relatedTarget を利用して判定ロジックを変更することで解消しました。

背景

このバグは、handleMouseOut がマウスアウト時に this.anchor?.matches(':hover')this.matches(':hover') でホストとツールチップのホバー状態を確認していたため、Chrome でポインタがツールチップの子要素へ移動する瞬間に :hover が一時的に false になることが原因でした。結果としてユーザーがツールチップ内部のリンクやボタンに触れたときにツールチップが閉じてしまい、期待したインタラクションが阻害されました。event.relatedTarget を用いることで、マウスが実際に移動した先の要素を確実に取得でき、ホバー状態に依存しない判定が可能になります。

技術的な変更

handleMouseOut のシグネチャを private handleMouseOut = (event: MouseEvent) => { … } と変更し、:hover マッチングを排除しました。代わりに event.relatedTarget を取得し、Node.contains()relatedTarget がアンカーもしくはツールチップ自身に含まれているかを判定します。含まれていれば何もせず、含まれていなければ既存の hideDelay に従ってツールチップを非表示にします。

@@ -198,22 +198,26 @@ export default class WaTooltip extends WebAwesomeElement {
     }
   };

-  private handleMouseOut = () => {
+  private handleMouseOut = (event: MouseEvent) => {
     if (this.hasTrigger('hover')) {
-      const anchorHovered = Boolean(this.anchor?.matches(':hover'));
-      const tooltipHovered = this.matches(':hover');
-
-      if (anchorHovered || tooltipHovered) {
+      const relatedTarget = event.relatedTarget as Node | null;
+
+      // Use the event's relatedTarget (the element the pointer moved to) to determine whether the
+      // pointer is still within the anchor or the tooltip itself. Relying on `:hover` matching here is
+      // unreliable in Chrome when the pointer moves onto a slotted child element of the tooltip, since
+      // the host's `:hover` state can briefly report false during that transition.
+      const movedIntoAnchor = Boolean(relatedTarget && this.anchor?.contains(relatedTarget));
+      const movedIntoTooltip = Boolean(relatedTarget && this.contains(relatedTarget));
+
+      if (movedIntoAnchor || movedIntoTooltip) {
         return;
       }

       clearTimeout(this.hoverTimeout);
-
-      if (!(anchorHovered || tooltipHovered)) {
-        this.hoverTimeout = window.setTimeout(() => {
-          this.hide();
-        }, this.hideDelay);
-      }
+
+      this.hoverTimeout = window.setTimeout(() => {
+        this.hide();
+      }, this.hideDelay);
     }
   };

テストケースも追加され、ツールチップが開いた状態で mouseout イベントに relatedTarget としてスロットされた子リンクを設定し、ツールチップが閉じないことを検証しています。

@@ -441,6 +441,30 @@ describe('<wa-tooltip>', () => {
       expect(tooltip.open).to.be.true;
     });
+
+    it('should remain open when the pointer moves onto a slotted child element of the tooltip', async () => {
+      const el = await fixtures[0]<HTMLDivElement>(html`
+        <div>
+          <wa-button id="hover-child-btn">Hover me</wa-button>
+          <wa-tooltip for="hover-child-btn" trigger="hover" show-delay="0" hide-delay="0">
+            <a href="#" id="tooltip-link">A link inside the tooltip</a>
+          </wa-tooltip>
+        </div>
+      `);
+      const tooltip = el.querySelector<WaTooltip>('wa-tooltip')!;
+      const childLink = el.querySelector<HTMLElement>('#tooltip-link')!;
+
+      tooltip.open = true;
+      await waitUntil(() => tooltip.open);
+      expect(tooltip.open).to.be.true;
+
+      // Simulate the pointer moving off of the tooltip host and onto a slotted child element.
+      // relatedTarget is the element the pointer is moving to, which is how browsers report this.
+      tooltip.dispatchEvent(new MouseEvent('mouseout', { relatedTarget: childLink, bubbles: true }));
+      await aTimeout(50);
+
+      expect(tooltip.open).to.be.true;
+    });
   });

   describe('keyboard navigation', () => {

設計判断

今回の修正は event.relatedTargetNode.contains() を組み合わせてマウスアウト判定を行う方針に統一した点が重要です。relatedTarget は全主要ブラウザで mouseout 時に確実に提供される情報であり、ホバー疑似クラスに依存しないため Chrome 固有のタイミングバグを根本的に排除できます。設計上のトレードオフとしては、relatedTargetnull(例えばウィンドウ外への移動)でも安全に処理できるように contains 判定を null チェックでラップしている点です。既存の API や公開インターフェースは変更せず、内部ロジックのみを改善したため、下位互換性への影響はありません。

まとめ

handleMouseOut:hover 依存から event.relatedTarget 依存へと変更されたことで、Chrome におけるツールチップの不意の关闭が解消されました。追加されたテストにより挙動が保証され、既存ユーザーへの影響はなく、より安定したインタラクション体験が提供されます。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
26b71468

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文があり、背景、技術的な変更、設計判断、まとめの各セクションが揃っており、総論→各論→結論の構成が明確です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは正しい diff 表記で、ファイル名付きハイライトは使用していないため問題なし。PR リンクは「PR #2512」の形で # が含まれ、GitHubリンク記法も適切です。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

技術者向けの専門的な記述で、初心者向けの余計な説明はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論・各論・結論の流れで書かれ、段落はトピックセンテンスで始まり 1 段落 1 トピック、文数も適切に分割されています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内の diff ブロックは提供された PR の diff と完全に一致しており、コード引用や変更点にずれはありません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

用語(handleMouseOut、event.relatedTarget、Node.contains など)は PR と一致し、誤用は見られません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

説明は PR の根拠に基づき、技術的に正確で因果関係も論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

すべての主張が PR のタイトル、説明、Diff に裏付けられており、推測や捏造はありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR 番号 #2512 など数値は正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の内容を的確に要約しており、矛盾はありません。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事に外部知識(バージョンサポート等)の記述はなく、PR に基づく内容だけです。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現は使用されておらず、PR の記述と食い違いはありません。