← Back to question bank
DebuggingMidMedium#1034 · 25m

Debug Accessibility interaction model

Explain focus, names, descriptions, landmarks, and live regions. Then apply it to a realistic product screen where a user action, browser behavior, and rendering timing all matter.

Answer Strategy

Accessibility debugging is the question that exposes whether you treat keyboard and screen-reader users as a first-class path. The interview-grade rule: every interactive element must be reachable by Tab, every group must have a role and an accessible name, every transient surface (dialog, menu, popover) must trap focus and restore it on close. Symptoms — Escape does nothing, Tab leaks behind a modal, focus lands at the top of the page after close — are all the same root: nobody owns the focus contract.

Diagnose by walking the page with the keyboard, then with VoiceOver/NVDA, then with the accessibility tree open in DevTools. Each step finds different bugs. Keyboard finds reachability and focus-restoration gaps. Screen readers find missing names, missing roles, and live regions that announce wrong urgency. The accessibility tree finds aria attributes pointing at non-existent ids and roles that conflict with the underlying tag.

Volunteer the senior tradeoff. Native elements (button, dialog) are correct by default; using divs with role attributes is opt-in correctness that breaks easily. The HTML <dialog> element handles modal semantics natively, but legacy browsers and styling constraints often push teams to roll their own. Pick the path explicitly. The reference shows the rolled-your-own pattern with all the focus-management glue; the same component would be three lines if the team adopted <dialog> with showModal().

Reference Implementation: Side Panel With Focus Trap And Restoration

BrokenSidePanel reproduces the keyboard failure mode (no role, no focus management). SidePanel fixes the contract: role=dialog, focus trap, Escape close, focus restore.

// Symptom: a "side panel" pattern is keyboard-broken. Mouse users can open,
// edit, and close it; keyboard users can open it but Tab cycles to the
// (visually hidden) page behind, Escape does nothing, and after closing,
// focus lands at the top of the document instead of the trigger.

// BROKEN version uses a div as a button, no role, no labelled relation,
// and no focus management. Mouse-only thinking.
function BrokenSidePanel({ open, onOpen, onClose }: {
  open: boolean;
  onOpen: () => void;
  onClose: () => void;
}) {
  return (
    <div>
      <div onClick={onOpen} className="trigger">Open settings</div>
      {open && (
        <div className="panel">
          <div onClick={onClose}>X</div>
          <p>Settings body</p>
          <button>Save</button>
        </div>
      )}
    </div>
  );
}

// FIXED version names the role, manages focus deterministically, traps Tab
// inside the dialog, restores focus on close, and labels the panel for
// screen readers. The exact same mouse path still works.
type SidePanelProps = {
  open: boolean;
  onClose: () => void;
};

export function SidePanel({ open, onClose }: SidePanelProps) {
  const dialogRef = React.useRef<HTMLDivElement | null>(null);
  const previousFocusRef = React.useRef<HTMLElement | null>(null);
  const titleId = React.useId();

  React.useEffect(() => {
    if (!open) return;
    previousFocusRef.current = document.activeElement as HTMLElement | null;
    const dialog = dialogRef.current;
    if (!dialog) return;

    const focusable = dialog.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
    );
    (focusable[0] ?? dialog).focus();

    function onKey(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
      } else if (event.key === 'Tab') {
        if (focusable.length === 0) {
          event.preventDefault();
          dialog.focus();
          return;
        }
        const first = focusable[0];
        const last = focusable[focusable.length - 1];
        if (event.shiftKey && document.activeElement === first) {
          event.preventDefault();
          last.focus();
        } else if (!event.shiftKey && document.activeElement === last) {
          event.preventDefault();
          first.focus();
        }
      }
    }

    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('keydown', onKey);
      previousFocusRef.current?.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby={titleId}
      tabIndex={-1}
    >
      <header>
        <h2 id={titleId}>Settings</h2>
        <button type="button" onClick={onClose} aria-label="Close settings">
          ×
        </button>
      </header>
      <p>Settings body</p>
      <button type="button">Save</button>
    </div>
  );
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Roles + names
Assert role=dialog, aria-modal=true, aria-labelledby resolves to the heading, and that close action has an accessible name (icon-only buttons need aria-label).
Focus contract
Test focus moves into the dialog on open, Tab/Shift-Tab cycle within, Escape closes, and focus restores to the opener. All four assertions in one suite catches regressions cheaply.
Real assistive tech
Pair the unit suite with a manual VoiceOver / NVDA pass on the deployed page. jsdom does not announce; screen reader tone and verbosity bugs only appear in the real browser.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

function Host() {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <button type="button" onClick={()=> setOpen(true)}>Open settings</button>
      <SidePanel open={open} onClose={()=> setOpen(false)} />
    </>
  );
}

describe('SidePanel a11y', ()=> {
  it('exposes role=dialog with aria-modal and an accessible name', async ()=> {
    const user= userEvent.setup();
    render(<Host />);
    await user.click(screen.getByRole('button', { name: /open settings/i }));
    const dialog = screen.getByRole('dialog', { name: /settings/i });
    expect(dialog).toHaveAttribute('aria-modal', 'true');
  });

  it('moves focus into the dialog and restores it on close', async () => {
    const user = userEvent.setup();
    render(<Host />);
    const opener = screen.getByRole('button', { name: /open settings/i });
    await user.click(opener);
    const close = screen.getByRole('button', { name: /close settings/i });
    expect(close).toHaveFocus();
    await user.keyboard('{Escape}');
    expect(opener).toHaveFocus();
  });

  it('Tab cycles inside the dialog, not to the dimmed page', async () => {
    const user = userEvent.setup();
    render(<Host />);
    await user.click(screen.getByRole('button', { name: /open settings/i }));
    await user.tab();
    expect(screen.getByRole('button', { name: /save/i })).toHaveFocus();
    await user.tab();
    // Wraps back to the close button, not to the host's Open settings.
    expect(screen.getByRole('button', { name: /close settings/i })).toHaveFocus();
  });
});

Interviewer Signal

Shows whether you understand accessibility interaction model as an operating model, not as memorized trivia.

Constraints

  • Use one concrete browser or React-facing example.
  • Name the failure mode a production user would notice.
  • Keep the first answer under two minutes before expanding.

Model Answer Shape

  • Start with the rule: focus, names, descriptions, landmarks, and live regions.
  • Tie the rule to ownership: what runs in render, what runs after paint, what is external state, and what must be cleaned up.
  • Close with the smallest test, trace, or code review check that would catch the bug.

Tradeoffs

  • A short interview answer is easier to follow, but a senior answer must still name the edge case.
  • Framework vocabulary helps only after the browser or language rule is clear.

Edge Cases

  • Slow devices where timing bugs become visible.
  • Repeated user actions before async work settles.
  • Browser defaults that differ from custom component behavior.

Testing And Proof

  • Unit-test the pure decision when possible.
  • Use an interaction test for focus, keyboard, timing, or cleanup behavior.

Follow-Ups

  • How would this change in a React component?
  • What would you log or profile if this broke in production?