Tooltip
要素がキーボードフォーカスを受けたとき、またはマウスがホバーしたときに、要素に関連する情報を表示するポップアップ。
デモ
アクセシビリティ
WAI-ARIA ロール
-
tooltip- 要素の説明を表示するコンテキストポップアップ
WAI-ARIA tooltip role (opens in new tab)
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.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>)を使用しています。
テスト
フレームワーク固有のテストライブラリを使用して、コンポーネントのレンダリング出力を検証します。正しい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 テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: Tooltip パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist