-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: Automatically render popovers as dialogs #7813
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
Conversation
focusSafely(ref.current); | ||
} | ||
}, [isDialog, ref]); | ||
|
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.
need to decide whether to do this in the hooks or keep the independent.
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.
for now i think keep independent and see how it goes in RAC, we can push it down if it goes well
or would you prefer to just get it over with?
@@ -877,7 +877,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' | |||
let {getByRole, getAllByRole} = (renderers.subdialogs!)(); | |||
let menu = getByRole('menu'); | |||
let options = within(menu).getAllByRole('menuitem'); | |||
expect(options[1]).toHaveAttribute('aria-haspopup', 'dialog'); | |||
expect(options[1]).toHaveAttribute('aria-haspopup', 'menu'); |
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.
now we don't know if the submenu will be a dialog or not, or rather, they are all dialogs? I didn't notice any announcement differences in real screen readers
@@ -253,7 +259,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm | |||
// We will manually coerce focus back to the triggers for mobile screen readers and non virtual focus use cases (aka submenus outside of autocomplete) so turn off | |||
// FocusScope then. For virtual focus use cases (Autocomplete subdialogs/menu) and subdialogs we want to keep FocusScope restoreFocus to automatically | |||
// send focus to parent subdialog input fields and/or tab containment | |||
disableFocusManagement: !shouldUseVirtualFocus && (getInteractionModality() === 'virtual' || type === 'menu'), | |||
disableFocusManagement: !shouldUseVirtualFocus && (getInteractionModality() === 'virtual'), |
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.
did some digging, looks like this is the culprit for the submenus automatically closing in Android Talkback now. The reason for this is that disableFocusManagement
is true
for submenus in Talkback, resulting in
react-spectrum/packages/@react-aria/overlays/src/useOverlay.ts
Lines 136 to 138 in 5334df7
if (!e.relatedTarget || isElementInChildOfActiveScope(e.relatedTarget)) { | |
return; | |
} |
isElementInChildOfActiveScope
returns false due to the submenu's Popover not rendering a FocusScope and thus not being added as being part of the FocusScope tree.
Previously this wasn't a problem because opening a submenu would either move focus to the first submenu item (aka if opened via keyboard or screenreader) or retain focus in the parent menu (aka on mouse hover) but now we moving focus to the popover on mount via
react-spectrum/packages/react-aria-components/src/Popover.tsx
Lines 185 to 190 in fedc6b3
// Focus the popover itself on mount, unless a child element is already focused. | |
useEffect(() => { | |
if (isDialog && ref.current && !ref.current.contains(document.activeElement)) { | |
focusSafely(ref.current); | |
} | |
}, [isDialog, ref]); |
It seems like setting disableFocusManagement
to false
at all times seems to work better, but causes the submenu's Popover to be focused instead of the first submenu item when opened via mobile screenreader which might be odd? This seems to be due to the focus moving to the popover on mount as mentioned above
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.
Some details from additional testing:
Behavior on main:
- on Android talkback, dismissing a submenu via the hidden dismiss button moves focus back to the parent menu, NOT the submenu trigger item. If 2 submenus are open and the outermost submenu is closed, focus is moved to what seems to be something wrapping both menus. Not sure when this behavior changed, digging.
- on iOS Voiceover, closing a submenu with the hidden dismiss button moves focus a bit unpredictably. If only one submenu is opened and subsequently closed, focus is moved to the "Google" item. If 2 submenus are open and the outermost submenu is closed, focus is moved to the parent menu's dismiss button.
Behavior on this branch with disableFocusManagement
removed from useSubmenuTrigger
:
- on Android talkback, closing a submenu now properly moves focus back to the original submenu trigger, regardless of the number of levels open at a single time.
- on iOS Voiceover, issues mentioned above on main still persist. In addition, if 2 submenus are open and the outermost submenu is closed, both submenus are closed.
Note that keyboard/hover focus seems to be fine in both
EDIT: more digging, seems like focus restoration to the original submenu triggers upon submenu dismiss via the hidden dismiss button has been broken on previous builds of main since even before the subdialog PR went in, at least for RAC submenus. The RSP submenus on iPad VO have also been broken, with both levels of submenus closing upon hitting the dismiss button. However, RSP submenu dismissing works just fine if we go all the back to the original inception of disableFocusManagement
in useSubmenuTrigger...
Additionally, the dismiss buttons seem to work fine in RAC submenus in their original introduction on Talkback but not in iOS Voiceover. As per RSP Component Milestones (view), something changed in #7352 that caused Talkback to no longer restore focus properly to the original submenu trigger, most likely something with the FocusScope changes
…ialog # Conflicts: # packages/react-aria-components/src/Menu.tsx # packages/react-aria-components/stories/Autocomplete.stories.tsx # packages/react-aria-components/test/Autocomplete.test.tsx
Dialog is no longer needed
focusSafely(ref.current); | ||
} | ||
}, [isDialog, ref]); | ||
|
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.
for now i think keep independent and see how it goes in RAC, we can push it down if it goes well
or would you prefer to just get it over with?
packages/react-aria-components/test/AriaAutocomplete.test-util.tsx
Outdated
Show resolved
Hide resolved
## API Changes
react-aria-components/react-aria-components:UNSTABLE_SubDialogTrigger-UNSTABLE_SubDialogTrigger {
- children: Array<ReactElement>
- delay?: number = 200
-} /react-aria-components:Popover Popover {
UNSTABLE_portalContainer?: Element = document.body
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
arrowBoundaryOffset?: number = 0
boundaryElement?: Element = document.body
children?: ReactNode | ((PopoverRenderProps & {
defaultChildren: ReactNode | undefined
className?: string | ((PopoverRenderProps & {
defaultClassName: string | undefined
})) => string
containerPadding?: number = 12
crossOffset?: number = 0
defaultOpen?: boolean
isEntering?: boolean
isExiting?: boolean
isKeyboardDismissDisabled?: boolean = false
isNonModal?: boolean
isOpen?: boolean
maxHeight?: number
offset?: number = 8
onOpenChange?: (boolean) => void
placement?: Placement = 'bottom'
scrollRef?: RefObject<Element | null> = overlayRef
shouldCloseOnInteractOutside?: (Element) => boolean
shouldFlip?: boolean = true
shouldUpdatePosition?: boolean = true
slot?: string | null
style?: CSSProperties | ((PopoverRenderProps & {
defaultStyle: CSSProperties
})) => CSSProperties | undefined
trigger?: string
triggerRef?: RefObject<Element | null>
} /react-aria-components:PopoverProps PopoverProps {
UNSTABLE_portalContainer?: Element = document.body
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
arrowBoundaryOffset?: number = 0
boundaryElement?: Element = document.body
children?: ReactNode | ((PopoverRenderProps & {
defaultChildren: ReactNode | undefined
className?: string | ((PopoverRenderProps & {
defaultClassName: string | undefined
})) => string
containerPadding?: number = 12
crossOffset?: number = 0
defaultOpen?: boolean
isEntering?: boolean
isExiting?: boolean
isKeyboardDismissDisabled?: boolean = false
isNonModal?: boolean
isOpen?: boolean
maxHeight?: number
offset?: number = 8
onOpenChange?: (boolean) => void
placement?: Placement = 'bottom'
scrollRef?: RefObject<Element | null> = overlayRef
shouldCloseOnInteractOutside?: (Element) => boolean
shouldFlip?: boolean = true
shouldUpdatePosition?: boolean = true
slot?: string | null
style?: CSSProperties | ((PopoverRenderProps & {
defaultStyle: CSSProperties
})) => CSSProperties | undefined
trigger?: string
triggerRef?: RefObject<Element | null>
} @react-aria/dnd/@react-aria/dnd:ClipboardProps ClipboardProps {
getItems?: ({
- action: 'cut' | 'copy'
+ type: 'cut' | 'copy'
}) => Array<DragItem>
isDisabled?: boolean
onCopy?: () => void
onCut?: () => void
} @react-aria/overlays/@react-aria/overlays:Overlay Overlay {
children: ReactNode
disableFocusManagement?: boolean
isExiting?: boolean
portalContainer?: Element = document.body
+ shouldContainFocus?: boolean
} /@react-aria/overlays:OverlayProps OverlayProps {
children: ReactNode
disableFocusManagement?: boolean
isExiting?: boolean
portalContainer?: Element = document.body
+ shouldContainFocus?: boolean
} @react-spectrum/s2/@react-spectrum/s2:PopoverProps PopoverProps {
UNSAFE_className?: string
UNSAFE_style?: CSSProperties
UNSTABLE_portalContainer?: Element = document.body
+ aria-describedby?: string
+ aria-details?: string
+ aria-label?: string
+ aria-labelledby?: string
boundaryElement?: Element = document.body
children?: ReactNode | ((PopoverRenderProps & {
defaultChildren: ReactNode | undefined
})) => ReactNode
defaultClassName: string | undefined
})) => string
containerPadding?: number = 12
crossOffset?: number = 0
defaultOpen?: boolean
hideArrow?: boolean = false
isEntering?: boolean
isExiting?: boolean
isOpen?: boolean
maxHeight?: number
offset?: number = 8
onOpenChange?: (boolean) => void
placement?: Placement = 'bottom'
scrollRef?: RefObject<Element | null> = overlayRef
shouldFlip?: boolean = true
size?: 'S' | 'M' | 'L'
slot?: string | null
style?: CSSProperties | ((PopoverRenderProps & {
defaultStyle: CSSProperties
})) => CSSProperties | undefined
styles?: StyleString
trigger?: string
triggerRef?: RefObject<Element | null>
} |
Developers have found it confusing when they need to render a
<Dialog>
inside a<Popover>
, and when they don't. This is becoming even more complicated with Autocomplete, which enables patterns like searchable menus, submenus, and selects. This PR makes RAC popovers automatically render withrole=dialog
when a<Dialog>
is not rendered inside them.Behavior changes
SubDialogTrigger
and merges it intoSubmenuTrigger
.