Files
gh-dieshen-claude-marketpla…/commands/a11y-patterns.md
2025-11-29 18:20:59 +08:00

11 KiB

Accessibility & Inclusive Design Patterns

Comprehensive accessibility patterns following WCAG 2.2 guidelines and ARIA best practices.

WCAG 2.2 Principles (POUR)

Perceivable

Information and UI components must be presentable to users in ways they can perceive.

Operable

UI components and navigation must be operable.

Understandable

Information and operation of UI must be understandable.

Robust

Content must be robust enough to be interpreted by various user agents, including assistive technologies.

Semantic HTML

<!-- Good: Semantic structure -->
<header>
  <nav aria-label="Main navigation">
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h1>Page Title</h1>
    <p>Content...</p>
  </article>

  <aside aria-label="Related content">
    <h2>Related Articles</h2>
  </aside>
</main>

<footer>
  <p>&copy; 2024 Company Name</p>
</footer>

<!-- Bad: Non-semantic -->
<div class="header">
  <div class="nav">
    <div class="link">Home</div>
  </div>
</div>

ARIA Attributes

Landmarks

<nav aria-label="Primary navigation">
<main role="main">
<aside aria-label="Sidebar">
<footer role="contentinfo">

Live Regions

<!-- Announce changes immediately -->
<div role="alert" aria-live="assertive">
  Error: Please correct the form errors.
</div>

<!-- Announce when convenient -->
<div aria-live="polite" aria-atomic="true">
  5 new messages
</div>

<!-- Status messages -->
<div role="status" aria-live="polite">
  Item added to cart
</div>

Form Accessibility

<!-- Proper labeling -->
<label for="email">Email Address</label>
<input
  type="email"
  id="email"
  name="email"
  required
  aria-required="true"
  aria-describedby="email-error email-help"
/>
<span id="email-help">We'll never share your email</span>
<span id="email-error" role="alert" aria-live="polite">
  <!-- Error message appears here -->
</span>

<!-- Grouped inputs -->
<fieldset>
  <legend>Contact Preferences</legend>
  <div>
    <input type="checkbox" id="email-pref" name="contact" value="email" />
    <label for="email-pref">Email</label>
  </div>
  <div>
    <input type="checkbox" id="phone-pref" name="contact" value="phone" />
    <label for="phone-pref">Phone</label>
  </div>
</fieldset>

<!-- Radio groups -->
<fieldset>
  <legend>Subscription Type</legend>
  <div>
    <input type="radio" id="free" name="subscription" value="free" checked />
    <label for="free">Free</label>
  </div>
  <div>
    <input type="radio" id="premium" name="subscription" value="premium" />
    <label for="premium">Premium</label>
  </div>
</fieldset>

Keyboard Navigation

// React component with keyboard support
function DropdownMenu({ items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);

  const handleKeyDown = (e: KeyboardEvent) => {
    switch (e.key) {
      case 'Enter':
      case ' ':
        e.preventDefault();
        setIsOpen(!isOpen);
        break;

      case 'Escape':
        setIsOpen(false);
        buttonRef.current?.focus();
        break;

      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else {
          setFocusedIndex((prev) => (prev + 1) % items.length);
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          setFocusedIndex((prev) => (prev - 1 + items.length) % items.length);
        }
        break;

      case 'Home':
        e.preventDefault();
        setFocusedIndex(0);
        break;

      case 'End':
        e.preventDefault();
        setFocusedIndex(items.length - 1);
        break;
    }
  };

  return (
    <div onKeyDown={handleKeyDown}>
      <button
        ref={buttonRef}
        aria-haspopup="true"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        Menu
      </button>

      {isOpen && (
        <div
          ref={menuRef}
          role="menu"
          aria-orientation="vertical"
        >
          {items.map((item, index) => (
            <button
              key={item.id}
              role="menuitem"
              tabIndex={index === focusedIndex ? 0 : -1}
              onClick={() => item.onClick()}
            >
              {item.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Focus Management

// Trap focus in modal
function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!isOpen) return;

    const modal = modalRef.current;
    if (!modal) return;

    const focusableElements = modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

    // Focus first element
    firstElement?.focus();

    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    modal.addEventListener('keydown', handleTabKey);

    return () => {
      modal.removeEventListener('keydown', handleTabKey);
    };
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">Modal Title</h2>
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Color Contrast

/* WCAG AA requires 4.5:1 for normal text, 3:1 for large text */
/* WCAG AAA requires 7:1 for normal text, 4.5:1 for large text */

.button-primary {
  /* Good contrast: 7.2:1 */
  background: #0066cc;
  color: #ffffff;
}

.button-secondary {
  /* Good contrast: 4.6:1 */
  background: #f0f0f0;
  color: #333333;
}

/* Don't rely on color alone */
.error {
  color: #cc0000;
}

.error::before {
  content: '⚠ '; /* Visual indicator */
}

.error[aria-invalid="true"] {
  /* Also announced to screen readers */
}
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<style>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}
</style>

Images and Alt Text

<!-- Informative image -->
<img src="chart.png" alt="Sales increased by 25% in Q4 2024" />

<!-- Decorative image -->
<img src="decorative.png" alt="" role="presentation" />

<!-- Complex image -->
<figure>
  <img src="complex-chart.png" alt="Detailed sales data" />
  <figcaption>
    <details>
      <summary>Chart description</summary>
      <p>Detailed text description of the chart data...</p>
    </details>
  </figcaption>
</figure>

<!-- Icon with text -->
<button>
  <svg aria-hidden="true"><path d="..." /></svg>
  <span>Delete</span>
</button>

<!-- Icon without text -->
<button aria-label="Delete item">
  <svg aria-hidden="true"><path d="..." /></svg>
</button>

Screen Reader-Only Content

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
<button>
  <svg aria-hidden="true">...</svg>
  <span class="sr-only">Settings</span>
</button>

Responsive and Touch-Friendly

/* Minimum touch target size: 44x44px */
button,
a {
  min-height: 44px;
  min-width: 44px;
  padding: 12px 24px;
}

/* Ensure adequate spacing */
.button-group button {
  margin: 4px;
}

Headings Hierarchy

<!-- Good: Logical hierarchy -->
<h1>Page Title</h1>
  <h2>Section 1</h2>
    <h3>Subsection 1.1</h3>
    <h3>Subsection 1.2</h3>
  <h2>Section 2</h2>

<!-- Bad: Skipped levels -->
<h1>Page Title</h1>
  <h3>Section</h3> <!-- Skipped h2 -->

Tables

<table>
  <caption>Monthly Sales Report</caption>
  <thead>
    <tr>
      <th scope="col">Month</th>
      <th scope="col">Sales</th>
      <th scope="col">Growth</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">January</th>
      <td>$10,000</td>
      <td>+5%</td>
    </tr>
  </tbody>
</table>

Loading States

function DataList() {
  const { data, isLoading } = useQuery('data');

  if (isLoading) {
    return (
      <div role="status" aria-live="polite" aria-busy="true">
        <span className="sr-only">Loading data...</span>
        <Spinner aria-hidden="true" />
      </div>
    );
  }

  return (
    <div role="region" aria-label="Data list">
      {data.map(item => <Item key={item.id} {...item} />)}
    </div>
  );
}

Testing Accessibility

Automated Testing

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('page has no accessibility violations', async () => {
  const { container } = render(<App />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing Checklist

  • Keyboard navigation works
  • Screen reader announces correctly
  • Color contrast meets WCAG AA
  • Forms have proper labels
  • Images have alt text
  • Headings are hierarchical
  • Focus indicators are visible
  • Touch targets are adequate
  • No color-only information
  • Text can be resized to 200%

Reduced Motion

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}
function AnimatedComponent() {
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <motion.div
      animate={{ opacity: 1 }}
      transition={{ duration: prefersReducedMotion ? 0 : 0.3 }}
    >
      Content
    </motion.div>
  );
}

Best Practices

  1. Use semantic HTML
  2. Provide text alternatives
  3. Ensure keyboard accessibility
  4. Maintain sufficient color contrast
  5. Create logical heading structure
  6. Label form inputs properly
  7. Make focus indicators visible
  8. Support screen readers
  9. Test with real assistive technologies
  10. Follow WCAG 2.2 guidelines