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.astro
---
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';

export interface Props {
  /** Tooltip content */
  content: string;
  /** Default open state */
  defaultOpen?: boolean;
  /** Delay before showing tooltip (ms) */
  delay?: number;
  /** Tooltip placement */
  placement?: TooltipPlacement;
  /** Custom tooltip ID */
  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;
}

const {
  content,
  defaultOpen = false,
  delay = 300,
  placement = 'top',
  id,
  disabled = false,
  class: className = '',
  tooltipClass = '',
} = Astro.props;

const tooltipId = id ?? `tooltip-${crypto.randomUUID().slice(0, 8)}`;

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

<apg-tooltip
  class:list={['apg-tooltip-trigger', 'relative inline-block', className]}
  data-delay={delay}
  data-disabled={disabled ? 'true' : undefined}
  data-tooltip-id={tooltipId}
  data-default-open={defaultOpen ? 'true' : undefined}
>
  <slot />
  <span
    id={tooltipId}
    role="tooltip"
    aria-hidden="true"
    class:list={[
      '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],
      'invisible opacity-0',
      tooltipClass,
    ]}
  >
    {content}
  </span>
</apg-tooltip>

<script>
  class ApgTooltip extends HTMLElement {
    private timeout: ReturnType<typeof setTimeout> | null = null;
    private isOpen = false;
    private tooltipEl: HTMLElement | null = null;
    private delay: number;
    private disabled: boolean;
    private tooltipId: string;

    constructor() {
      super();
      this.delay = 300;
      this.disabled = false;
      this.tooltipId = '';
    }

    connectedCallback() {
      this.delay = parseInt(this.dataset.delay ?? '300', 10);
      this.disabled = this.dataset.disabled === 'true';
      this.tooltipId = this.dataset.tooltipId ?? '';
      this.tooltipEl = this.querySelector(`#${this.tooltipId}`);

      if (this.dataset.defaultOpen === 'true') {
        this.showTooltip();
      }

      this.addEventListener('mouseenter', this.handleMouseEnter);
      this.addEventListener('mouseleave', this.handleMouseLeave);
      this.addEventListener('focusin', this.handleFocusIn);
      this.addEventListener('focusout', this.handleFocusOut);
      document.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      if (this.timeout) {
        clearTimeout(this.timeout);
      }
      this.removeEventListener('mouseenter', this.handleMouseEnter);
      this.removeEventListener('mouseleave', this.handleMouseLeave);
      this.removeEventListener('focusin', this.handleFocusIn);
      this.removeEventListener('focusout', this.handleFocusOut);
      document.removeEventListener('keydown', this.handleKeyDown);
    }

    private handleMouseEnter = () => {
      this.scheduleShow();
    };

    private handleMouseLeave = () => {
      this.hideTooltip();
    };

    private handleFocusIn = () => {
      this.scheduleShow();
    };

    private handleFocusOut = () => {
      this.hideTooltip();
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && this.isOpen) {
        this.hideTooltip();
      }
    };

    private scheduleShow() {
      if (this.disabled) return;
      if (this.timeout) {
        clearTimeout(this.timeout);
      }
      this.timeout = setTimeout(() => {
        this.showTooltip();
      }, this.delay);
    }

    private showTooltip() {
      if (this.disabled || !this.tooltipEl) return;
      this.isOpen = true;
      this.tooltipEl.setAttribute('aria-hidden', 'false');
      this.tooltipEl.classList.remove('opacity-0', 'invisible');
      this.tooltipEl.classList.add('opacity-100', 'visible');
      this.setAttribute('aria-describedby', this.tooltipId);
    }

    private hideTooltip() {
      if (this.timeout) {
        clearTimeout(this.timeout);
        this.timeout = null;
      }
      if (!this.tooltipEl) return;
      this.isOpen = false;
      this.tooltipEl.setAttribute('aria-hidden', 'true');
      this.tooltipEl.classList.remove('opacity-100', 'visible');
      this.tooltipEl.classList.add('opacity-0', 'invisible');
      this.removeAttribute('aria-describedby');
    }
  }

  customElements.define('apg-tooltip', ApgTooltip);
</script>

使い方

Example
---
import Tooltip from './Tooltip.astro';
---

<Tooltip
  content="Save your changes"
  placement="top"
  delay={300}
>
  <button>Save</button>
</Tooltip>

API

プロパティ デフォルト 説明
content string - ツールチップの内容(必須)
defaultOpen boolean false デフォルトの開閉状態
delay number 300 表示までの遅延時間(ミリ秒)
placement 'top' | 'bottom' | 'left' | 'right' 'top' ツールチップの位置
id string auto-generated カスタム ID
disabled boolean false ツールチップを無効化

この実装は、クライアント側のインタラクティブ性のために Web Component(<apg-tooltip>)を使用しています。

テスト

テスト概要

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

リソース