APG Patterns
English
English

Tooltip

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

デモ

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
tooltip ツールチップポップアップ 要素の説明を表示するコンテキストポップアップ

WAI-ARIA プロパティ

aria-describedby

ツールチップが表示されている時のみ。トリガー要素にアクセシブルな説明を提供するために、ツールチップ要素を参照します。

ID of tooltip
必須
条件付き

aria-hidden

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

true | false
必須
いいえ

キーボードサポート

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

フォーカス管理

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

マウス/ポインター動作

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

ビジュアルデザイン

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

参考資料

ソースコード

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

プロパティデフォルト説明
contentstring-ツールチップの内容(必須)
defaultOpenbooleanfalseデフォルトの開閉状態
delaynumber300表示までの遅延時間(ミリ秒)
placement'top' | 'bottom' | 'left' | 'right''top'ツールチップの位置
idstringauto-generatedカスタム ID
disabledbooleanfalseツールチップを無効化
この実装は、クライアント側のインタラクティブ性のために Web Component(<apg-tooltip>)を使用しています。

テスト

フレームワーク固有のテストライブラリを使用して、コンポーネントのレンダリング出力を検証します。正しいHTML構造とARIA属性を確認します。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用して、コンポーネントのレンダリング出力を検証します。正しいHTML構造とARIA属性を確認します。

  • ARIA属性(role、aria-describedby、aria-hidden)
  • キーボード操作(Escapeキーでの解除)
  • フォーカス/ぼかし時の表示/非表示動作
  • jest-axeによるアクセシビリティ

E2Eテスト(Playwright)

全フレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。

  • 遅延タイミングを伴うホバー操作
  • フォーカス/ぼかし操作
  • Escapeキーでの解除
  • ライブブラウザでのARIA構造検証
  • axe-coreアクセシビリティスキャン
  • クロスフレームワーク一貫性チェック

テストカテゴリ

APG ARIA構造 (Unit + E2E)

テストAPG要件
role="tooltip"ツールチップコンテナはtooltipロールを持つ必要がある
aria-hidden非表示のツールチップはaria-hidden="true"を持つ必要がある
aria-describedbyトリガーは表示時にツールチップを参照する

表示/非表示動作 (Unit + E2E)

テストAPG要件
Hover showsマウスホバー後、遅延してツールチップを表示
Focus showsキーボードフォーカスでツールチップを表示
Blur hidesフォーカスを失うとツールチップを非表示
Mouseleave hidesマウスがトリガーから離れるとツールチップを非表示

キーボード操作 (Unit + E2E)

テストAPG要件
EscapeEscapeキーでツールチップを閉じる
Focus retentionEscape後もトリガーにフォーカスが残る

無効化状態 (Unit + E2E)

テストWCAG要件
Disabled no show無効化されたツールチップはホバーしても表示されない

アクセシビリティ (Unit + E2E)

テストWCAG要件
axe violations (hidden)ツールチップ非表示時にWCAG 2.1 AA違反がないこと
axe violations (visible)ツールチップ表示時にWCAG 2.1 AA違反がないこと

クロスフレームワーク一貫性 (E2E)

テスト説明
All frameworks have tooltipsReact、Vue、Svelte、Astro全てがtooltip要素をレンダリング
Show on hover全フレームワークでホバー時にツールチップを表示
Consistent ARIA全フレームワークで一貫したARIA構造

テストコード例

以下は実際のE2Eテストファイル(e2e/tooltip.spec.ts)です。

e2e/tooltip.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Tooltip Pattern
 *
 * A tooltip is a popup that displays information related to an element
 * when the element receives keyboard focus or the mouse hovers over it.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// Helper to get tooltip triggers (wrapper elements that contain tooltips)
const getTooltipTriggers = (page: import('@playwright/test').Page) => {
  return page.locator('.apg-tooltip-trigger');
};

// Helper to get tooltip content
const getTooltip = (page: import('@playwright/test').Page) => {
  return page.locator('[role="tooltip"]');
};

// Helper to get the element that should have aria-describedby
// In React/Vue/Astro: the wrapper span has aria-describedby
// In Svelte: the button inside has aria-describedby (passed via slot props)
const getDescribedByElement = (
  _page: import('@playwright/test').Page,
  framework: string,
  trigger: import('@playwright/test').Locator
) => {
  if (framework === 'svelte') {
    return trigger.locator('button').first();
  }
  return trigger;
};

for (const framework of frameworks) {
  test.describe(`Tooltip (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/tooltip/${framework}/demo/`);
      // Wait for tooltip triggers to be available
      await getTooltipTriggers(page).first().waitFor();
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('tooltip has role="tooltip"', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const tooltip = getTooltip(page).first();

        // Hover to show tooltip
        await trigger.hover();
        // Wait for tooltip to appear (default delay is 300ms)
        await expect(tooltip).toBeVisible({ timeout: 1000 });
        await expect(tooltip).toHaveRole('tooltip');
      });

      test('tooltip has aria-hidden when not visible', async ({ page }) => {
        const tooltip = getTooltip(page).first();
        await expect(tooltip).toHaveAttribute('aria-hidden', 'true');
      });

      test('trigger has aria-describedby when tooltip is shown', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const describedByElement = getDescribedByElement(page, framework, trigger);
        const tooltip = getTooltip(page).first();

        // Hover to show tooltip
        await trigger.hover();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        // After hover - has aria-describedby linking to tooltip
        const tooltipId = await tooltip.getAttribute('id');
        await expect(describedByElement).toHaveAttribute('aria-describedby', tooltipId!);
      });

      test('trigger removes aria-describedby when tooltip is hidden', async ({ page }) => {
        // Svelte always has aria-describedby set (even when hidden) - skip this test for Svelte
        if (framework === 'svelte') {
          test.skip();
          return;
        }

        const trigger = getTooltipTriggers(page).first();
        const describedByElement = getDescribedByElement(page, framework, trigger);
        const tooltip = getTooltip(page).first();

        // Show tooltip
        await trigger.hover();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        // Hide tooltip by moving mouse away
        await page.locator('body').hover({ position: { x: 10, y: 10 } });
        await expect(tooltip).not.toBeVisible();

        // aria-describedby should be removed
        const describedby = await describedByElement.getAttribute('aria-describedby');
        expect(describedby).toBeNull();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Show/Hide Behavior
    // ------------------------------------------
    test.describe('APG: Show/Hide Behavior', () => {
      test('shows tooltip on hover after delay', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const tooltip = getTooltip(page).first();

        await expect(tooltip).not.toBeVisible();
        await trigger.hover();
        // Tooltip should appear after delay (300ms default)
        await expect(tooltip).toBeVisible({ timeout: 1000 });
      });

      test('shows tooltip on focus', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const focusable = trigger.locator('button, a, [tabindex="0"]').first();
        const tooltip = getTooltip(page).first();

        await expect(tooltip).not.toBeVisible();
        // Click first to ensure page is focused, then Tab to element
        await page.locator('body').click({ position: { x: 10, y: 10 } });
        // Focus the element directly - use click to ensure focus event fires
        await focusable.click();
        await expect(focusable).toBeFocused();
        // Tooltip should appear after delay
        await expect(tooltip).toBeVisible({ timeout: 1000 });
      });

      test('hides tooltip on blur', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const focusable = trigger.locator('button, a, [tabindex="0"]').first();
        const tooltip = getTooltip(page).first();

        // Show tooltip via click (which also focuses)
        await focusable.click();
        await expect(focusable).toBeFocused();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        // Blur by clicking outside
        await page.locator('body').click({ position: { x: 10, y: 10 } });
        await expect(tooltip).not.toBeVisible();
      });

      test('hides tooltip on mouseleave', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const tooltip = getTooltip(page).first();

        // Show tooltip via hover
        await trigger.hover();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        // Move mouse away
        await page.locator('body').hover({ position: { x: 10, y: 10 } });
        await expect(tooltip).not.toBeVisible();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('hides tooltip on Escape key', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const tooltip = getTooltip(page).first();

        // Show tooltip via hover (more reliable than focus for this test)
        await trigger.hover();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        // Press Escape
        await page.keyboard.press('Escape');
        await expect(tooltip).not.toBeVisible();
      });

      test('focus remains on trigger after Escape', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const focusable = trigger.locator('button, a, [tabindex="0"]').first();
        const tooltip = getTooltip(page).first();

        // Show tooltip via click (which also focuses)
        await focusable.click();
        await expect(focusable).toBeFocused();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        // Press Escape
        await page.keyboard.press('Escape');
        await expect(tooltip).not.toBeVisible();

        // Focus should remain on the focusable element
        await expect(focusable).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled tooltip does not show on hover', async ({ page }) => {
        // Find the disabled tooltip trigger (4th one in demo)
        const disabledTrigger = getTooltipTriggers(page).nth(3);
        const tooltips = getTooltip(page);

        // Get initial visible tooltip count
        const initialVisibleCount = await tooltips
          .filter({ has: page.locator(':visible') })
          .count();

        await disabledTrigger.hover();
        // Wait a bit for potential tooltip to appear
        await page.waitForTimeout(500);

        // No new tooltip should be visible
        const finalVisibleCount = await tooltips.filter({ has: page.locator(':visible') }).count();
        expect(finalVisibleCount).toBe(initialVisibleCount);
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations (tooltip hidden)', async ({ page }) => {
        const trigger = getTooltipTriggers(page);
        await trigger.first().waitFor();

        const results = await new AxeBuilder({ page })
          .include('.apg-tooltip-trigger')
          // Exclude color-contrast - design choice for tooltip styling
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });

      test('has no axe-core violations (tooltip visible)', async ({ page }) => {
        const trigger = getTooltipTriggers(page).first();
        const tooltip = getTooltip(page).first();

        // Show tooltip
        await trigger.hover();
        await expect(tooltip).toBeVisible({ timeout: 1000 });

        const results = await new AxeBuilder({ page })
          .include('.apg-tooltip-trigger')
          // Exclude color-contrast - design choice for tooltip styling
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Tooltip - Cross-framework Consistency', () => {
  test('all frameworks have tooltips', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/tooltip/${framework}/demo/`);
      await getTooltipTriggers(page).first().waitFor();

      const triggers = getTooltipTriggers(page);
      const count = await triggers.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks show tooltip on hover', async ({ page }) => {
    // Run sequentially to avoid parallel test interference
    test.setTimeout(60000);

    for (const framework of frameworks) {
      // Navigate fresh for each framework to avoid state leaking
      await page.goto(`patterns/tooltip/${framework}/demo/`);
      const trigger = getTooltipTriggers(page).first();
      await trigger.waitFor();

      const tooltip = getTooltip(page).first();

      // Ensure tooltip is initially hidden
      await expect(tooltip).toHaveAttribute('aria-hidden', 'true');

      // Get bounding box for precise hover
      const box = await trigger.boundingBox();
      if (!box) throw new Error(`Trigger not found for ${framework}`);

      // Move mouse away, then to center of trigger
      await page.mouse.move(0, 0);
      await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);

      // Wait for tooltip to appear (300ms delay + buffer)
      await expect(tooltip).toBeVisible({ timeout: 2000 });

      // Move away to hide for next iteration
      await page.mouse.move(0, 0);
      await expect(tooltip).not.toBeVisible({ timeout: 1000 });
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/tooltip/${framework}/demo/`);
      await getTooltipTriggers(page).first().waitFor();

      const trigger = getTooltipTriggers(page).first();
      const describedByElement = getDescribedByElement(page, framework, trigger);
      const tooltip = getTooltip(page).first();

      // Show tooltip
      await trigger.hover();
      await expect(tooltip).toBeVisible({ timeout: 1000 });

      // Check role
      await expect(tooltip).toHaveRole('tooltip');

      // Check aria-describedby linkage
      // Note: In React/Vue/Astro, aria-describedby is on the wrapper span
      // In Svelte, it's on the button inside (passed via slot props)
      const tooltipId = await tooltip.getAttribute('id');
      await expect(describedByElement).toHaveAttribute('aria-describedby', tooltipId!);

      // Move away to hide for next iteration
      await page.locator('body').hover({ position: { x: 10, y: 10 } });
    }
  });
});

テストの実行

# Tooltipのユニットテストを実行
npm run test -- tooltip

# TooltipのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=tooltip

# 特定フレームワークのE2Eテストを実行
npm run test:e2e:react:pattern --pattern=tooltip

npm run test:e2e:vue:pattern --pattern=tooltip

npm run test:e2e:svelte:pattern --pattern=tooltip

npm run test:e2e:astro:pattern --pattern=tooltip

テストツール

詳細はテスト戦略ガイドを参照してください。

リソース