APG Patterns
English
English

Tooltip

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

デモ

デモのみを開く →

アクセシビリティ

WAI-ARIA ロール

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

aria-describedby

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

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

aria-hidden

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

適用先 ツールチップ要素
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.vue
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { cn } from '@/lib/utils';

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

export interface TooltipProps {
  /** Tooltip content */
  content: string;
  /** Controlled open state */
  open?: boolean;
  /** Default open state (uncontrolled) */
  defaultOpen?: boolean;
  /** 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 */
  class?: string;
  /** Additional class name for the tooltip content */
  tooltipClass?: string;
}

const props = withDefaults(defineProps<TooltipProps>(), {
  open: undefined,
  defaultOpen: false,
  delay: 300,
  placement: 'top',
  id: undefined,
  disabled: false,
  class: '',
  tooltipClass: '',
});

const emit = defineEmits<{
  'update:open': [value: boolean];
}>();

// Generate unique ID
let uid = '';
onMounted(() => {
  uid = props.id ?? `tooltip-${crypto.randomUUID().slice(0, 8)}`;
  tooltipId.value = uid;
});

const tooltipId = ref(props.id ?? '');

const internalOpen = ref(props.defaultOpen);
const isControlled = computed(() => props.open !== undefined);
const isOpen = computed(() => (isControlled.value ? props.open : internalOpen.value));

let timeout: ReturnType<typeof setTimeout> | null = null;

const setOpen = (value: boolean) => {
  if (!isControlled.value) {
    internalOpen.value = value;
  }
  emit('update:open', value);
};

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

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

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

watch(isOpen, (newValue) => {
  if (newValue) {
    document.addEventListener('keydown', handleKeyDown);
  } else {
    document.removeEventListener('keydown', handleKeyDown);
  }
});

onUnmounted(() => {
  if (timeout) {
    clearTimeout(timeout);
  }
  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>

<template>
  <span
    :class="cn('apg-tooltip-trigger', 'relative inline-block', props.class)"
    @mouseenter="showTooltip"
    @mouseleave="hideTooltip"
    @focusin="showTooltip"
    @focusout="hideTooltip"
    :aria-describedby="isOpen && !disabled ? tooltipId : undefined"
  >
    <slot />
    <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',
          props.tooltipClass
        )
      "
    >
      {{ content }}
    </span>
  </span>
</template>

使い方

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

<template>
  <Tooltip
    content="Save your changes"
    placement="top"
    :delay="300"
  >
    <button>Save</button>
  </Tooltip>
</template>

API

プロパティ デフォルト 説明
content string - ツールチップのコンテンツ(必須)
open boolean - 制御された開いた状態(v-model:open)
defaultOpen boolean false デフォルトの開いた状態(非制御)
delay number 300 表示前の遅延時間(ミリ秒)
placement 'top' | 'bottom' | 'left' | 'right' 'top' ツールチップの位置
id string 自動生成 カスタム ID
disabled boolean false ツールチップを無効にする

テスト

フレームワーク固有のテストライブラリを使用して、コンポーネントのレンダリング出力を検証します。正しい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要件
Escape Escapeキーでツールチップを閉じる
Focus retention Escape後もトリガーにフォーカスが残る

無効化状態 (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 tooltips React、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

テストツール

完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

Tooltip.test.vue.ts
import { render, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Tooltip from './Tooltip.vue';

describe('Tooltip (Vue)', () => {
  describe('APG: ARIA 属性', () => {
    it('role="tooltip" を持つ', () => {
      render(Tooltip, {
        props: { content: 'This is a tooltip' },
        slots: { default: '<button>Hover me</button>' },
      });
      expect(screen.getByRole('tooltip', { hidden: true })).toBeInTheDocument();
    });

    it('非表示時は aria-hidden が true', () => {
      render(Tooltip, {
        props: { content: 'This is a tooltip' },
        slots: { default: '<button>Hover me</button>' },
      });
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveAttribute('aria-hidden', 'true');
    });

    it('表示時は aria-hidden が false', async () => {
      const user = userEvent.setup();
      render(Tooltip, {
        props: { content: 'This is a tooltip', delay: 0 },
        slots: { default: '<button>Hover me</button>' },
      });
      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(Tooltip, {
        props: { content: 'This is a tooltip', delay: 0 },
        slots: { default: '<button>Hover me</button>' },
      });
      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(Tooltip, {
        props: { content: 'This is a tooltip', delay: 0 },
        slots: { default: '<button>Hover me</button>' },
      });

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

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

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

    it('ホバー解除で閉じる', async () => {
      const user = userEvent.setup();
      render(Tooltip, {
        props: { content: 'This is a tooltip', delay: 0 },
        slots: { default: '<button>Hover me</button>' },
      });
      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(Tooltip, {
        props: { content: 'This is a tooltip' },
        slots: { default: '<button>Hover me</button>' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

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

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

      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(Tooltip, {
        props: { content: 'Tooltip', id: 'custom-tooltip-id' },
        slots: { default: '<button>Hover me</button>' },
      });
      const tooltip = screen.getByRole('tooltip', { hidden: true });
      expect(tooltip).toHaveAttribute('id', 'custom-tooltip-id');
    });
  });
});

リソース