Tooltip
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
Demo
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tooltip | Tooltip popup | A contextual popup that displays a description for an element |
WAI-ARIA Properties
aria-describedby
Only when tooltip is visible. References the tooltip element to provide an accessible description for the trigger element.
- Values
- ID of tooltip
- Required
- Conditional
aria-hidden
Indicates whether the tooltip is hidden from assistive technology. Default is true.
- Values
true|false- Required
- No
Keyboard Support
| Key | Action |
|---|---|
| Escape | Closes the tooltip |
| Tab | Standard focus navigation; tooltip shows when trigger receives focus |
Focus Management
| Event | Behavior |
|---|---|
| Tooltip display | Tooltip never receives focus - Per APG, tooltips must not be focusable. If interactive content is needed, use a Dialog or Popover pattern instead. |
| Trigger focus | Focus triggers display - When the trigger element receives focus, the tooltip appears after the configured delay. |
| Trigger blur | Blur hides tooltip - When focus leaves the trigger element, the tooltip is hidden. |
Mouse/Pointer Behavior
- Hover triggers display - Moving the pointer over the trigger shows the tooltip after the delay.
- Pointer leave hides - Moving the pointer away from the trigger hides the tooltip.
Visual Design
- High contrast - Dark background with light text ensures readability
- Dark mode support - Colors invert appropriately in dark mode
- Positioned near trigger - Tooltip appears adjacent to the triggering element
- Configurable delay - Prevents accidental activation during cursor movement
References
Source Code
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; Usage
import { Tooltip } from './Tooltip';
function App() {
return (
<Tooltip
content="Save your changes"
placement="top"
delay={300}
>
<button>Save</button>
</Tooltip>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactNode | - | Tooltip content (required) |
children | ReactNode | - | Trigger element (required) |
open | boolean | - | Controlled open state |
defaultOpen | boolean | false | Default open state (uncontrolled) |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
delay | number | 300 | Delay before showing (ms) |
placement | 'top' | 'bottom' | 'left' | 'right' | 'top' | Tooltip position relative to trigger |
id | string | auto-generated | Custom ID for SSR |
disabled | boolean | false | Disable the tooltip |
Testing
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (role, aria-describedby, aria-hidden)
- Keyboard interaction (Escape key dismissal)
- Show/hide behavior on focus and blur
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Hover interactions with delay timing
- Focus/blur interactions
- Escape key dismissal
- ARIA structure validation in live browser
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
APG ARIA Structure (Unit + E2E)
| Test | APG Requirement |
|---|---|
role="tooltip" | Tooltip container must have tooltip role |
aria-hidden | Hidden tooltips must have aria-hidden="true" |
aria-describedby | Trigger references tooltip when visible |
Show/Hide Behavior (Unit + E2E)
| Test | APG Requirement |
|---|---|
Hover shows | Shows tooltip on mouse hover after delay |
Focus shows | Shows tooltip on keyboard focus |
Blur hides | Hides tooltip on focus loss |
Mouseleave hides | Hides tooltip when mouse leaves trigger |
Keyboard Interaction (Unit + E2E)
| Test | APG Requirement |
|---|---|
Escape | Closes tooltip on Escape key |
Focus retention | Focus remains on trigger after Escape |
Disabled State (Unit + E2E)
| Test | WCAG Requirement |
|---|---|
Disabled no show | Disabled tooltip does not show on hover |
Accessibility (Unit + E2E)
| Test | WCAG Requirement |
|---|---|
axe violations (hidden) | No WCAG 2.1 AA violations when tooltip hidden |
axe violations (visible) | No WCAG 2.1 AA violations when tooltip visible |
Cross-framework Consistency (E2E)
| Test | Description |
|---|---|
All frameworks have tooltips | React, Vue, Svelte, Astro all render tooltip elements |
Show on hover | All frameworks show tooltip on hover |
Consistent ARIA | All frameworks have consistent ARIA structure |
Example Test Code
The following is the actual E2E test file (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 } });
}
});
}); Running Tests
# Run unit tests for Tooltip
npm run test -- tooltip
# Run E2E tests for Tooltip (all frameworks)
npm run test:e2e:pattern --pattern=tooltip
# Run E2E tests for specific framework
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 Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
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');
});
});
}); Resources
- WAI-ARIA APG: Tooltip Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist