APG Patterns
English GitHub
English GitHub

Tooltip

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

🤖 AI 実装ガイド

デモ

アクセシビリティ

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.tsx
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';

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

export interface TooltipProps {
  /** Tooltip content */
  content: ReactNode;
  /** Trigger element */
  children: ReactNode;
  /** Controlled open state */
  open?: boolean;
  /** Default open state (uncontrolled) */
  defaultOpen?: boolean;
  /** Callback when open state changes */
  onOpenChange?: (open: boolean) => void;
  /** Delay before showing tooltip (ms) */
  delay?: number;
  /** Tooltip placement */
  placement?: TooltipPlacement;
  /** Custom tooltip ID for SSR */
  id?: string;
  /** Whether the tooltip is disabled */
  disabled?: boolean;
  /** Additional class name for the wrapper */
  className?: string;
  /** Additional class name for the tooltip content */
  tooltipClassName?: string;
}

export const Tooltip: React.FC<TooltipProps> = ({
  content,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  delay = 300,
  placement = 'top',
  id: providedId,
  disabled = false,
  className,
  tooltipClassName,
}) => {
  const generatedId = useId();
  const tooltipId = providedId ?? `tooltip-${generatedId}`;

  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const isOpen = isControlled ? controlledOpen : internalOpen;

  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const triggerRef = useRef<HTMLSpanElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);

  const setOpen = useCallback(
    (value: boolean) => {
      if (!isControlled) {
        setInternalOpen(value);
      }
      onOpenChange?.(value);
    },
    [isControlled, onOpenChange]
  );

  const showTooltip = useCallback(() => {
    if (disabled) return;
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    timeoutRef.current = setTimeout(() => {
      setOpen(true);
    }, delay);
  }, [delay, disabled, setOpen]);

  const hideTooltip = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
    setOpen(false);
  }, [setOpen]);

  // Handle Escape key
  useEffect(() => {
    if (!isOpen) return;

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

    document.addEventListener('keydown', handleKeyDown);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [isOpen, hideTooltip]);

  // Cleanup timeout on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  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',
  };

  return (
    <span
      ref={triggerRef}
      className={cn('apg-tooltip-trigger', 'relative inline-block', className)}
      onMouseEnter={showTooltip}
      onMouseLeave={hideTooltip}
      onFocus={showTooltip}
      onBlur={hideTooltip}
      aria-describedby={isOpen && !disabled ? tooltipId : undefined}
    >
      {children}
      <span
        ref={tooltipRef}
        id={tooltipId}
        role="tooltip"
        aria-hidden={!isOpen}
        className={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',
          tooltipClassName
        )}
      >
        {content}
      </span>
    </span>
  );
};

export default Tooltip;

使い方

Example
import { Tooltip } from './Tooltip';

function App() {
  return (
    <Tooltip
      content="Save your changes"
      placement="top"
      delay={300}
    >
      <button>Save</button>
    </Tooltip>
  );
}

API

プロパティ デフォルト 説明
content ReactNode - ツールチップの内容(必須)
children ReactNode - トリガー要素(必須)
open boolean - 制御された開閉状態
defaultOpen boolean false デフォルトの開閉状態(非制御)
onOpenChange (open: boolean) => void - 開閉状態が変更されたときのコールバック
delay number 300 表示までの遅延時間(ミリ秒)
placement 'top' | 'bottom' | 'left' | 'right' 'top' トリガーに対するツールチップの位置
id string auto-generated SSR用のカスタムID
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.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Tooltip } from './Tooltip';

describe('Tooltip', () => {
  // 🔴 High Priority: APG Core Compliance
  describe('APG: ARIA Attributes', () => {
    it('has role="tooltip"', () => {
      render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      expect(screen.getByRole('tooltip', { hidden: true })).toBeInTheDocument();
    });

    it('has aria-hidden="true" when hidden', () => {
      render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveAttribute('aria-hidden', 'true');
    });

    it('has aria-hidden="false" when visible', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole('button');

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

    it('sets aria-describedby only when visible', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole('button');
      const wrapper = trigger.parentElement;

      // No aria-describedby when hidden
      expect(wrapper).not.toHaveAttribute('aria-describedby');

      await user.hover(trigger);
      await waitFor(() => {
        expect(wrapper).toHaveAttribute('aria-describedby');
      });

      const tooltipId = wrapper?.getAttribute('aria-describedby');
      const tooltip = screen.getByRole('tooltip');
      expect(tooltip).toHaveAttribute('id', tooltipId);
    });
  });

  describe('APG: Keyboard Interaction', () => {
    it('closes with Escape key', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      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('shows on focus', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );

      await user.tab();
      expect(screen.getByRole('button')).toHaveFocus();

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

    it('closes on focus out', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Tooltip content="This is a tooltip" delay={0}>
            <button>First</button>
          </Tooltip>
          <button>Second</button>
        </>
      );

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

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

  describe('Hover Interaction', () => {
    afterEach(() => {
      vi.useRealTimers();
    });

    it('shows on hover', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole('button');

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

    it('closes on hover out', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );
      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'
        );
      });
    });

    it('shows after delay', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="This is a tooltip" delay={100}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole('button');

      await user.hover(trigger);

      // Hidden immediately before delay
      expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');

      // Visible after delay
      await waitFor(
        () => {
          expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
        },
        { timeout: 200 }
      );
    });
  });

  // 🟡 Medium Priority: Accessibility Validation
  describe('Accessibility', () => {
    it('has no WCAG 2.1 AA violations (hidden state)', async () => {
      const { container } = render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no WCAG 2.1 AA violations (visible state)', async () => {
      const user = userEvent.setup();
      const { container } = render(
        <Tooltip content="This is a tooltip" delay={0}>
          <button>Hover me</button>
        </Tooltip>
      );

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

      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('tooltip does not receive focus', () => {
      render(
        <Tooltip content="This is a tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).not.toHaveAttribute('tabindex');
    });
  });

  describe('Props', () => {
    it('can change position with placement prop', () => {
      render(
        <Tooltip content="Tooltip" placement="bottom">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveClass('top-full');
    });

    it('does not show tooltip when disabled', async () => {
      const user = userEvent.setup();
      render(
        <Tooltip content="Tooltip" delay={0} disabled>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole('button');

      await user.hover(trigger);
      // Does not show because disabled (delay=0 so immediate)
      expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
    });

    it('can set custom ID with id prop', () => {
      render(
        <Tooltip content="Tooltip" id="custom-tooltip-id">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveAttribute('id', 'custom-tooltip-id');
    });

    it('calls onOpenChange when state changes', async () => {
      const handleOpenChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Tooltip content="Tooltip" delay={0} onOpenChange={handleOpenChange}>
          <button>Hover me</button>
        </Tooltip>
      );
      const trigger = screen.getByRole('button');

      await user.hover(trigger);
      await waitFor(() => {
        expect(handleOpenChange).toHaveBeenCalledWith(true);
      });

      await user.unhover(trigger);
      await waitFor(() => {
        expect(handleOpenChange).toHaveBeenCalledWith(false);
      });
    });

    it('can be controlled with open prop', () => {
      const { rerender } = render(
        <Tooltip content="Tooltip" open={false}>
          <button>Hover me</button>
        </Tooltip>
      );

      expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');

      rerender(
        <Tooltip content="Tooltip" open={true}>
          <button>Hover me</button>
        </Tooltip>
      );

      expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
    });
  });

  // 🟢 Low Priority: Extensibility
  describe('HTML Attribute Inheritance', () => {
    it('merges className correctly', () => {
      render(
        <Tooltip content="Tooltip" className="custom-class">
          <button>Hover me</button>
        </Tooltip>
      );
      const wrapper = screen.getByRole('button').parentElement;
      expect(wrapper).toHaveClass('custom-class');
      expect(wrapper).toHaveClass('apg-tooltip-trigger');
    });

    it('applies tooltipClassName', () => {
      render(
        <Tooltip content="Tooltip" tooltipClassName="custom-tooltip">
          <button>Hover me</button>
        </Tooltip>
      );
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveClass('custom-tooltip');
    });
  });
});

リソース