Setting the file. One moment.
Chapter 180 · Web Component Design
Subchapter 180.1
references/accessibility-patterns.mdMarkdown15 KBView on GitHub
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<Element | null>(null);
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement;
dialogRef.current?.focus();
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
(previousActiveElement.current as HTMLElement)?.focus();
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "Tab") trapFocus(e, dialogRef.current);
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
}
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
aria-hidden={!isOpen}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
>
<h2 id="modal-title" className="text-lg font-semibold">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="absolute right-4 top-4 p-1"
>
<XIcon aria-hidden="true" />
</button>
<div className="mt-4">{children}</div>
</div>
</div>,
document.body,
);
}
function trapFocus(e: KeyboardEvent, container: HTMLElement | null) {
if (!container) return;
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}import { useState, useRef, useEffect, type ReactNode } from "react";
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
label: string;
}
export function Dropdown({ trigger, children, label }: DropdownProps) {
const [isOpen
import {
useState,
useRef,
useId,
type ChangeEvent,
type KeyboardEvent,
} from "react";
interface Option {
value: string;
label: string;
}
interface ComboboxProps {
options: Option[];
value: string
import { useId, type FormEvent } from "react";
interface FormFieldProps {
label: string;
error?: string;
required?: boolean;
children: (props: {
id: string;
"aria-describedby": string | undefined;
"aria-invalid": boolean;
}) => ReactNode;
}
export function FormField({
label,
error,
required,
children,
}: FormFieldProps) {
const id = useId();
const errorId = `${id}-error`;
return (
<div className="space-y-1">
<label htmlFor={id} className="block text-sm font-medium">
{label}
{required && (
<span aria-hidden="true" className="ml-1 text-red-500">
*
</span>
)}
</label>
{children({
id,
"aria-describedby": error ? errorId : undefined,
"aria-invalid": !!error,
})}
{error && (
<p id={errorId} role="alert" className="text-sm text-red-600">
{error}
</p>
)}
</div>
);
}
// Usage
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// Validation logic...
};
return (
<form onSubmit={handleSubmit} noValidate>
<FormField label="Email" error={errors.email} required>
{(props) => (
<input
{...props}
type="email"
required
className={`w-full rounded border px-3 py-2 ${
props["aria-invalid"] ? "border-red-500" : "border-gray-300"
}`}
/>
)}
</FormField>
<button
type="submit"
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Submit
</button>
</form>
);
}export function SkipLinks() {
return (
<div className="sr-only focus-within:not-sr-only">
<a
href="#main-content"
className="absolute left-4 top-4 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2"
>
Skip to main content
</a>
<a
href="#main-navigation"
className="absolute left-4 top-16 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2"
>
Skip to navigation
</a>
</div>
);
}import { useState, useEffect } from "react";
interface LiveAnnouncerProps {
message: string;
politeness?: "polite" | "assertive";
}
export function LiveAnnouncer({
message,
politeness = "polite",
}: LiveAnnouncerProps) {
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
// Clear first, then set - ensures screen readers pick up the change
setAnnouncement("");
const timer = setTimeout(() => setAnnouncement(message), 100);
return () => clearTimeout(timer);
}, [message]);
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>
);
}
// Usage in a search component
function SearchResults({
results,
loading,
}: {
results: Item[];
loading: boolean;
}) {
const message = loading
? "Loading results..."
: `${results.length} results found`;
return (
<>
<LiveAnnouncer message={message} />
<ul>{/* results */}</ul>
</>
);
}// useFocusReturn - restore focus after closing
function useFocusReturn() {
const previousElement = useRef<Element | null>(null);
const saveFocus = () => {
previousElement.current = document.activeElement;
};
const restoreFocus = () => {
(previousElement.current as HTMLElement)?.focus();
};
return { saveFocus, restoreFocus };
}
// useFocusTrap - keep focus within container
function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) {
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusableElements =
container.querySelectorAll<HTMLElement>(focusableSelector);
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, isActive]);
}// Check if colors meet WCAG requirements
function getContrastRatio(fg: string, bg: string): number {
const getLuminance = (hex: string): number => {
const rgb = parseInt(hex.slice(1), 16);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = rgb & 0xff;
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
const l1 = getLuminance(fg);
const l2 = getLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
function meetsWCAG(
fg: string,
bg: string,
level: "AA" | "AAA" = "AA",
): boolean {
const ratio = getContrastRatio(fg, bg);
return level === "AAA" ? ratio >= 7 : ratio >= 4.5;
}