DEV Community

Add Arrow-Key Shortcuts to a Confirmation Dialog Without Breaking Accessibility

Keep the baseline first

For an alert-style confirmation, users still need:

  • an accessible name and description
  • focus moved inside when the dialog opens
  • Tab and Shift+Tab constrained to dialog controls
  • Escape to dismiss when cancellation is allowed
  • visible focus
  • focus returned to the trigger after close
  • actual buttons whose labels explain the actions

The WAI-ARIA Authoring Practices Alert Dialog Pattern describes the modal semantics and keyboard foundation. Left/right mapping is a product shortcut, not a required AlertDialog convention. That means we must not steal keys from the established behavior around it.

Isolate the extra mapping

The companion keyboard.mjs starts with a pure function:

export function arrowAction(key) {
  if (key === "ArrowLeft") return "cancel";
  if (key === "ArrowRight") return "confirm";
  return null;
}

The event handler ignores unrelated and modified keys:

export function handleDialogArrow(event, controls) {
  const action = arrowAction(event.key);
  if (!action || event.altKey || event.ctrlKey || event.metaKey) return false;
  event.preventDefault();
  controls[action].focus();
  return true;
}

Notice what is absent: no handler for Tab, Shift+Tab, Escape, or Enter. The native <dialog> and buttons in the minimal demo retain their normal jobs. In a React component, use a well-tested modal/dialog primitive for focus containment and dismissal, then add this narrow handler to its content.

A complete minimal dialog

The included dialog.html uses native semantics:

<button id="open">Run slash command</button>

<dialog id="confirm" aria-labelledby="title" aria-describedby="description">
  <h2 id="title">Run this slash command?</h2>
  <p id="description">The task may change files in the workspace.</p>
  <form method="dialog">
    <button id="cancel" value="cancel">Cancel</button>
    <button id="accept" value="confirm">Confirm</button>
  </form>
</dialog>

On open, the demo focuses Cancel. That conservative default avoids placing initial focus on a potentially destructive action. On close, it restores focus to the trigger and announces the result in an aria-live status.

Test the mapping and the interaction

Run the pure mapping test:

node test-keyboard.mjs

Expected output:

PASS arrows move focus; Tab, Escape, and modified arrows remain untouched

Then serve the directory over HTTP and test dialog.html with keyboard only. The manual checklist is:

  • Open from the trigger; focus is visible on Cancel.
  • Press Right; focus moves to Confirm without executing it.
  • Press Left; focus returns to Cancel.
  • Use Tab and Shift+Tab; focus stays within the modal.
  • Press Escape; the dialog closes and focus returns to the trigger.
  • Reopen, focus Confirm, press Enter; the button activates once.
  • Zoom to 200% and repeat without losing labels or focus indication.

Also test with a screen reader. Arrow keys can participate in screen-reader navigation modes, so the control must remain understandable without discovering the shortcut. Never make the arrows the only way to choose an action.

The design rule is pleasantly small: add shortcuts as an enhancement around semantic controls, and prove that the keys you did not handle still work.

Disclosure: I contribute to the MonkeyCode project. The product behavior is based on the linked public issue, PR, and pinned source; the standalone mapping tests were run locally, not as a full MonkeyCode accessibility audit.

Comments

No comments yet. Start the discussion.