Skip to content

Let a dialog choose where initial focus lands - #116

Merged
woksin merged 6 commits into
mainfrom
feat/imp-25-dialog-initial-focus
Aug 10, 2026
Merged

Let a dialog choose where initial focus lands#116
woksin merged 6 commits into
mainfrom
feat/imp-25-dialog-initial-focus

Conversation

@woksin

@woksin woksin commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

A destructive dialog could not stop its OK button taking focus on open without discarding the footer's behavior.

Added

  • initialFocus on Dialog, forwarded by CommandDialog, choosing where focus lands when the dialog opens: DialogInitialFocus.Confirm (the default, unchanged), Cancel, or Content. Cancel degrades to Content where no dismissing button exists.

Fixed

  • A dialog with a custom footer or buttons={null} left focus on document.body, outside the modal. BusyIndicatorDialog shipped in that state; focus now moves to the dialog's title.

woksin and others added 6 commits August 7, 2026 13:58
Initial focus only exists in a DOM: which element ends up under the user's
keyboard cannot be observed from server-rendered markup. The specs that
cover it therefore render into jsdom with the real PrimeReact components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRf73wBo2QQQ2FRoVLqgJu
Dialog focused its confirm button on open with no way to say otherwise. That
also arms it: a browser fires click from the keydown of Enter, so a key still
held from the control that opened the dialog, or the ordinary habit of
pressing Enter twice, confirms it immediately. A dialog that collects input
is protected by isValid; a dialog that needs no input is not, which is
backwards when the action is irreversible.

The only escape was replacing the footer with a custom ReactNode, which also
removes the close (X), stops Escape closing the dialog and leaves
onConfirm/onCancel/onClose uncalled — so it was never a workaround.

initialFocus changes focus and nothing else. Confirm stays the default so no
existing dialog changes; Cancel focuses the dismissing button; Content
focuses the dialog's own title so nothing is armed. Cancel degrades to
Content where there is no dismissing button, because a modal that leaves
focus on document.body strands keyboard and screen-reader users outside the
content that just interrupted them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRf73wBo2QQQ2FRoVLqgJu
A command whose form has required fields is protected from a held Enter for
free, because isCommandFormValid keeps confirm disabled until something is
filled in. A command that takes no input has no such gate, so its confirm
button is armed the instant the dialog appears — and those are exactly the
delete-this-permanently commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRf73wBo2QQQ2FRoVLqgJu
A busy indicator has no buttons, so there was nothing for the browser to
focus and focus stayed on document.body — outside the modal that blocks it,
which leaves a keyboard or screen-reader user with nothing to work with and
nothing announced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRf73wBo2QQQ2FRoVLqgJu
Rendered into jsdom with the real PrimeReact Dialog and Button, because the
thing under test is which element ends up under the user's keyboard.

Covers the default being unchanged for every button set, the new prop for
each target and each degradation, the keyboard path from the incident that
prompted this — an Enter repeating onto a dialog that just mounted must not
confirm it — CommandDialog forwarding the choice while still running its
command, and the three behaviors a custom footer takes away that initialFocus
keeps: the close (X), Escape, and the confirm callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRf73wBo2QQQ2FRoVLqgJu
Also spells out what a custom buttons node costs, since the docs previously
presented it as a plain styling choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRf73wBo2QQQ2FRoVLqgJu
@woksin woksin added the minor label Aug 8, 2026
@woksin

woksin commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context, kept out of the body so it stays out of the release notes.

The trigger was a one-keystroke destruction path found in a consuming application: a native <button> fires click on keydown, and autoFocus put a freshly mounted dialog's OK under the same held key, collapsing a two-step confirmation into one act. The asymmetry is what makes it a framework concern rather than an application mistake — a dialog with a mandatory input is protected for free by its validity gate, while a destructive dialog that asks for nothing is armed. The framework protected the dialog that asks for something and armed the one that asks for nothing.

Replacing the footer was not a usable workaround: typeof buttons === 'number' also flips closable, no-ops onHide, severs CommandDialog's handleConfirm so the command never executes, and — a fourth behavior the report missed — kills Escape, since PrimeReact derives isCloseOnEscape from closable. That coupling is left in place deliberately: a blanket fix would put a close button on BusyIndicatorDialog, which must not be dismissible. It is documented and pinned with characterization specs, and needs its own design pass.

initialFocus is an enum rather than a boolean because false cannot say where focus goes, and the implicit answer is document.body — a modal with focus outside itself. Content focuses the dialog title with tabIndex={-1}, the WAI-ARIA APG pattern; the title is PrimeReact's aria-labelledby target, so screen readers announce what the dialog is.

The default is unchanged and byte-identical, argued rather than assumed: the framework cannot know which dialogs are destructive.

Verification: 30 new tests (suite 188 → 218), 14 mutations, all red. Specs render into jsdom with the real PrimeReact Dialog. yarn ci, npx tsc -b and build-storybook all exit 0. Honest gap: jsdom does not synthesize click from Enter keydown, so the harness does it explicitly — the invariant is right but one step removed from the browser's own key handling.

Also flagged, not fixed: ConfirmationDialog cannot express the choice (its request type and hook use positional args, an Arc-side API decision), and a single-step StepperCommandDialog still arms submit on mount.

@woksin
woksin merged commit c685c29 into main Aug 10, 2026
7 checks passed
@woksin
woksin deleted the feat/imp-25-dialog-initial-focus branch August 10, 2026 11:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant