WCAG 2.1 AA — release blocker
Web (React DOM)
- Each pane renders
<ul role="listbox" aria-multiselectable>witharia-labelmatching the pane label. - Each row is
<button role="option" aria-selected={isChecked}>— single click toggles per-side selection (which is internal state). - Move buttons are real
<button>elements with action-describingaria-labels:aria-label="Move selected right"aria-label="Move all right"aria-label="Move selected left"aria-label="Move all left"And matchingtitlefor hover tooltips.
- Buttons that would be no-ops are visibly disabled (
cursor-not-allowed+ reduced opacity) AND carry thedisabledattribute so screen readers announce "dimmed" / "unavailable".
Keyboard
Tabenters the first pane → cycles through visible options → moves to the filter → next pane → move buttons.Space/Entertoggles the focused option.- The move buttons are reachable via
Tab;Enter/Spacetriggers them.
Empty states
- "No items" / "None assigned" / "No matches" all render as plain text inside the listbox so screen readers announce the state when the user navigates into the empty pane.
Reduced motion
- Hover background transitions are
duration-short(200 ms). User agents honourprefers-reduced-motion.
Color is never the only signal
- Selected rows have a fill on the checkbox plus a checkmark — colour change is reinforcement, not the only signal.
- Move buttons gray out when disabled — opacity + cursor, not just colour.
Audit checklist
- Each pane has a meaningful
aria-label. - Each row has
aria-selectedreflecting state. - Every move button has an action-describing
aria-label. - Disabled move buttons have the
disabledattribute (not justaria-disabled). - 4.5:1 contrast on row text in selected AND unselected states.
- Empty pane state announces via screen reader.