# Dialog Pattern - AI Implementation Guide

> APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/

## Overview

A dialog is a window overlaid on the primary content, requiring user interaction. Modal dialogs trap focus and prevent interaction with content outside the dialog.

## ARIA Requirements

### Roles

| Role | Element | Description |
| --- | --- | --- |
| `dialog` | Dialog container | Indicates the element is a dialog window (required) |

### Properties

| Attribute | Element | Values | Required | Notes |
| --- | --- | --- | --- | --- |
| `aria-modal` | dialog | true | Yes | Indicates this is a modal dialog |
| `aria-labelledby` | dialog | ID reference to title element | Yes | References the dialog title |
| `aria-describedby` | dialog | ID reference to description | No | References optional description text |

## Keyboard Support

| Key | Action |
| --- | --- |
| `Tab` | Move focus to next focusable element within dialog. When focus is on the last element, moves to first. |
| `Shift + Tab` | Move focus to previous focusable element within dialog. When focus is on the first element, moves to last. |
| `Escape` | Close the dialog and return focus to trigger element |

## Focus Management

- Dialog opens: Focus moves to first focusable element inside the dialog
- Dialog closes: Focus returns to the element that triggered the dialog
- Focus trap: Tab/Shift+Tab cycles through focusable elements within the dialog only
- Background: Content outside dialog is made inert (not focusable or interactive)

## Test Checklist

### High Priority: Keyboard

- [ ] Escape closes the dialog
- [ ] Tab moves to next focusable element
- [ ] Shift+Tab moves to previous focusable element
- [ ] Tab wraps from last to first element
- [ ] Shift+Tab wraps from first to last element

### High Priority: ARIA

- [ ] Container has role="dialog"
- [ ] Dialog has aria-modal="true"
- [ ] Dialog has aria-labelledby referencing title
- [ ] Title element id matches aria-labelledby value

### High Priority: Focus Management

- [ ] Focus moves into dialog on open
- [ ] Focus returns to trigger on close
- [ ] Focus is trapped within dialog
- [ ] Background content is inert

### Medium Priority: Accessibility

- [ ] No axe-core violations (WCAG 2.1 AA)
- [ ] Page scrolling is disabled while open
- [ ] Close button has accessible label

## Implementation Notes

## Structure

```
+-------------------------------------+
| Dialog Title          [X]           |  <- aria-labelledby target
+-------------------------------------+
|                                     |
| Dialog content...                   |  <- aria-describedby target (optional)
|                                     |
| [Cancel]  [Confirm]                 |  <- focusable elements
+-------------------------------------+
```

## Focus Trap

- First focusable -> ... -> Last focusable -> First focusable (loop)
- Store trigger element reference before opening
- Restore focus to trigger on close

## Native Dialog Element

When using native `<dialog>` element with `showModal()`:
- Modal behavior is implicit
- `aria-modal` may be omitted
- Browser handles focus trapping automatically

## Example Test Code (React + Testing Library)

```typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

// Focus trap test
it('traps focus within dialog', async () => {
  const user = userEvent.setup();
  render(<Dialog open />);

  const closeButton = screen.getByRole('button', { name: /close/i });
  const confirmButton = screen.getByRole('button', { name: /confirm/i });

  closeButton.focus();
  await user.tab();
  // Focus should cycle within dialog
});

// Escape closes dialog
it('closes on Escape', async () => {
  const onClose = vi.fn();
  const user = userEvent.setup();
  render(<Dialog open onClose={onClose} />);

  await user.keyboard('{Escape}');
  expect(onClose).toHaveBeenCalled();
});

// Focus restoration
it('returns focus to trigger on close', async () => {
  const user = userEvent.setup();
  render(<DialogWithTrigger />);

  const trigger = screen.getByRole('button', { name: /open/i });
  await user.click(trigger);
  await user.keyboard('{Escape}');

  expect(trigger).toHaveFocus();
});
```

## Example E2E Test Code (Playwright)

```typescript
import { test, expect } from '@playwright/test';

const getDialog = (page: import('@playwright/test').Page) => {
  return page.getByRole('dialog');
};

const openDialog = async (page: import('@playwright/test').Page) => {
  const trigger = page.getByRole('button', { name: /open dialog/i }).first();
  await trigger.click();
  await getDialog(page).waitFor({ state: 'visible' });
  return trigger;
};

// ARIA structure test
test('has role="dialog"', async ({ page }) => {
  await openDialog(page);
  const dialog = getDialog(page);
  await expect(dialog).toBeVisible();
  await expect(dialog).toHaveRole('dialog');
});

// Focus trap test
test('traps focus within dialog', async ({ page }) => {
  await openDialog(page);
  const dialog = getDialog(page);
  const focusableElements = dialog.locator(
    'button:not([disabled]), [tabindex="0"], input:not([disabled])'
  );
  const count = await focusableElements.count();
  const tabCount = Math.max(count * 3, 10);

  for (let i = 0; i < tabCount; i++) {
    await page.keyboard.press('Tab');
  }

  // Focus should still be within dialog
  const isWithinDialog = await page.evaluate(() => {
    const focused = document.activeElement;
    return focused?.closest('dialog, [role="dialog"]') !== null;
  });
  expect(isWithinDialog).toBe(true);
});

// Focus restoration test
test('returns focus to trigger on close', async ({ page }) => {
  const trigger = await openDialog(page);
  await page.keyboard.press('Escape');
  await expect(trigger).toBeFocused();
});
```
