Skip to content

fix(Dialog): hide the focus ring on pointer-initiated focus moves in WebKit - #7313

Merged
cixzhang merged 1 commit into
facebook:mainfrom
harjothkhara:codex/astryx-7306-2026-10-10
Oct 11, 2026
Merged

cixzhang merged 1 commit into
facebook:mainfrom
harjothkhara:codex/astryx-7306-2026-10-10

Conversation

@harjothkhara

Copy link
Copy Markdown
Contributor

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

  1. Why does the ring appear? Dialog moves focus programmatically with a plain 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 own dialog.close() focus restoration has already focused the opener with a ring before Dialog's focus() call runs, so calling focus() again on an element that already has focus changes nothing.
  2. Why does it harm the task? The ring tells the user that keyboard focus is now the way to continue, right after they used a pointer.
  3. Why does that matter? Every modal in an app goes through Dialog, so the wrong signal shows on every pointer open and close in WebKit.

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.md INV1: programmatic focus after pointer input does not show the shared focus indicator; after keyboard input it does.
  • Dialog.spec.md FR2 (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.
  • Precedent: DropdownMenu returns focus to its trigger without a ring after a pointer dismissal (fix(DropdownMenu): return focus to the trigger after a pointer dismissal, without a ring #6825).

Smallest restoration

In Dialog.tsx only:

  • Dialog turns on the shared modality tracking (useInteractionModalityTracking()).
  • A small local helper, focusForLastInput, replaces the two plain focus() calls. After keyboard input it still calls plain focus(). After pointer input it calls focus({focusVisible: false}). If the browser already drew a ring on that element, it first calls blur(), because focusing an element that already has focus would leave the ring in place.
  • Text-entry targets (input, textarea, select, contenteditable) keep plain focus() 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.md gains a verified_by entry for the new browser spec, the architecture:interaction-modality link, 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):

Case Pointer Keyboard
button autofocus target, open focus 2, blur 1 (one extra pair) focus 1, blur 0
text-input autofocus target, open focus 1, blur 0 focus 1, blur 0
button invoker, close focus 2, blur 1 (one extra pair) focus 1, blur 0
tabindex=0 invoker, close focus 2, blur 1 (one extra pair) focus 1, blur 0
text-input invoker, close focus 1, blur 0 n/a

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.

  • Reproduction before the change: with the new browser spec run against the base Dialog.tsx, 4 of 10 cases fail. In WebKit, a pointer open leaves Cancel matching :focus-visible and 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.
  • Result after the change: all 10 cases pass (5 in WebKit, 5 in Chromium), and the existing Dialog Chromium contract spec still passes (15 Playwright tests in total).
  • Representative unchanged path: a keyboard open followed by an Escape close keeps both rings in both engines. A pointer-opened form Dialog whose autofocus target is a text field still focuses the field, its wrapper keeps its focus treatment, and typing works.
  • Regression test or other mutation-sensitive proof: three jsdom routing tests (pointer vs keyboard focus options, blur before refocus) and nine focus/blur callback-count tests. Two of the routing tests fail on base, the three button/tabindex pointer count cases fail on base, and the text pointer count cases fail if the text-entry exemption is removed. Removing the 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 a tabIndex={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-text input types, and select, also keep today's plain focus() because of the text-entry exemption above. A Dialog with neither a DialogHeader nor a [data-autofocus] target keeps the browser's own showModal() focus placement.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

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. Base Dialog.tsx with the same spec: 6 passed, 4 failed.
  • pnpm vitest run for 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, and pnpm build pass.
  • Full pnpm test locally 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, BSD cp without --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/core patch, [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

…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
@vercel

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 10, 2026 7:13pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 10, 2026
@astracat-bot

astracat-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 10, 2026, 7:45 PM UTC

@github-actions github-actions Bot added community Authored by a community contributor (not on the eng/design team) needs:code-review High-risk change (new package/component/API) — needs human code review before merge needs:spec-owner-review Current knowledge records await owner approval labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

25 of 448 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 155,733
Chat Full AI Chat probe dark 155,005
Chat Default neutral dark 33,205
Chat Default neutral light 32,876
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,229
Chat With Attachments probe dark 11,065
TableRowExpansion Detail Panel neutral dark 5,129
TableRowExpansion Detail Panel neutral light 4,050
SideNav Resizable in AppShell probe light 3,208
TableGroupedRows Default neutral light 2,133
TableGroupedRows Default neutral dark 2,116
Markdown Default probe dark 897
Markdown Default probe light 897
Markdown Default neutral dark 882

and 5 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 147c749

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 11, 2026
@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Oct 11, 2026
@cixzhang
cixzhang merged commit 0818c42 into facebook:main Oct 11, 2026
42 checks passed
cixzhang pushed a commit that referenced this pull request Oct 13, 2026
…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.

This branch was successfully deployed

1 active deployment
Preview — 147c7492 Deployed Oct 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. community Authored by a community contributor (not on the eng/design team)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants