Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js

Accessible React Component Library Demo

A playground for six React components built to the WAI-ARIA patterns: Dialog, Tabs, Combobox, Menu button, Toast and Switch. Use each one with the keyboard, read its key table, and watch the inspector show the role, name and ARIA state assistive tech receives as you move. Tick the checklist as you verify each behaviour.

Read the case study →

Interactive demo

samiullah.tech/lab/accessible-component-library

Dialog

Modal with focus trap, scroll lock and focus return. The alert variant cannot be dismissed by clicking outside.

WAI-ARIA pattern: Dialog (modal) / Alert dialog ↗

Open a dialog, press Tab a few times, then Escape.

⌨ keyboard

Keyboard interactions for Dialog
TabNext focusable element inside the dialog; wraps from last to first
Shift + TabPrevious focusable element; wraps from first to last
EscapeClose the dialog and return focus to the button that opened it
✓ a11y checklist (0/6 verified by you)

Try each item with the keyboard (and a screen reader if you have one), then tick it. Saved in this browser.

## How it works

Keyboard logic that is the same across components lives in a11y.ts: nextIndex() handles arrows, Home, End, wrapping and disabled items for both Tabs and Menu; trapTab() keeps focus inside the Dialog; typeahead() powers first-letter jumps in the Menu.

Tabs and Menu use roving focus (real DOM focus moves between items). The Combobox keeps focus in the input and points at the highlighted option with aria-activedescendant, which is what lets you keep typing while you browse.

The inspector listens for focusin and key presses, then reads the focused element's role, accessible name and ARIA attributes. It updates after every key, so you can watch aria-expanded or aria-activedescendant change.

## View source

The code running in the window above, exactly as deployed.

app/lab/accessible-component-library/a11y.ts180 lines
/**
 * Framework-free accessibility helpers used by the components: focus trapping, roving index maths, typeahead,
 * combobox filtering, and a tiny "accessibility tree" reader for the inspector panel.
 */

export const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

export function focusables(root: HTMLElement): HTMLElement[] {
  return [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => !el.hasAttribute("inert") && el.getClientRects().length > 0);
}

/** Keep Tab and Shift+Tab inside `root`. Returns true if it handled the key. */
export function trapTab(e: { key: string; shiftKey: boolean; preventDefault(): void }, root: HTMLElement): boolean {
  if (e.key !== "Tab") return false;
  const items = focusables(root);
  const first = items[0];
  const last = items.at(-1);
  if (!first || !last) {
    e.preventDefault();
    return true;
  }
  const active = document.activeElement;
  if (e.shiftKey && (active === first || !root.contains(active))) {
    e.preventDefault();
    last.focus();
    return true;
  }
  if (!e.shiftKey && (active === last || !root.contains(active))) {
    e.preventDefault();
    first.focus();
    return true;
  }
  return false;
}

/**
 * Next index for arrow-key navigation (roving tabindex / active descendant).
 * Returns null when the key is not a navigation key for this orientation.
 */
export function nextIndex(
  current: number,
  key: string,
  count: number,
  opts: { orientation?: "horizontal" | "vertical"; wrap?: boolean; disabled?: (i: number) => boolean } = {},
): number | null {
  const { orientation = "horizontal", wrap = true, disabled } = opts;
  if (count === 0) return null;
  const prev = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
  const next = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
  let dir: 1 | -1;
  let i: number;
  if (key === "Home") {
    i = -1;
    dir = 1;
  } else if (key === "End") {
    i = count;
    dir = -1;
  } else if (key === next) {
    i = current;
    dir = 1;
  } else if (key === prev) {
    i = current;
    dir = -1;
  } else return null;
  for (let step = 0; step < count; step++) {
    i += dir;
    if (i < 0 || i >= count) {
      if (!wrap || key === "Home" || key === "End") return current;
      i = (i + count) % count;
    }
    if (!disabled?.(i)) return i;
  }
  return current;
}

/** Typeahead: first item after `from` whose label starts with the buffer (wrapping). */
export function typeahead(labels: string[], buffer: string, from: number): number | null {
  const q = buffer.toLowerCase();
  if (!q) return null;
  for (let k = 1; k <= labels.length; k++) {
    const i = (from + k) % labels.length;
    if (labels[i]!.toLowerCase().startsWith(q)) return i;
  }
  return null;
}

/** Combobox filter: prefix matches first, then substring matches. */
export function filterOptions(options: string[], query: string): string[] {
  const q = query.trim().toLowerCase();
  if (!q) return options;
  const starts = options.filter((o) => o.toLowerCase().startsWith(q));
  const contains = options.filter((o) => !o.toLowerCase().startsWith(q) && o.toLowerCase().includes(q));
  return [...starts, ...contains];
}

/* ------------------------------------------------------------------ */
/* Inspector: a simplified accessible name + role computation          */
/* ------------------------------------------------------------------ */

const IMPLICIT: Record<string, string> = {
  BUTTON: "button",
  A: "link",
  SELECT: "combobox",
  TEXTAREA: "textbox",
  UL: "list",
  OL: "list",
  LI: "listitem",
  H1: "heading",
  H2: "heading",
  H3: "heading",
  DIALOG: "dialog",
};

export function roleOf(el: Element): string {
  const explicit = el.getAttribute("role");
  if (explicit) return explicit;
  if (el.tagName === "INPUT") {
    const t = (el as HTMLInputElement).type;
    return t === "checkbox" ? "checkbox" : t === "radio" ? "radio" : t === "range" ? "slider" : "textbox";
  }
  return IMPLICIT[el.tagName] ?? "generic";
}

/** Rough version of the accname algorithm: aria-labelledby, aria-label, <label>, then text content. */
export function nameOf(el: Element): string {
  const ids = el.getAttribute("aria-labelledby");
  if (ids) {
    const text = ids
      .split(/\s+/)
      .map((id) => el.ownerDocument.getElementById(id)?.textContent?.trim() ?? "")
      .join(" ")
      .trim();
    if (text) return text;
  }
  const label = el.getAttribute("aria-label");
  if (label) return label;
  if (el.id) {
    const lab = el.ownerDocument.querySelector(`label[for="${CSS.escape(el.id)}"]`);
    if (lab?.textContent) return lab.textContent.trim();
  }
  const wrapping = el.closest("label");
  if (wrapping?.textContent) return wrapping.textContent.trim();
  return (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
}

const STATES = [
  "aria-expanded",
  "aria-selected",
  "aria-checked",
  "aria-pressed",
  "aria-controls",
  "aria-activedescendant",
  "aria-haspopup",
  "aria-modal",
  "aria-invalid",
  "aria-disabled",
  "aria-describedby",
  "tabindex",
];

export type Inspection = { role: string; name: string; states: [string, string][] };

export function inspect(el: Element): Inspection {
  const states: [string, string][] = [];
  for (const a of STATES) {
    const v = el.getAttribute(a);
    if (v !== null) states.push([a, v]);
  }
  return { role: roleOf(el), name: nameOf(el), states };
}

export const COUNTRIES = [
  "Argentina", "Australia", "Austria", "Bangladesh", "Belgium", "Brazil", "Canada", "Chile", "China", "Colombia",
  "Denmark", "Egypt", "Finland", "France", "Germany", "Ghana", "Greece", "India", "Indonesia", "Ireland",
  "Italy", "Japan", "Jordan", "Kenya", "Malaysia", "Mexico", "Morocco", "Netherlands", "New Zealand", "Nigeria",
  "Norway", "Oman", "Pakistan", "Peru", "Philippines", "Poland", "Portugal", "Qatar", "Saudi Arabia", "Singapore",
  "South Africa", "South Korea", "Spain", "Sweden", "Switzerland", "Thailand", "Turkey", "United Arab Emirates", "United Kingdom", "United States",
];
app/lab/accessible-component-library/components.tsx468 lines
"use client";

import { createContext, useCallback, useContext, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { filterOptions, focusables, nextIndex, trapTab, typeahead } from "./a11y";

/* ================================================================== */
/* Switch                                                              */
/* ================================================================== */

export function Switch({ checked, onChange, label, disabled }: { checked: boolean; onChange: (v: boolean) => void; label: string; disabled?: boolean }) {
  const id = useId();
  return (
    <span className="inline-flex items-center gap-3">
      <button
        type="button"
        role="switch"
        id={id}
        aria-checked={checked}
        disabled={disabled}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 transition-colors motion-reduce:transition-none disabled:opacity-40 ${
          checked ? "border-accent bg-accent" : "border-fg-muted bg-bg-muted"
        }`}
      >
        <span
          aria-hidden="true"
          className={`inline-block size-4 rounded-full bg-bg shadow transition-transform motion-reduce:transition-none ${checked ? "translate-x-5" : "translate-x-0.5"}`}
        />
      </button>
      <label htmlFor={id} className="text-sm select-none">
        {label}
      </label>
    </span>
  );
}

/* ================================================================== */
/* Tabs                                                                */
/* ================================================================== */

export type TabItem = { id: string; label: string; content: ReactNode; disabled?: boolean };

export function Tabs({ items, activation = "automatic", label }: { items: TabItem[]; activation?: "automatic" | "manual"; label: string }) {
  const base = useId();
  const [selected, setSelected] = useState(items.find((t) => !t.disabled)?.id ?? "");
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  function onKeyDown(e: React.KeyboardEvent, i: number) {
    const n = nextIndex(i, e.key, items.length, { disabled: (k) => !!items[k]!.disabled });
    if (n === null) return;
    e.preventDefault();
    refs.current[n]?.focus();
    if (activation === "automatic") setSelected(items[n]!.id);
  }

  return (
    <div>
      <div role="tablist" aria-label={label} className="flex gap-1 border-b border-border">
        {items.map((t, i) => {
          const isSel = t.id === selected;
          return (
            <button
              key={t.id}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="tab"
              id={`${base}-tab-${t.id}`}
              aria-selected={isSel}
              aria-controls={`${base}-panel-${t.id}`}
              aria-disabled={t.disabled || undefined}
              tabIndex={isSel ? 0 : -1}
              onClick={() => !t.disabled && setSelected(t.id)}
              onKeyDown={(e) => onKeyDown(e, i)}
              className={`-mb-px border-b-2 px-3 py-2 text-sm ${isSel ? "border-accent font-medium text-fg" : "border-transparent text-fg-muted hover:text-fg"} ${t.disabled ? "cursor-not-allowed opacity-40" : ""}`}
            >
              {t.label}
            </button>
          );
        })}
      </div>
      {items.map((t) => (
        <div
          key={t.id}
          role="tabpanel"
          id={`${base}-panel-${t.id}`}
          aria-labelledby={`${base}-tab-${t.id}`}
          hidden={t.id !== selected}
          tabIndex={0}
          className="py-3 text-sm"
        >
          {t.content}
        </div>
      ))}
    </div>
  );
}

/* ================================================================== */
/* Dialog                                                              */
/* ================================================================== */

export function Dialog({
  open,
  onClose,
  title,
  description,
  children,
  variant = "dialog",
}: {
  open: boolean;
  onClose: () => void;
  title: string;
  description?: string;
  children: ReactNode;
  variant?: "dialog" | "alertdialog";
}) {
  const id = useId();
  const panel = useRef<HTMLDivElement>(null);
  const returnTo = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;
    returnTo.current = document.activeElement as HTMLElement | null;
    const overflow = document.body.style.overflow;
    document.body.style.overflow = "hidden"; // scroll lock
    // Alert dialogs focus the least destructive action; normal dialogs focus the first control.
    const root = panel.current!;
    const target = root.querySelector<HTMLElement>("[data-autofocus]") ?? focusables(root)[0] ?? root;
    target.focus();
    return () => {
      document.body.style.overflow = overflow;
      returnTo.current?.focus(); // focus goes back to the trigger
    };
  }, [open]);

  if (!open) return null;
  return (
    <div
      className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4 motion-safe:animate-[fadein_120ms_ease-out]"
      onMouseDown={(e) => variant === "dialog" && e.target === e.currentTarget && onClose()}
    >
      <div
        ref={panel}
        role={variant}
        aria-modal="true"
        aria-labelledby={`${id}-t`}
        aria-describedby={description ? `${id}-d` : undefined}
        tabIndex={-1}
        onKeyDown={(e) => {
          if (e.key === "Escape") {
            e.stopPropagation();
            onClose();
          } else trapTab(e, panel.current!);
        }}
        className="w-full max-w-md rounded-xl border border-border bg-bg-elev p-5 shadow-2xl outline-none"
      >
        <h2 id={`${id}-t`} className="text-lg font-semibold">
          {title}
        </h2>
        {description && (
          <p id={`${id}-d`} className="mt-1 text-sm text-fg-muted">
            {description}
          </p>
        )}
        <div className="mt-4">{children}</div>
      </div>
    </div>
  );
}

/* ================================================================== */
/* Combobox (editable, list autocomplete, aria-activedescendant)       */
/* ================================================================== */

export function Combobox({ label, options, value, onChange, placeholder }: { label: string; options: string[]; value: string | null; onChange: (v: string | null) => void; placeholder?: string }) {
  const base = useId();
  const [query, setQuery] = useState(value ?? "");
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(-1);
  const results = filterOptions(options, query === value ? "" : query);
  const listRef = useRef<HTMLUListElement>(null);
  const optionId = (i: number) => `${base}-opt-${i}`;

  useEffect(() => {
    if (active >= 0) listRef.current?.querySelector(`#${CSS.escape(optionId(active))}`)?.scrollIntoView({ block: "nearest" });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active]);

  function choose(v: string) {
    onChange(v);
    setQuery(v);
    setOpen(false);
    setActive(-1);
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        if (!e.altKey) setActive(0);
      } else setActive((a) => (a + 1) % Math.max(1, results.length));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setOpen(true);
      setActive((a) => (a <= 0 ? results.length - 1 : a - 1));
    } else if (e.key === "Enter") {
      if (open && results[active]) {
        e.preventDefault();
        choose(results[active]!);
      }
    } else if (e.key === "Escape") {
      if (open) setOpen(false);
      else {
        setQuery("");
        onChange(null);
      }
      setActive(-1);
    }
  }

  return (
    <div className="relative max-w-xs">
      <label htmlFor={`${base}-input`} className="block text-sm font-medium">
        {label}
      </label>
      <input
        id={`${base}-input`}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={open}
        aria-controls={`${base}-list`}
        aria-activedescendant={open && active >= 0 && results[active] ? optionId(active) : undefined}
        autoComplete="off"
        value={query}
        placeholder={placeholder}
        onChange={(e) => {
          setQuery(e.target.value);
          setOpen(true);
          setActive(e.target.value ? 0 : -1);
        }}
        onKeyDown={onKeyDown}
        onBlur={() => setOpen(false)}
        onClick={() => setOpen(true)}
        className="mt-1 w-full rounded-md border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-accent"
      />
      <ul
        ref={listRef}
        id={`${base}-list`}
        role="listbox"
        aria-label={label}
        hidden={!open}
        className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-border bg-bg-elev py-1 shadow-lg"
      >
        {results.map((o, i) => (
          <li
            key={o}
            id={optionId(i)}
            role="option"
            aria-selected={o === value}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => choose(o)}
            onMouseMove={() => setActive(i)}
            className={`flex cursor-pointer items-center justify-between px-3 py-1.5 text-sm ${i === active ? "bg-accent-soft text-fg" : ""}`}
          >
            {o}
            {o === value && <span aria-hidden="true">✓</span>}
          </li>
        ))}
        {results.length === 0 && <li className="px-3 py-1.5 text-sm text-fg-muted">No matches</li>}
      </ul>
      <p className="sr-only" aria-live="polite">
        {open ? `${results.length} result${results.length === 1 ? "" : "s"} available.` : ""}
      </p>
    </div>
  );
}

/* ================================================================== */
/* Menu button                                                         */
/* ================================================================== */

export type MenuItem = { label: string; onSelect: () => void; disabled?: boolean; shortcut?: string; danger?: boolean };

export function MenuButton({ label, items }: { label: string; items: MenuItem[] }) {
  const base = useId();
  const [open, setOpen] = useState(false);
  const button = useRef<HTMLButtonElement>(null);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const buffer = useRef({ text: "", at: 0 });

  const focusItem = useCallback((i: number) => refs.current[i]?.focus(), []);
  const firstEnabled = items.findIndex((i) => !i.disabled);
  const lastEnabled = items.length - 1 - [...items].reverse().findIndex((i) => !i.disabled);

  function openAt(i: number) {
    setOpen(true);
    requestAnimationFrame(() => focusItem(i));
  }
  function close(returnFocus = true) {
    setOpen(false);
    if (returnFocus) button.current?.focus();
  }

  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => {
      if (!button.current?.parentElement?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [open]);

  function onMenuKey(e: React.KeyboardEvent, i: number) {
    const n = nextIndex(i, e.key, items.length, { orientation: "vertical", disabled: (k) => !!items[k]!.disabled });
    if (n !== null) {
      e.preventDefault();
      focusItem(n);
    } else if (e.key === "Escape") {
      e.preventDefault();
      close();
    } else if (e.key === "Tab") {
      close(false);
    } else if (e.key.length === 1 && /\S/.test(e.key)) {
      const now = Date.now();
      buffer.current = { text: (now - buffer.current.at > 500 ? "" : buffer.current.text) + e.key, at: now };
      const hit = typeahead(
        items.map((x) => (x.disabled ? "" : x.label)),
        buffer.current.text,
        i,
      );
      if (hit !== null) focusItem(hit);
    }
  }

  return (
    <div className="relative inline-block">
      <button
        ref={button}
        type="button"
        id={`${base}-btn`}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={`${base}-menu`}
        onClick={() => (open ? close() : openAt(firstEnabled))}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            openAt(firstEnabled);
          } else if (e.key === "ArrowUp") {
            e.preventDefault();
            openAt(lastEnabled);
          }
        }}
        className="inline-flex items-center gap-2 rounded-md border border-border bg-bg-elev px-3 py-2 text-sm hover:border-accent"
      >
        {label} <span aria-hidden="true">▾</span>
      </button>
      <ul id={`${base}-menu`} role="menu" aria-labelledby={`${base}-btn`} hidden={!open} className="absolute z-20 mt-1 min-w-48 rounded-md border border-border bg-bg-elev py-1 shadow-lg">
        {items.map((it, i) => (
          <li key={it.label} role="none">
            <button
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="menuitem"
              tabIndex={-1}
              aria-disabled={it.disabled || undefined}
              onClick={() => {
                if (it.disabled) return;
                it.onSelect();
                close();
              }}
              onKeyDown={(e) => onMenuKey(e, i)}
              onMouseMove={(e) => !it.disabled && e.currentTarget.focus()}
              className={`flex w-full items-center justify-between gap-6 px-3 py-1.5 text-left text-sm outline-none focus:bg-accent-soft ${it.disabled ? "cursor-not-allowed opacity-40" : ""} ${it.danger ? "text-danger" : ""}`}
            >
              {it.label}
              {it.shortcut && <kbd className="font-mono text-[11px] text-fg-muted">{it.shortcut}</kbd>}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

/* ================================================================== */
/* Toasts                                                              */
/* ================================================================== */

export type Toast = { id: number; title: string; body?: string; tone: "info" | "success" | "error"; duration: number };
type ToastApi = { push: (t: Omit<Toast, "id">) => void };
const ToastContext = createContext<ToastApi | null>(null);

export function useToast() {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
  return ctx;
}

export function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const [paused, setPaused] = useState(false);
  const seq = useRef(0);
  const push = useCallback((t: Omit<Toast, "id">) => setToasts((list) => [...list, { ...t, id: ++seq.current }].slice(-4)), []);
  const dismiss = (id: number) => setToasts((list) => list.filter((t) => t.id !== id));

  return (
    <ToastContext.Provider value={{ push }}>
      {children}
      <section
        aria-label="Notifications"
        className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
        onMouseEnter={() => setPaused(true)}
        onMouseLeave={() => setPaused(false)}
        onFocus={() => setPaused(true)}
        onBlur={() => setPaused(false)}
      >
        {toasts.map((t) => (
          <ToastItem key={t.id} toast={t} paused={paused} onDismiss={() => dismiss(t.id)} />
        ))}
      </section>
    </ToastContext.Provider>
  );
}

function ToastItem({ toast, paused, onDismiss }: { toast: Toast; paused: boolean; onDismiss: () => void }) {
  const [left, setLeft] = useState(toast.duration);
  useEffect(() => {
    if (paused || toast.duration === 0) return;
    if (left <= 0) {
      onDismiss();
      return;
    }
    const t = setTimeout(() => setLeft((l) => l - 100), 100);
    return () => clearTimeout(t);
  }, [left, paused, toast.duration, onDismiss]);
  const tone = toast.tone === "error" ? "border-danger/60" : toast.tone === "success" ? "border-accent/60" : "border-border";
  return (
    <div
      role={toast.tone === "error" ? "alert" : "status"}
      className={`pointer-events-auto rounded-lg border ${tone} bg-bg-elev p-3 shadow-xl motion-safe:animate-[slidein_160ms_ease-out]`}
    >
      <div className="flex items-start gap-2">
        <span aria-hidden="true" className={toast.tone === "error" ? "text-danger" : toast.tone === "success" ? "text-accent" : "text-accent-2"}>
          {toast.tone === "error" ? "✕" : toast.tone === "success" ? "✓" : "i"}
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-medium">{toast.title}</p>
          {toast.body && <p className="mt-0.5 text-xs text-fg-muted">{toast.body}</p>}
        </div>
        <button type="button" onClick={onDismiss} aria-label={`Dismiss: ${toast.title}`} className="rounded px-1 text-fg-muted hover:text-fg">
          ×
        </button>
      </div>
      {toast.duration > 0 && (
        <div className="mt-2 h-0.5 overflow-hidden rounded bg-bg-muted" aria-hidden="true">
          <div className="h-full bg-fg-muted" style={{ width: `${(left / toast.duration) * 100}%` }} />
        </div>
      )}
    </div>
  );
}
app/lab/accessible-component-library/docs.ts137 lines
/** Per-component documentation: the WAI-ARIA pattern it follows, its keyboard contract and a manual test checklist. */

export type ComponentId = "dialog" | "tabs" | "combobox" | "menu" | "toast" | "switch";

export type Doc = {
  id: ComponentId;
  name: string;
  pattern: string;
  apg: string;
  summary: string;
  keys: [key: string, action: string][];
  checklist: string[];
};

const APG = "https://www.w3.org/WAI/ARIA/apg/patterns";

export const DOCS: Doc[] = [
  {
    id: "dialog",
    name: "Dialog",
    pattern: "Dialog (modal) / Alert dialog",
    apg: `${APG}/dialog-modal/`,
    summary: "Modal with focus trap, scroll lock and focus return. The alert variant cannot be dismissed by clicking outside.",
    keys: [
      ["Tab", "Next focusable element inside the dialog; wraps from last to first"],
      ["Shift + Tab", "Previous focusable element; wraps from first to last"],
      ["Escape", "Close the dialog and return focus to the button that opened it"],
    ],
    checklist: [
      "role=dialog (or alertdialog) with aria-modal=true",
      "Labelled by its heading via aria-labelledby",
      "Focus moves into the dialog when it opens",
      "Tab and Shift+Tab never leave the dialog",
      "Escape closes it and focus returns to the trigger",
      "Page behind does not scroll while open",
    ],
  },
  {
    id: "tabs",
    name: "Tabs",
    pattern: "Tabs",
    apg: `${APG}/tabs/`,
    summary: "Roving tabindex: only the selected tab is in the Tab order. Automatic or manual activation, disabled tabs are skipped.",
    keys: [
      ["← / →", "Move to the previous / next tab (wraps)"],
      ["Home / End", "First / last tab"],
      ["Enter / Space", "Activate the focused tab (manual mode)"],
      ["Tab", "Move from the tab list into the panel"],
    ],
    checklist: [
      "tablist > tab with aria-selected and aria-controls",
      "Each tabpanel is labelled by its tab",
      "Only one tab has tabindex=0",
      "Arrow keys skip disabled tabs",
      "Panel itself is focusable when it has no focusable content",
    ],
  },
  {
    id: "combobox",
    name: "Combobox",
    pattern: "Combobox with list autocomplete",
    apg: `${APG}/combobox/`,
    summary: "Focus stays in the input; the highlighted option is exposed with aria-activedescendant and the result count is announced.",
    keys: [
      ["↓ / ↑", "Open the list and move the highlight"],
      ["Alt + ↓", "Open the list without moving the highlight"],
      ["Enter", "Choose the highlighted option"],
      ["Escape", "Close the list; press again to clear the value"],
    ],
    checklist: [
      "Input has role=combobox, aria-expanded and aria-controls",
      "aria-activedescendant points at the highlighted option",
      "Options have role=option and aria-selected",
      "Number of results is announced in a live region",
      "Clicking an option does not blur the input first",
    ],
  },
  {
    id: "menu",
    name: "Menu button",
    pattern: "Menu button",
    apg: `${APG}/menu-button/`,
    summary: "Button opens a menu of actions. Focus moves into the menu, arrow keys move between items, and typing jumps by first letter.",
    keys: [
      ["Enter / Space / ↓", "Open the menu and focus the first item"],
      ["↑ (on button)", "Open the menu and focus the last item"],
      ["↓ / ↑", "Next / previous item, skipping disabled ones"],
      ["Home / End", "First / last item"],
      ["a–z", "Typeahead: jump to the next item starting with the typed letters"],
      ["Escape", "Close and return focus to the button"],
    ],
    checklist: [
      "Button has aria-haspopup=menu and aria-expanded",
      "Items have role=menuitem inside role=menu",
      "Disabled items use aria-disabled and stay discoverable",
      "Escape and Tab close the menu",
      "Clicking outside closes the menu",
    ],
  },
  {
    id: "toast",
    name: "Toast",
    pattern: "Status messages (WCAG 4.1.3)",
    apg: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html",
    summary: "Messages announced without moving focus. Errors use role=alert, others role=status. Timers pause on hover and focus.",
    keys: [
      ["Tab", "Reach the dismiss buttons in the notifications region"],
      ["Enter / Space", "Dismiss the focused toast"],
    ],
    checklist: [
      "Info and success use role=status (polite)",
      "Errors use role=alert (assertive)",
      "Focus is never moved to a toast",
      "Auto-dismiss pauses on hover and focus",
      "Animations respect prefers-reduced-motion",
    ],
  },
  {
    id: "switch",
    name: "Switch",
    pattern: "Switch",
    apg: `${APG}/switch/`,
    summary: "A button with role=switch and aria-checked, labelled by a real <label>. On/off is shown by position and colour.",
    keys: [
      ["Space / Enter", "Toggle"],
      ["Tab", "Move to the next switch"],
    ],
    checklist: [
      "role=switch with aria-checked true/false",
      "Visible label linked with for/id",
      "Focus ring visible in both themes",
      "State is not shown by colour alone (thumb position)",
      "Target is at least 24×24 CSS pixels",
    ],
  },
];
app/lab/accessible-component-library/Demo.tsx393 lines
"use client";

import { useEffect, useRef, useState } from "react";
import { COUNTRIES, inspect, type Inspection } from "./a11y";
import { Combobox, Dialog, MenuButton, Switch, Tabs, ToastProvider, useToast } from "./components";
import { DOCS, type ComponentId } from "./docs";

const KEY = "lab:accessible-component-library:v1";

type Saved = { active: ComponentId; checks: Record<string, boolean> };
type KeyLog = { n: number; key: string; target: string };

export default function Demo() {
  return (
    <ToastProvider>
      <Playground />
    </ToastProvider>
  );
}

function Playground() {
  const [active, setActive] = useState<ComponentId>("dialog");
  const [checks, setChecks] = useState<Record<string, boolean>>({});
  const [ready, setReady] = useState(false);
  const [info, setInfo] = useState<Inspection | null>(null);
  const [keys, setKeys] = useState<KeyLog[]>([]);
  const stage = useRef<HTMLDivElement>(null);
  const seq = useRef(0);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) {
        const s = JSON.parse(raw) as Saved;
        if (DOCS.some((d) => d.id === s.active)) setActive(s.active);
        setChecks(s.checks ?? {});
      }
    } catch {
      /* ignore */
    }
    setReady(true);
  }, []);
  useEffect(() => {
    if (!ready) return;
    try {
      localStorage.setItem(KEY, JSON.stringify({ active, checks } satisfies Saved));
    } catch {
      /* ignore */
    }
  }, [active, checks, ready]);

  // Inspector: read role, name and ARIA state of whatever has focus inside the stage, including after key presses.
  useEffect(() => {
    const read = () => {
      const el = document.activeElement;
      if (el && el !== document.body && (stage.current?.contains(el) || el.closest("[role=dialog],[role=alertdialog],[aria-label=Notifications]"))) setInfo(inspect(el));
    };
    const onKey = (e: KeyboardEvent) => {
      if (!(e.target instanceof Element)) return;
      const t = e.target;
      if (!stage.current?.contains(t) && !t.closest("[role=dialog],[role=alertdialog],[aria-label=Notifications]")) return;
      const label = e.key === " " ? "Space" : e.key;
      setKeys((k) => [{ n: ++seq.current, key: `${e.shiftKey && e.key !== "Shift" ? "Shift+" : ""}${e.altKey && e.key !== "Alt" ? "Alt+" : ""}${label}`, target: inspect(t).role }, ...k].slice(0, 8));
      requestAnimationFrame(read);
    };
    document.addEventListener("focusin", read);
    document.addEventListener("keydown", onKey, true);
    return () => {
      document.removeEventListener("focusin", read);
      document.removeEventListener("keydown", onKey, true);
    };
  }, []);

  const doc = DOCS.find((d) => d.id === active)!;
  const done = doc.checklist.filter((c) => checks[`${doc.id}:${c}`]).length;

  return (
    <div className="grid lg:grid-cols-[13rem_minmax(0,1fr)_18rem]">
      <style>{`@keyframes fadein{from{opacity:0}to{opacity:1}}@keyframes slidein{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}`}</style>

      {/* ---------- component list ---------- */}
      <nav aria-label="Components" className="border-b border-border p-3 lg:border-r lg:border-b-0">
        <ul className="flex gap-1 overflow-x-auto lg:flex-col">
          {DOCS.map((d) => {
            const n = d.checklist.filter((c) => checks[`${d.id}:${c}`]).length;
            return (
              <li key={d.id}>
                <button
                  type="button"
                  aria-current={active === d.id ? "page" : undefined}
                  onClick={() => setActive(d.id)}
                  className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm whitespace-nowrap ${active === d.id ? "bg-accent-soft font-medium text-fg" : "text-fg-muted hover:bg-bg-muted hover:text-fg"}`}
                >
                  {d.name}
                  <span className={`font-mono text-[10px] ${n === d.checklist.length ? "text-accent" : "text-fg-muted"}`}>
                    {n}/{d.checklist.length}
                  </span>
                </button>
              </li>
            );
          })}
        </ul>
        <button
          type="button"
          onClick={() => {
            setChecks({});
            setKeys([]);
            setActive("dialog");
          }}
          className="mt-3 hidden w-full rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-danger hover:text-danger lg:block"
        >
          reset demo
        </button>
      </nav>

      {/* ---------- stage ---------- */}
      <div className="min-w-0 space-y-5 p-4 sm:p-5">
        <header>
          <h3 className="text-lg font-semibold">{doc.name}</h3>
          <p className="mt-1 text-sm text-fg-muted">{doc.summary}</p>
          <a href={doc.apg} target="_blank" rel="noreferrer" className="mt-1 inline-block font-mono text-xs text-accent underline">
            WAI-ARIA pattern: {doc.pattern} ↗
          </a>
        </header>

        <div ref={stage} className="rounded-lg border border-dashed border-border bg-bg-elev p-4 sm:p-6" aria-label={`${doc.name} example`} role="group">
          {active === "dialog" && <DialogExample />}
          {active === "tabs" && <TabsExample />}
          {active === "combobox" && <ComboboxExample />}
          {active === "menu" && <MenuExample />}
          {active === "toast" && <ToastExample />}
          {active === "switch" && <SwitchExample />}
        </div>

        <section aria-labelledby="kb-h">
          <h4 id="kb-h" className="font-mono text-sm font-semibold">
            <span className="text-accent">⌨</span> keyboard
          </h4>
          <table className="mt-2 w-full text-left text-sm">
            <caption className="sr-only">Keyboard interactions for {doc.name}</caption>
            <tbody>
              {doc.keys.map(([k, a]) => (
                <tr key={k} className="border-b border-border">
                  <th scope="row" className="w-40 py-1.5 pr-3 align-top font-normal">
                    <kbd className="rounded border border-border bg-bg-muted px-1.5 py-0.5 font-mono text-xs">{k}</kbd>
                  </th>
                  <td className="py-1.5 text-fg-muted">{a}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </section>

        <fieldset>
          <legend className="font-mono text-sm font-semibold">
            <span className="text-accent">✓</span> a11y checklist{" "}
            <span className="font-normal text-fg-muted">
              ({done}/{doc.checklist.length} verified by you)
            </span>
          </legend>
          <p className="mt-1 text-xs text-fg-muted">Try each item with the keyboard (and a screen reader if you have one), then tick it. Saved in this browser.</p>
          <ul className="mt-2 space-y-1.5">
            {doc.checklist.map((c) => {
              const k = `${doc.id}:${c}`;
              return (
                <li key={k}>
                  <label className="flex items-start gap-2 text-sm">
                    <input type="checkbox" checked={!!checks[k]} onChange={(e) => setChecks({ ...checks, [k]: e.target.checked })} className="mt-0.5 size-4 accent-[var(--accent)]" />
                    <span className={checks[k] ? "text-fg-muted line-through" : ""}>{c}</span>
                  </label>
                </li>
              );
            })}
          </ul>
        </fieldset>
      </div>

      {/* ---------- inspector ---------- */}
      <aside aria-label="Accessibility inspector" className="border-t border-border bg-bg-elev p-4 lg:border-t-0 lg:border-l">
        <h4 className="font-mono text-sm font-semibold">a11y inspector</h4>
        <p className="mt-1 text-xs text-fg-muted">Focus anything in the example. This is roughly what assistive tech is told about it.</p>
        <div className="mt-3 rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed text-[#a1a1aa]">
          {info ? (
            <>
              <p>
                <span className="text-[#71717a]">role </span>
                <span className="text-[#4ade80]">{info.role}</span>
              </p>
              <p className="break-words">
                <span className="text-[#71717a]">name </span>
                <span className="text-[#e4e4e7]">“{info.name || <span className="text-[#f87171]">(no name)</span>}”</span>
              </p>
              {info.states.map(([k, v]) => (
                <p key={k} className="break-all">
                  <span className="text-[#60a5fa]">{k}</span>=<span className="text-[#fbbf24]">{v}</span>
                </p>
              ))}
            </>
          ) : (
            <p className="text-[#71717a]">$ waiting for focus…</p>
          )}
        </div>
        <h4 className="mt-4 font-mono text-xs font-semibold text-fg-muted">key presses</h4>
        <ol className="mt-1 h-40 overflow-y-auto rounded-md border border-border bg-[#0b0b0d] p-2 font-mono text-[11px] text-[#a1a1aa]">
          {keys.length === 0 && <li className="text-[#71717a]">none yet</li>}
          {keys.map((k) => (
            <li key={k.n}>
              <span className="text-[#4ade80]">{k.key}</span> <span className="text-[#71717a]">on {k.target}</span>
            </li>
          ))}
        </ol>
        <button
          type="button"
          onClick={() => {
            setChecks({});
            setKeys([]);
          }}
          className="mt-3 w-full rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-danger hover:text-danger lg:hidden"
        >
          reset demo
        </button>
      </aside>
    </div>
  );
}

/* ---------------- examples ---------------- */

const exBtn = "rounded-md border border-border bg-bg px-3 py-2 text-sm hover:border-accent hover:text-accent";

function DialogExample() {
  const [open, setOpen] = useState<null | "dialog" | "alertdialog">(null);
  const [name, setName] = useState("Order #1042");
  const [log, setLog] = useState("");
  return (
    <div className="space-y-3">
      <div className="flex flex-wrap gap-2">
        <button type="button" className={exBtn} onClick={() => setOpen("dialog")}>
          Rename order…
        </button>
        <button type="button" className={exBtn + " text-danger"} onClick={() => setOpen("alertdialog")}>
          Delete order…
        </button>
      </div>
      <p className="text-sm text-fg-muted" aria-live="polite">
        {log || "Open a dialog, press Tab a few times, then Escape."}
      </p>
      <Dialog open={open === "dialog"} onClose={() => setOpen(null)} title="Rename order" description="The new name is shown on invoices.">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            setLog(`Renamed to “${name}”.`);
            setOpen(null);
          }}
          className="space-y-3"
        >
          <label className="block text-sm">
            Name
            <input value={name} onChange={(e) => setName(e.target.value)} className="mt-1 w-full rounded-md border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-accent" />
          </label>
          <div className="flex justify-end gap-2">
            <button type="button" className={exBtn} onClick={() => setOpen(null)}>
              Cancel
            </button>
            <button type="submit" className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg">
              Save
            </button>
          </div>
        </form>
      </Dialog>
      <Dialog open={open === "alertdialog"} variant="alertdialog" onClose={() => setOpen(null)} title="Delete Order #1042?" description="This cannot be undone. The customer is not notified.">
        <div className="flex justify-end gap-2">
          <button type="button" data-autofocus className={exBtn} onClick={() => setOpen(null)}>
            Keep order
          </button>
          <button
            type="button"
            className="rounded-md bg-danger px-3 py-2 text-sm font-medium text-white"
            onClick={() => {
              setLog("Order deleted (not really, it is a demo).");
              setOpen(null);
            }}
          >
            Delete
          </button>
        </div>
      </Dialog>
    </div>
  );
}

function TabsExample() {
  const [manual, setManual] = useState(false);
  return (
    <div className="space-y-4">
      <Switch checked={manual} onChange={setManual} label="Manual activation (arrows move focus, Enter selects)" />
      <Tabs
        key={String(manual)}
        label="Patient record"
        activation={manual ? "manual" : "automatic"}
        items={[
          { id: "overview", label: "Overview", content: <p>Acme Dental · next appointment Tuesday 10:30 with Dr. Example.</p> },
          { id: "history", label: "History", content: <p>3 visits this year. Last cleaning in March.</p> },
          { id: "billing", label: "Billing", content: <p>Invoice #1042 paid. Balance $0.</p>, disabled: true },
          { id: "notes", label: "Notes", content: <p>Prefers morning appointments. Sensitive to cold.</p> },
        ]}
      />
      <p className="text-xs text-fg-muted">“Billing” is disabled, so arrow keys skip it.</p>
    </div>
  );
}

function ComboboxExample() {
  const [value, setValue] = useState<string | null>(null);
  return (
    <div className="space-y-3">
      <Combobox label="Country" options={COUNTRIES} value={value} onChange={setValue} placeholder="Start typing, e.g. “pa”" />
      <p className="text-sm text-fg-muted" aria-live="polite">
        Selected: <span className="font-mono text-fg">{value ?? "none"}</span>
      </p>
    </div>
  );
}

function MenuExample() {
  const [last, setLast] = useState("");
  const toast = useToast();
  const act = (label: string) => () => {
    setLast(label);
    toast.push({ title: `${label}`, body: "Order #1042", tone: "info", duration: 3000 });
  };
  return (
    <div className="space-y-3">
      <MenuButton
        label="Order actions"
        items={[
          { label: "Edit", onSelect: act("Edit"), shortcut: "E" },
          { label: "Duplicate", onSelect: act("Duplicate"), shortcut: "D" },
          { label: "Download PDF", onSelect: act("Download PDF") },
          { label: "Archive", onSelect: act("Archive"), disabled: true },
          { label: "Delete", onSelect: act("Delete"), danger: true },
        ]}
      />
      <p className="text-sm text-fg-muted" aria-live="polite">
        {last ? `Last action: ${last}` : "Open the menu with Enter or ↓, then type “d” twice."}
      </p>
    </div>
  );
}

function ToastExample() {
  const toast = useToast();
  const [duration, setDuration] = useState(5000);
  return (
    <div className="space-y-3">
      <div className="flex flex-wrap gap-2">
        <button type="button" className={exBtn} onClick={() => toast.push({ title: "Invoice sent", body: "Acme Dental will receive it by email.", tone: "success", duration })}>
          Success toast
        </button>
        <button type="button" className={exBtn} onClick={() => toast.push({ title: "Sync scheduled", body: "Runs again in 15 minutes.", tone: "info", duration })}>
          Info toast
        </button>
        <button type="button" className={exBtn + " text-danger"} onClick={() => toast.push({ title: "Payment failed", body: "Card declined for Order #1042.", tone: "error", duration: 0 })}>
          Error (stays)
        </button>
      </div>
      <label className="flex items-center gap-2 text-sm">
        Auto-dismiss after
        <select value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="rounded-md border border-border bg-bg px-2 py-1 text-sm">
          <option value={3000}>3 s</option>
          <option value={5000}>5 s</option>
          <option value={10000}>10 s</option>
        </select>
      </label>
      <p className="text-xs text-fg-muted">Hover the toasts or Tab into them and the timers pause. Errors use role=alert and stay until dismissed.</p>
    </div>
  );
}

function SwitchExample() {
  const [s, setS] = useState({ reminders: true, sms: false, marketing: false });
  return (
    <div className="space-y-3">
      <Switch checked={s.reminders} onChange={(v) => setS({ ...s, reminders: v })} label="Appointment reminders" />
      <br />
      <Switch checked={s.sms} onChange={(v) => setS({ ...s, sms: v })} label="SMS as well as email" disabled={!s.reminders} />
      <br />
      <Switch checked={s.marketing} onChange={(v) => setS({ ...s, marketing: v })} label="Product news" />
      <p className="text-xs text-fg-muted">The SMS switch is disabled while reminders are off.</p>
    </div>
  );
}