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
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;
-
TabandShift+Tabconstrained to dialog controls; -
Escapeto 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.
Top comments (0)