Skip to content

feat: Overlay focus primitive - #5175

Draft
alpharius-ck wants to merge 5 commits into
mainfrom
overlay-focus-primitive
Draft

alpharius-ck wants to merge 5 commits into
mainfrom
overlay-focus-primitive

Conversation

@alpharius-ck

Copy link
Copy Markdown
Collaborator

Motivation

Continues #5127 (originally by @konstmar, fork branch overlay-focus-primitive), rebased onto the current main.

Most of that branch has since landed on main in #5124, #5125 and #5126:

  • reduce-motion forwarding and queued-update key matching
  • the overlay → modal rename on Portal
  • hiding content below modals from assistive technology
  • Modal rendering its own Portal
  • the accessible Modal role and name

This PR carries over only what main still lacks, with the original authorship kept:

  • Topmost-only dismissal. The new useOverlayDismiss keeps a module-level stack of open overlays. Android back and web Escape close only the topmost one. A non-dismissable modal absorbs the press instead of letting the app exit or closing the modal underneath. Modal now uses the hook in place of its inline back handler.
  • Focus handling. The new useOverlayFocus moves focus into the modal when it opens and hands it back when it closes.
    • On web, the first tabbable element in the modal gets focus.
    • On native, an accessibility focus event is sent to the modal content.
    • Focus waits for the fade to finish, because transparent content is not in the accessibility tree.
    • On close, restoring focus waits a tick so the background is no longer inert.
    • Reopening mid-close keeps the element that first opened the modal.
  • New optional props. Modal and Dialog accept initialFocusRef and restoreFocusRef, so an app can name the element to focus on open and the control to return to on close.
  • Test setup. AccessibilityInfo.sendAccessibilityEvent is mocked, and the forwardedRef prop is stripped from the Reanimated snapshot serializer. Printing it never terminates, because it walks the host instance. This removes forwardedRef from the FABExtended snapshots.

Merge conflicts were resolved in favour of main's accessibility props. The fork's TODO_REMOVE placeholders for #5125 are not carried over. Refs use React.ComponentRef<typeof View>, because View is no longer an instance type in RN 0.88.

No breaking changes: the new props are optional.

Test plan

Automated, all passing on this branch:

  • yarn typecheck
  • yarn lint
  • yarn test (56 suites, 743 tests)
  • New useOverlayDismiss and useOverlayFocus unit tests, plus new Modal tests for topmost-only dismissal, a non-dismissable modal not exiting the app on back, and focus on open and close

Manual, with the example app's Dialogs screen:

  • Web: opening a dialog focuses its first tabbable element, Escape closes it, and focus returns to the trigger
  • Web: with a dialog open, Escape closes only the topmost overlay
  • Android: back closes only the topmost modal, and a non-dismissable one does not exit the app
  • iOS and Android: with VoiceOver or TalkBack, opening a modal moves the screen reader into it, and closing it returns the reader to restoreFocusRef
  • initialFocusRef and restoreFocusRef override the defaults on Modal and Dialog

Konstantin Marushchak added 3 commits November 6, 2026 12:00
Opening a modal now moves focus to its content, and closing it returns
focus to whatever had it before. On web the first tabbable element is
focused, since only the DOM can work out what that is; on native an
accessibility focus event is sent instead.

Focus waits for the fade in either direction: the content is not in the
accessibility tree until it is opaque, and the app behind the modal only
stops being inert once the modal has gone.

Adds `initialFocusRef` and `restoreFocusRef` so an app can name the
element to focus on open and the control to return to on close.
`useOverlayFocus` now returns `restoreFocus`, so the overlay decides when
focus goes back rather than having it fire from an unmount cleanup that
only ran once the content was already gone.

Restoring waits a tick, so the close render has removed background
inertness before the previous element is focused again. Reopening
mid-close keeps the element that first opened the overlay, rather than
capturing something inside the overlay that is still on its way out.

Each focus move now reports whether it landed, through a web half that
takes a DOM node and a native half that takes a ref, so an overlay that
cannot take focus no longer leaves the hook believing focus moved.
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.
  • Screenshot or video evidence is missing. Make sure to include one if it affects the UI.

@alpharius-ck alpharius-ck changed the title Overlay focus primitive feat: Overlay focus primitive Nov 6, 2026
@Doberjohn

Copy link
Copy Markdown
Collaborator

#5127 and #5129 closed in favour of this PR. What still applies from their reviews:

  1. Escape (blocker): the keydown listener on document runs in the capture phase and calls stopImmediatePropagation(), so while an overlay is open no Escape handler in the app or the modal runs, and the defaultPrevented check never sees them. Please listen in the bubble phase without stopping propagation, as feat(portal)!: dismiss only the topmost modal on back or Escape #5127's last version did, and drop the test that presets defaultPrevented.
  2. IME: ignore Escape while event.isComposing.
  3. aria-live="polite" on Modal's wrapper may double-announce now that focus moves in (feat(modal): move focus into the modal and restore it on close #5129 removed it). Please check in your screen reader pass.
  4. Description: link feat(modal): move focus into the modal and restore it on close #5129, drop fix(modal): make Modal accessible and honour its dismissal props #5125, add "Part of Paper 6.0.0: merge order, ground rules and decisions #5150", and keep it under 1000 characters.

6.0 keeps the app-wide stack and dismissableBackButton; onDismiss and dismissable on Portal can come in a 6.x minor.

@Doberjohn Doberjohn added this to the 6.0.0 milestone Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants