Repository navigation
fix(core): one focus return for Popover, Dialog and DropdownMenu that never strands focus (AST-116 FR17–FR21) #11121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: nynexman4464/feat/popover-rect-anchor
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [fix] Popover, Dialog and DropdownMenu return focus through one rule when they close, and focus no longer falls to the page when the control that opened them is gone. Focus goes to the opener; if the opener is gone, it goes to its replacement (a trigger that remounted, or the control that took its place in the same group), then the anchor, then the nearest surviving ancestor, then the layer that opened this one, then the page's `main` region. A Dialog opened from a menu row returns focus to the menu's trigger. Focus that you or the person already moved outside the closing layer stays where it is. There is no option to turn focus return off; to send focus somewhere specific, focus it in `onOpenChange` or `onHide`. Focus returns before the browser's own restore, so the trigger gets a focus ring only after keyboard input. | ||
|
|
||
| @nynexman4464 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -143,6 +143,11 @@ export const docs = { | |
| description: | ||
| 'Dialog displays a modal overlay that blocks interaction with the page until the user responds. Use it for delete confirmations, edit forms, terms acceptance, or any decision that should not be skipped.\n\nFor cases where you want to show a dialog without managing open state, use the `useImperativeDialog` hook: call `dialog.show(content)` and render `dialog.element` in your tree.', | ||
| bestPractices: [ | ||
| { | ||
| guidance: false, | ||
| description: | ||
| "Don't add your own focus return or try to turn it off. When the dialog closes, focus goes back to the opener, or to the nearest control that stands in for it (a dialog opened from a menu row returns focus to the menu's trigger), never to the page. To send focus somewhere specific, focus that element in onOpenChange.", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dialog calls |
||
| }, | ||
| { | ||
| guidance: true, | ||
| description: | ||
|
|
@@ -214,6 +219,11 @@ export const docsZh = { | |
| description: | ||
| 'Dialog displays a modal overlay that blocks interaction with the page until the user responds. Use it for delete confirmations, edit forms, terms acceptance, or any decision that should not be skipped.', | ||
| bestPractices: [ | ||
| { | ||
| guidance: false, | ||
| description: | ||
| '不要自行实现或关闭焦点返回。对话框关闭时,焦点会回到打开它的元素,或回到最近的替代控件(从菜单项打开的对话框会把焦点还给菜单的触发器),绝不会落到页面上。若要把焦点移到特定位置,请在 onOpenChange 中聚焦该元素。', | ||
| }, | ||
| { | ||
| guidance: true, | ||
| description: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,7 +47,7 @@ import React, { | |
| } from 'react'; | ||
| import {readAccessibleName} from '../utils/accessibleName'; | ||
| import * as stylex from '@stylexjs/stylex'; | ||
| import {usePopover} from '../Popover/usePopover'; | ||
| import {usePopoverInternal} from '../Popover/usePopover'; | ||
| import {holdEntranceInput} from '../Layer/holdEntranceInput'; | ||
| import { | ||
| claimPress, | ||
|
|
@@ -1033,27 +1033,21 @@ function DropdownMenuPopover({ | |
| if (!isControlled) { | ||
| setInternalIsOpen(false); | ||
| } | ||
| const trigger = buttonRef.current; | ||
| if (!trigger) { | ||
| return; | ||
| } | ||
| const active = document.activeElement; | ||
| const focusLandedElsewhere = | ||
| active instanceof HTMLElement && | ||
| active !== document.body && | ||
| active !== trigger && | ||
| !contentRef.current?.contains(active); | ||
| if (focusLandedElsewhere) { | ||
| return; | ||
| } | ||
| prepareFocusReturn(); | ||
| // After pointer input the trigger takes focus without the focus-visible | ||
| // ring (`component:DropdownMenu/DEC-1`). The style the helper above adds | ||
| // reaches only the design system's own Button; the browser's focus option | ||
| // also reaches a control the caller renders, which the browser would | ||
| // otherwise ring (Chromium after a mouse pick, WebKit after a touch). | ||
| returnFocusForLastInput(trigger); | ||
| }, [hideOpenFlyouts, isControlled, onOpenChange, prepareFocusReturn]); | ||
| }, [hideOpenFlyouts, isControlled, onOpenChange]); | ||
|
|
||
| // How returned focus moves. After pointer input the trigger takes focus | ||
| // without a ring. The helper's style reaches only the design system's own | ||
| // Button. The browser's focus option also reaches a control the caller | ||
| // renders, which Chromium rings after a mouse pick and WebKit after a touch. | ||
| const restoreFocusTo = useCallback( | ||
| (element: HTMLElement) => { | ||
| if (element === buttonRef.current) { | ||
| prepareFocusReturn(); | ||
| } | ||
| returnFocusForLastInput(element); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Miss: this move adds no temporary tab stop ( |
||
| }, | ||
| [prepareFocusReturn], | ||
| ); | ||
|
|
||
| // Defer item focus until the layer has committed open, so focus restore | ||
| // captures the trigger instead of the first menu item. | ||
|
|
@@ -1108,7 +1102,8 @@ function DropdownMenuPopover({ | |
| ? button.label | ||
| : undefined; | ||
|
|
||
| const popover = usePopover({ | ||
| const popover = usePopoverInternal({ | ||
| restoreFocusTo, | ||
| onHide: handleLayerHide, | ||
| onShow: handleLayerShow, | ||
| hasLightDismiss: true, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"A Dialog opened from a menu row returns focus to the menu's trigger" already holds on the base branch: keyboard and mouse picks in Chromium and a mouse pick in WebKit end on the trigger, and the new menu-row test passes there too. A manual popover also returns to its trigger on the base. The "one rule" does not reach
presentation="bottom-sheet", which keeps BottomSheet's own return. Suggest: "In their popover presentation, focus no longer falls to the page when the opener remounted, was removed, or the layer was opened by code", without the menu-row sentence.