Repository navigation
fix(Dialog): hide the focus ring on pointer-initiated focus moves in WebKit - #7313
Conversation
…WebKit
Dialog's initial focus and the focus it returns on close now follow the
last input (interaction-modality INV1). After a pointer press both moves
use focus({focusVisible: false}); after a key press they keep a plain
focus(). When the browser already drew a ring on the target (WebKit's
showModal() and native close restoration), Dialog clears that focus first,
because focusing the focused element is a no-op. That costs the target one
extra blur and focus. Text-entry targets keep a plain focus(): they paint
their own focus treatment, and an extra blur there could run validation.
Adds a Chromium and WebKit browser spec with a test-only story, jsdom
routing and focus-callback count tests, and links the Dialog record to the
interaction-modality architecture.
Refs facebook#7306
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report📚 Storybook Preview🧪 Sandbox PreviewNo new or modified components detected. Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Visual Regression25 of 448 shot(s) changed. A change here is a question, not a failure: check whether the after is the
and 5 more. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
There was a problem hiding this comment.
Thanks for the careful write-up and the before/after browser proof. This reads as a clean restoration of the interaction-modality rule (INV1): Dialog's initial focus and returned focus now follow the last input, keyboard rings are preserved, text fields keep their own focus treatment, and there is no public API change. I verified the AFTER behaviour in Chromium against the new Storybook fixture (pointer open/close show no ring, a keyboard open keeps the ring, and a keyboard open followed by a mouse close returns focus without a ring) and reran the Dialog unit tests plus the knowledge and changeset validators locally — all passing.
Two things still to keep visible: the Dialog.spec.md record edit needs the repository's exact-head spec-owner approval before merge (that gate is currently pending), and real Safari/iOS Safari remains unverified (Playwright WebKit only), so the manual Mac/iPhone spot-check you asked a maintainer for is still valuable. The Safari gap does not block my recommendation for this engine-level fix. Non-text inputs such as checkbox and radio also keep today's plain focus under the text-entry exemption, which is unchanged behaviour and could be narrowed later.
[Automated review]
|
/approve-spec 147c749 |
…ter a pointer pick (#7382) * fix(DropdownMenu): a trigger the caller renders gets no focus ring after a pointer pick in WebKit After a pointer dismissal the menu returned focus with a plain `trigger.focus({preventScroll: true})`, and WebKit matches :focus-visible on that programmatic focus after a touch. The design system's Button hid the ring with the focus-return helper's style, which cannot reach a control the caller renders, so a renderTrigger trigger rang after every touch pick. Dialog's focusForLastInput (#7313) moves unchanged into utils/focusReturn.ts with an optional FocusOptions argument, and the menu's focus return uses it: after pointer input it asks the browser for focusVisible: false, clearing a ring WebKit already drew first; after keyboard input it focuses as before. The stale comment on the render prop's no-op onFocus now says why none is needed. Tests: unit cases for both triggers (three fail on main), focusForLastInput cases, and a cross-browser spec on the CustomTrigger story. * fix(DropdownMenu): say the ring is not WebKit-only A mouse pick leaves a caller-rendered trigger matching :focus-visible in Chromium too (the new spec's pointer case fails on a main build there), so the changeset, the spec header and two comments name both engines.
User impact
Anyone who opens or closes a modal Dialog with a mouse click or a tap in a WebKit-based browser sees the shared focus ring on the element Dialog focuses: the
[data-autofocus]target when it opens (for example AlertDialog's Cancel button) and the element it returns focus to when it closes. A ring after pointer input reads as a keyboard state the user never entered. Keyboard users should keep both rings, and they do.Problem and solution fit
focus()on open and on close, and never consults the shared interaction modality. WebKit treats that programmatic focus as focus-visible after a pointer press. On close, WebKit's owndialog.close()focus restoration has already focused the opener with a ring before Dialog'sfocus()call runs, so callingfocus()again on an element that already has focus changes nothing.The fix makes both focus moves follow the last input, which is what the interaction-modality architecture already requires. It touches only Dialog's two focus calls.
Expected behavior and authority
docs/architecture/interaction-modality.mdINV1: programmatic focus after pointer input does not show the shared focus indicator; after keyboard input it does.Dialog.spec.mdFR2 (initial focus) and FR4 (return to the external invoker) keep both focus moves and hand focus-ring appearance and modality detection to that architecture record.Smallest restoration
In
Dialog.tsxonly:useInteractionModalityTracking()).focusForLastInput, replaces the two plainfocus()calls. After keyboard input it still calls plainfocus(). After pointer input it callsfocus({focusVisible: false}). If the browser already drew a ring on that element, it first callsblur(), because focusing an element that already has focus would leave the ring in place.input,textarea,select, contenteditable) keep plainfocus()on both paths. They have their own focus treatment that shows where typing goes, and an extra blur there could run validation.There is no new prop, hook, token, or appearance change.
Dialog.spec.mdgains averified_byentry for the new browser spec, thearchitecture:interaction-modalitylink, and one relationship line saying INV1 decides both moves. This record change needs exact-head approval from the spec owner.Compatibility cost, stated plainly: when the last input was a pointer and the browser had already drawn a ring on a non-text target, that target gets one extra blur and focus pair. From the callback-count tests (counted from the open or close action):
In the Chromium measurement, a pure pointer open and close emit a single focus, because Chromium does not draw the ring there. Text fields are unchanged.
Evidence
Evidence comes from Playwright WebKit (Desktop Safari engine) and Playwright Chromium only. Real Safari and iOS Safari were not verified. AST-013 FR10 says Playwright WebKit does not prove Safari behavior, so I would be grateful if a maintainer could confirm on a Mac and an iPhone: click or tap an AlertDialog opener and check that Cancel shows no ring, then click Cancel and check that the opener shows no ring.
Dialog.tsx, 4 of 10 cases fail. In WebKit, a pointer open leaves Cancel matching:focus-visibleand a pointer close leaves the opener matching:focus-visible. In both engines, a keyboard open followed by a mouse close leaves a ring on the returned focus.blur()makes the WebKit pointer open and close proofs fail.Separate gap left untouched: WebKit does not focus a
<button>when it is clicked or tapped. Dialog therefore records the body as the opener, and focus returns to the body on close. That is a separate FR4 defect about opener capture and is not changed here. The browser fixture uses atabIndex={0}opener so the return move can be observed.Other cases left as they are today: a Dialog that is first mounted by the same pointer press that opens it has not seen that press yet, so it keeps today's plain
focus(). Checkbox, radio, and other non-textinputtypes, andselect, also keep today's plainfocus()because of the text-entry exemption above. A Dialog with neither a DialogHeader nor a[data-autofocus]target keeps the browser's ownshowModal()focus placement.Scope
Not in this PR: opener-capture ordering (#6607, #5642), the WebKit button-opener gap above, and other overlays (Popover, BottomSheet, Drawer, MobileNav).
Testing
playwright test --config playwright.config.ts packages/core/src/Dialog/__tests__(WebKit and Chromium projects), against Storybook built from this commit: 15 passed. BaseDialog.tsxwith the same spec: 6 passed, 4 failed.pnpm vitest runfor Dialog, AlertDialog, DropdownMenu, ContextMenu, CommandPalette, BottomSheet, Typeahead, TextInput, and TimeInput: 34 files, 1018 tests passed.pnpm lint:strict, core typecheck,pnpm check:knowledge,pnpm check:changesets,pnpm check:sync, andpnpm buildpass.pnpm testlocally on macOS: 22658 passed, 8 failed in 5 files that this PR does not touch and that are identical to main. Those failures come from the local environment (macOS bash 3.2 empty-array handling, BSDcpwithout--reflink, a locally built Storybook enabling the story-tree check) and one timing-ratio test that passes when run alone. This is not a green full run.Changeset:
@astryxdesign/corepatch,[fix].Writing rubric results
Scope: the lines added to Dialog.spec.md (verified_by entry, architecture:interaction-modality entry, one relationship bullet).
R1 narration: none; the bullet states in the present tense what INV1 decides and what the browser spec verifies.
R2 research: none; no survey, options or benchmark in the record.
R3 evidence: the bullet cites the fact source (INV1 and the browser spec file), not a pull request, issue or commit.
R4 rejected alternatives: none recorded.
R5 change references: none; searched for "#" plus digits, "/pull/" and "/issues/" in the added lines and found none.
R6 open questions: none added; the existing focus-containment question is untouched.
Refs #7306