APG Patterns
日本語
日本語

Tooltip

A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.

Demo

Open demo only →

Accessibility Features

WAI-ARIA Roles

WAI-ARIA States & Properties

aria-describedby

Only when tooltip is visible. References the tooltip element to provide an accessible description for the trigger element.

Applied to Trigger element (wrapper)
When Only when tooltip is visible
Reference aria-describedby (opens in new tab)

aria-hidden

Indicates whether the tooltip is hidden from assistive technology. Default is true.

Applied to Tooltip element
Values true (hidden) | false (visible)
Default true
Reference aria-hidden (opens in new tab)

Keyboard Support

Key Action
Escape Closes the tooltip
Tab Standard focus navigation; tooltip shows when trigger receives focus

Focus Management

  • Tooltip never receives focus - Per APG, tooltips must not be focusable. If interactive content is needed, use a Dialog or Popover pattern instead.
  • Focus triggers display - When the trigger element receives focus, the tooltip appears after the configured delay.
  • 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.

Important Notes

Note: The APG Tooltip pattern is currently marked as "work in progress" by the WAI. This implementation follows the documented guidelines, but the specification may evolve. View APG Tooltip Pattern (opens in new tab)

Visual Design

This implementation follows best practices for tooltip visibility:

  • **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

Source Code

Tooltip.tsx
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

Example
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).

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

See testing-strategy.md (opens in new tab) for full documentation.

Tooltip.test.tsx
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