APG Patterns
English GitHub
English GitHub

Tooltip

要素がキーボードフォーカスを受け取ったとき、またはマウスがホバーしたときに、その要素に関連する情報を表示するポップアップ。

🤖 AI Implementation Guide

デモ

アクセシビリティ

WAI-ARIA ロール

WAI-ARIA ステート & プロパティ

aria-describedby

トリガー要素にアクセシブルな説明を提供するために、ツールチップ要素を参照します。

適用先 トリガー要素(ラッパー)
タイミング ツールチップが表示されている時のみ
参照 aria-describedby (opens in new tab)

aria-hidden

ツールチップが支援技術から隠されているかどうかを示します。

true(非表示)| false(表示)
デフォルト true
参照 aria-hidden (opens in new tab)

キーボードサポート

キー アクション
Escape ツールチップを閉じる
Tab 標準のフォーカスナビゲーション。トリガーがフォーカスを受け取るとツールチップが表示される

フォーカス管理

  • ツールチップはフォーカスを受け取らない - APGに従い、ツールチップはフォーカス可能であってはいけません。インタラクティブなコンテンツが必要な場合は、DialogまたはPopoverパターンを使用してください。
  • フォーカスが表示をトリガーする - トリガー要素がフォーカスを受け取ると、設定された遅延後にツールチップが表示されます。
  • ぼかしがツールチップを非表示にする - フォーカスがトリガー要素を離れると、ツールチップは非表示になります。

マウス/ポインター動作

  • ホバーが表示をトリガーする - ポインターをトリガー上に移動すると、遅延後にツールチップが表示されます。
  • ポインター離脱で非表示 - ポインターをトリガーから離すと、ツールチップが非表示になります。

重要な注意事項

注意: APG Tooltipパターンは現在WAIによって「作業中」とマークされています。この実装は文書化されたガイドラインに従っていますが、仕様は進化する可能性があります。 View APG Tooltip Pattern (opens in new tab)

ビジュアルデザイン

この実装は、ツールチップの視認性のベストプラクティスに従っています:

  • 高コントラスト - 暗い背景に明るいテキストで可読性を確保
  • ダークモード対応 - ダークモードで色が適切に反転
  • トリガーの近くに配置 - ツールチップはトリガー要素に隣接して表示
  • 設定可能な遅延 - カーソル移動中の誤作動を防止

ソースコード

Tooltip.svelte
<script lang="ts" module>
  import type { Snippet } from 'svelte';

  export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';

  export interface TooltipProps {
    /** Tooltip content - can be string or Snippet for rich content */
    content: string | Snippet;
    /** Trigger element - must be a focusable element for keyboard accessibility */
    children?: Snippet<[{ describedBy: string | undefined }]>;
    /** Controlled open state */
    open?: boolean;
    /** Default open state (uncontrolled) */
    defaultOpen?: boolean;
    /** Delay before showing tooltip (ms) */
    delay?: number;
    /** Tooltip placement */
    placement?: TooltipPlacement;
    /**
     * Tooltip ID - Required for SSR/hydration consistency.
     * Must be unique and stable across server and client renders.
     */
    id: string;
    /** Whether the tooltip is disabled */
    disabled?: boolean;
    /** Additional class name for the wrapper */
    class?: string;
    /** Additional class name for the tooltip content */
    tooltipClass?: string;
  }
</script>

<script lang="ts">
  import { cn } from '@/lib/utils';
  import { onDestroy } from 'svelte';

  let {
    content,
    children,
    open: controlledOpen = undefined,
    defaultOpen = false,
    delay = 300,
    placement = 'top',
    id,
    disabled = false,
    class: className = '',
    tooltipClass = '',
    onOpenChange,
  }: TooltipProps & { onOpenChange?: (open: boolean) => void } = $props();

  // Use provided id directly - required for SSR/hydration consistency
  const tooltipId = $derived(id);

  let internalOpen = $state(defaultOpen);
  let timeout: ReturnType<typeof setTimeout> | null = null;

  let isControlled = $derived(controlledOpen !== undefined);
  let isOpen = $derived(isControlled ? controlledOpen : internalOpen);

  // aria-describedby should always be set when not disabled for screen reader accessibility
  // This ensures SR users know the element has a description even before tooltip is visible
  let describedBy = $derived(!disabled ? tooltipId : undefined);

  function setOpen(value: boolean) {
    if (controlledOpen === undefined) {
      internalOpen = value;
    }
    onOpenChange?.(value);
  }

  function handleKeyDown(event: KeyboardEvent) {
    if (event.key === 'Escape') {
      hideTooltip();
    }
  }

  function showTooltip() {
    if (disabled) return;
    if (timeout) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(() => {
      setOpen(true);
    }, delay);
  }

  function hideTooltip() {
    if (timeout) {
      clearTimeout(timeout);
      timeout = null;
    }
    setOpen(false);
  }

  // Manage keydown listener based on isOpen state
  // This handles both controlled and uncontrolled modes
  $effect(() => {
    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
    } else {
      document.removeEventListener('keydown', handleKeyDown);
    }
  });

  // Cleanup on destroy - fix for memory leak
  onDestroy(() => {
    if (timeout) {
      clearTimeout(timeout);
      timeout = null;
    }
    document.removeEventListener('keydown', handleKeyDown);
  });

  const placementClasses: Record<TooltipPlacement, string> = {
    top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
    bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
    left: 'right-full top-1/2 -translate-y-1/2 mr-2',
    right: 'left-full top-1/2 -translate-y-1/2 ml-2',
  };
</script>

<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
  class={cn('apg-tooltip-trigger', 'relative inline-block', className)}
  onmouseenter={showTooltip}
  onmouseleave={hideTooltip}
  onfocusin={showTooltip}
  onfocusout={hideTooltip}
>
  {#if children}
    {@render children({ describedBy })}
  {/if}
  <span
    id={tooltipId}
    role="tooltip"
    aria-hidden={!isOpen}
    class={cn(
      'apg-tooltip',
      'absolute z-50 px-3 py-1.5 text-sm',
      'rounded-md bg-gray-900 text-white shadow-lg',
      'dark:bg-gray-100 dark:text-gray-900',
      'pointer-events-none whitespace-nowrap',
      'transition-opacity duration-150',
      placementClasses[placement],
      isOpen ? 'visible opacity-100' : 'invisible opacity-0',
      tooltipClass
    )}
  >
    {#if typeof content === 'string'}
      {content}
    {:else}
      {@render content()}
    {/if}
  </span>
</span>

使い方

使用例
<script>
  import Tooltip from './Tooltip.svelte';
</script>

<!-- aria-describedby のレンダープロパティを使用した基本的な使用法 -->
<Tooltip
  id="tooltip-save"
  content="Save your changes"
  placement="top"
  delay={300}
>
  {#snippet children({ describedBy })}
    <button aria-describedby={describedBy}>Save</button>
  {/snippet}
</Tooltip>

<!-- Snippet を使用したリッチコンテンツ -->
<Tooltip id="tooltip-shortcut">
  {#snippet content()}
    <span class="flex items-center gap-1">
      <kbd>Ctrl</kbd>+<kbd>S</kbd>
    </span>
  {/snippet}
  {#snippet children({ describedBy })}
    <button aria-describedby={describedBy}>Keyboard shortcut</button>
  {/snippet}
</Tooltip>

API

プロパティ デフォルト 説明
id string - ツールチップの一意の ID(SSR/ハイドレーション一貫性のために必須)
content string | Snippet - ツールチップの内容(必須)
children Snippet<[{ describedBy }]> - レンダープロパティパターン - aria-describedby 用の describedBy を受け取る
open boolean - 制御された開閉状態
defaultOpen boolean false デフォルトの開閉状態(非制御)
onOpenChange (open: boolean) => void - 開閉状態変更時のコールバック
delay number 300 表示前の遅延時間(ミリ秒)
placement 'top' | 'bottom' | 'left' | 'right' 'top' ツールチップの位置
disabled boolean false ツールチップを無効にする

テスト

テスト概要

ToolチップコンポーネントのテストはAPG準拠要件に基づいて優先度レベルに分類されています。

テストカテゴリ

高優先度: APGコア準拠

テスト APG要件
role="tooltip" が存在する ツールチップコンテナはtooltipロールを持つ必要がある
閉じている時はaria-hidden 非表示のツールチップは支援技術から読み取られてはならない
表示時はaria-describedby トリガーは表示時のみツールチップを参照する必要がある
Escapeキーでツールチップを閉じる キーボードによる解除サポート
フォーカスでツールチップを表示 キーボードアクセシビリティ
ぼかしでツールチップを非表示 フォーカス管理

中優先度: アクセシビリティ検証

テスト WCAG要件
axe違反なし(非表示状態) WCAG 2.1 AA準拠
axe違反なし(表示状態) WCAG 2.1 AA準拠
ツールチップはフォーカス可能ではない APG: ツールチップはフォーカスを受け取ってはならない

低優先度: Props & 拡張性

テスト 機能
placement propで位置を変更 配置のカスタマイズ
disabled propで表示を防ぐ 無効化機能
delay propでタイミングを制御 遅延のカスタマイズ
id propでカスタムIDを設定 SSR/カスタムIDサポート
制御されたopen状態 外部状態制御
onOpenChangeコールバック 状態変更通知
className継承 スタイルのカスタマイズ

テストの実行

# すべてのToolチップテストを実行
npm run test -- tooltip

# 特定のフレームワークのテストを実行
npm run test -- Tooltip.test.tsx    # React
npm run test -- Tooltip.test.vue    # Vue
npm run test -- Tooltip.test.svelte # Svelte
Tooltip.test.svelte.ts
import { render, screen, waitFor } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import TooltipTestWrapper from './test-wrappers/TooltipTestWrapper.svelte';

describe('Tooltip (Svelte)', () => {
  describe('APG: ARIA 属性', () => {
    it('role="tooltip" を持つ', () => {
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip' } });
      expect(screen.getByRole('tooltip', { hidden: true })).toBeInTheDocument();
    });

    it('非表示時は aria-hidden が true', () => {
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip' } });
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveAttribute('aria-hidden', 'true');
    });

    it('表示時は aria-hidden が false', async () => {
      const user = userEvent.setup();
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip', delay: 0 } });
      const trigger = screen.getByRole('button');

      await user.hover(trigger);
      await waitFor(() => {
        const tooltip = screen.getByRole('tooltip');
        expect(tooltip).toHaveAttribute('aria-hidden', 'false');
      });
    });
  });

  describe('APG: キーボード操作', () => {
    it('Escape キーで閉じる', async () => {
      const user = userEvent.setup();
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip', delay: 0 } });
      const trigger = screen.getByRole('button');

      await user.hover(trigger);
      await waitFor(() => {
        expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
      });

      await user.keyboard('{Escape}');
      await waitFor(() => {
        expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute(
          'aria-hidden',
          'true'
        );
      });
    });

    it('フォーカスで表示される', async () => {
      const user = userEvent.setup();
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip', delay: 0 } });

      await user.tab();
      await waitFor(() => {
        expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
      });
    });
  });

  describe('ホバー操作', () => {
    it('ホバーで表示される', async () => {
      const user = userEvent.setup();
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip', delay: 0 } });

      await user.hover(screen.getByRole('button'));
      await waitFor(() => {
        expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
      });
    });

    it('ホバー解除で閉じる', async () => {
      const user = userEvent.setup();
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip', delay: 0 } });
      const trigger = screen.getByRole('button');

      await user.hover(trigger);
      await waitFor(() => {
        expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
      });

      await user.unhover(trigger);
      await waitFor(() => {
        expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute(
          'aria-hidden',
          'true'
        );
      });
    });
  });

  describe('アクセシビリティ', () => {
    it('axe による WCAG 2.1 AA 違反がない', async () => {
      const { container } = render(TooltipTestWrapper, { props: { content: 'This is a tooltip' } });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('tooltip がフォーカスを受け取らない', () => {
      render(TooltipTestWrapper, { props: { content: 'This is a tooltip' } });
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).not.toHaveAttribute('tabindex');
    });
  });

  describe('Props', () => {
    it('placement prop で位置を変更できる', () => {
      render(TooltipTestWrapper, { props: { content: 'Tooltip', placement: 'bottom' } });
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveClass('top-full');
    });

    it('disabled の場合、tooltip が表示されない', async () => {
      const user = userEvent.setup();
      render(TooltipTestWrapper, { props: { content: 'Tooltip', delay: 0, disabled: true } });

      await user.hover(screen.getByRole('button'));
      await new Promise((r) => setTimeout(r, 50));
      expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
    });

    it('id prop でカスタム ID を設定できる', () => {
      render(TooltipTestWrapper, { props: { content: 'Tooltip', id: 'custom-tooltip-id' } });
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveAttribute('id', 'custom-tooltip-id');
    });
  });
});

リソース