Tooltip mouseout handling improved: use relatedTarget instead of :hover
Tooltip が Chrome でスロットされた子要素上にマウスを移動した際に誤って閉じてしまうバグを、handleMouseOut が event.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.relatedTarget と Node.contains() を組み合わせてマウスアウト判定を行う方針に統一した点が重要です。relatedTarget は全主要ブラウザで mouseout 時に確実に提供される情報であり、ホバー疑似クラスに依存しないため Chrome 固有のタイミングバグを根本的に排除できます。設計上のトレードオフとしては、relatedTarget が null(例えばウィンドウ外への移動)でも安全に処理できるように contains 判定を null チェックでラップしている点です。既存の API や公開インターフェースは変更せず、内部ロジックのみを改善したため、下位互換性への影響はありません。
まとめ
handleMouseOut が :hover 依存から event.relatedTarget 依存へと変更されたことで、Chrome におけるツールチップの不意の关闭が解消されました。追加されたテストにより挙動が保証され、既存ユーザーへの影響はなく、より安定したインタラクション体験が提供されます。