Skip to content

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

Merged
merged 7 commits into from
Feb 28, 2025
Merged

Conversation

devongovett
Copy link
Member

@devongovett devongovett commented Feb 21, 2025

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 with role=dialog when a <Dialog> is not rendered inside them.

Behavior changes

  • This always renders (modal) popovers as dialogs, so all menus, select list boxes, etc. are wrapped in a dialog. We need to test how this behaves with screen readers to ensure the announcements aren't too much more verbose or confusing.
  • Selects and Menus now contain focus. This means you can no longer tab out of them. That matches macOS native behavior.
  • Opening a submenu with the right arrow key will autofocus the first menu item within the dialog (if any) the same way it does with regular submenus. Is this a problem?
  • This removes SubDialogTrigger and merges it into SubmenuTrigger.

focusSafely(ref.current);
}
}, [isDialog, ref]);

Copy link
Member Author

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.

Copy link
Member

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?

@rspbot
Copy link

rspbot commented Feb 21, 2025

@@ -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');
Copy link
Member Author

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'),
Copy link
Member

@LFDanLu LFDanLu Feb 26, 2025

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

if (!e.relatedTarget || isElementInChildOfActiveScope(e.relatedTarget)) {
return;
}
no longer early returning and thus being detected as a "interact outside" because 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

// 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

Copy link
Member

@LFDanLu LFDanLu Feb 26, 2025

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
@rspbot
Copy link

rspbot commented Feb 27, 2025

focusSafely(ref.current);
}
}, [isDialog, ref]);

Copy link
Member

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?

@rspbot
Copy link

rspbot commented Feb 27, 2025

@rspbot
Copy link

rspbot commented Feb 28, 2025

@rspbot
Copy link

rspbot commented Feb 28, 2025

## 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>
 }

@devongovett devongovett added this pull request to the merge queue Feb 28, 2025
Merged via the queue into main with commit e3b217a Feb 28, 2025
30 checks passed
@devongovett devongovett deleted the popover-dialog branch February 28, 2025 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants