DEV Community

babycat
babycat

Posted on

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

Two buttons in a confirmation dialog look simple: Cancel and Confirm. Keyboard behavior makes the component a small state machine.

A recent MonkeyCode change gives us a concrete example. Issue #862 and PR #863 add these shortcuts to the slash-command confirmation:

ArrowLeft  -> focus Cancel
ArrowRight -> focus Confirm
Enter fullscreen mode Exit fullscreen mode

The reviewed implementation at commit c58bcd4 moves focus through button refs. That is a useful extra interaction. It is not a replacement for the dialog's accessibility foundation.

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected output:

PASS arrows move focus; Tab, Escape, and modified arrows remain untouched
Enter fullscreen mode Exit fullscreen mode

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

  1. Open from the trigger; focus is visible on Cancel.
  2. Press Right; focus moves to Confirm without executing it.
  3. Press Left; focus returns to Cancel.
  4. Use Tab and Shift+Tab; focus stays within the modal.
  5. Press Escape; the dialog closes and focus returns to the trigger.
  6. Reopen, focus Confirm, press Enter; the button activates once.
  7. 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.

Top comments (0)