Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit d30b22a

Browse files
[SG-36350] Accessibility: Global Menu: Focus isn't correctly captured when menus are opened (#36915)
* feat: keep MenuList in DOM when Menu is hidden Co-authored-by: gitstart-sourcegraph <[email protected]>
1 parent 0567e0b commit d30b22a

File tree

8 files changed

+28
-16
lines changed

8 files changed

+28
-16
lines changed

client/web/src/enterprise/insights/pages/dashboards/dashboard-page/DashboardsContentPage.test.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,14 @@ const renderDashboardsContent = (
165165

166166
const triggerDashboardMenuItem = async (screen: RenderWithBrandedContextResult & { user: UserEvent }, name: RegExp) => {
167167
const { user } = screen
168-
const dashboardMenu = await waitFor(() => screen.getByRole('button', { name: /dashboard context menu/ }))
169-
user.click(dashboardMenu)
170168

171-
const dashboardMenuItem = screen.getByRole('menuitem', { name })
169+
const dashboardMenu = await screen.findByRole('button', { name: /dashboard context menu/ })
170+
userEvent.click(dashboardMenu)
172171

173172
// We're simulating keyboard navigation here to circumvent a bug in ReachUI
174173
// does not respond to programmatic click events on menu items
175-
dashboardMenuItem.focus()
176-
user.keyboard(' ')
174+
screen.getByText(name).closest<HTMLButtonElement>('[role="menuitem"]')?.focus()
175+
user.keyboard('{enter}')
177176
}
178177

179178
beforeEach(() => {
@@ -218,7 +217,7 @@ describe('DashboardsContent', () => {
218217

219218
const { history } = screen
220219

221-
await triggerDashboardMenuItem(screen, /configure dashboard/)
220+
await triggerDashboardMenuItem(screen, /configure dashboard/i)
222221

223222
expect(history.location.pathname).toEqual('/insights/dashboards/foo/edit')
224223
})
@@ -238,18 +237,17 @@ describe('DashboardsContent', () => {
238237
it('opens delete dashboard modal', async () => {
239238
const screen = renderDashboardsContent()
240239

241-
await triggerDashboardMenuItem(screen, /Delete/)
240+
await triggerDashboardMenuItem(screen, /delete/i)
242241

243-
const addInsightHeader = await waitFor(() => screen.getByRole('heading', { name: /Delete/ }))
244-
expect(addInsightHeader).toBeInTheDocument()
242+
await waitFor(() => expect(screen.getByRole('heading', { name: /Delete/ })).toBeInTheDocument())
245243
})
246244

247245
// copies dashboard url
248246
it('copies dashboard url', async () => {
249247
const screen = renderDashboardsContent()
250248

251-
await triggerDashboardMenuItem(screen, /Copy link/)
249+
await triggerDashboardMenuItem(screen, /copy link/i)
252250

253-
sinon.assert.calledOnce(mockCopyURL)
251+
await waitFor(() => sinon.assert.calledOnce(mockCopyURL))
254252
})
255253
})

client/web/src/nav/NavBar/NavDropdown.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ export const NavDropdown: React.FunctionComponent<React.PropsWithChildren<NavDro
184184
>
185185
{mobileHomeItem.content}
186186
</MenuLink>
187-
{items.map(item => (
188-
<MenuLink as={Link} key={item.path} to={item.path}>
187+
{items.map((item, index) => (
188+
<MenuLink as={Link} key={item.path} to={item.path} index={index}>
189189
{item.content}
190190
</MenuLink>
191191
))}

client/wildcard/src/components/Menu/MenuList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ const Popover = React.forwardRef(({ popoverPosition, ...props }, reference) => (
4646
position={popoverPosition}
4747
focusLocked={false}
4848
className={classNames('py-1', props.className)}
49+
keepInDOM={true}
4950
/>
5051
)) as ForwardReferenceComponent<'div', PopoverProps>

client/wildcard/src/components/Popover/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,5 @@ enum Strategy {
226226
```
227227

228228
- **_targetPadding_** (optional) - Adds space/padding between target and popover elements
229+
230+
- **_keepInDOM_** (optional) - By default `PopoverContent` element is removed from DOM when tooltip is hidden. If it's `true`, `PopoverContent` element will be kept in DOM but is hidden with CSS rule `visibility=hidden`. This prop is useful when `PopoverContent` children need this behavior to work. Ex: `@sourcegraph/wildcard` `MenuList` component need this to fix [focus state issue](https://github.com/sourcegraph/sourcegraph/issues/36350).

client/wildcard/src/components/Popover/components/floating-panel/FloatingPanel.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface FloatingPanelProps extends Omit<Tether, 'target' | 'element'>,
2222
* outside the dom tree.
2323
*/
2424
rootRender?: HTMLElement | null
25+
26+
forceHidden?: boolean
2527
}
2628

2729
/**
@@ -45,6 +47,7 @@ export const FloatingPanel = forwardRef((props, reference) => {
4547
targetPadding,
4648
constraint,
4749
rootRender,
50+
forceHidden,
4851
...otherProps
4952
} = props
5053

@@ -72,6 +75,7 @@ export const FloatingPanel = forwardRef((props, reference) => {
7275
constrainToScrollParents,
7376
overflowToScrollParents,
7477
flipping,
78+
forceHidden,
7579
})
7680

7781
return unsubscribe
@@ -90,6 +94,7 @@ export const FloatingPanel = forwardRef((props, reference) => {
9094
constrainToScrollParents,
9195
overflowToScrollParents,
9296
flipping,
97+
forceHidden,
9398
])
9499

95100
if (strategy === Strategy.Absolute) {

client/wildcard/src/components/Popover/components/popover-content/PopoverContent.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface PopoverContentProps extends Omit<FloatingPanelProps, 'target' |
1717
isOpen?: boolean
1818
focusLocked?: boolean
1919
autoFocus?: boolean
20+
keepInDOM?: boolean
2021
}
2122

2223
export const PopoverContent = forwardRef((props, reference) => {
@@ -28,9 +29,11 @@ export const PopoverContent = forwardRef((props, reference) => {
2829
as: Component = 'div',
2930
role = 'dialog',
3031
'aria-modal': ariaModel = true,
32+
keepInDOM = false,
33+
// we should let FloatingPanel to control its `hidden` attribute
34+
hidden,
3135
...otherProps
3236
} = props
33-
3437
const { isOpen: isOpenContext, targetElement, tailElement, anchor, setOpen } = useContext(PopoverContext)
3538
const { renderRoot } = useContext(PopoverRoot)
3639

@@ -68,7 +71,7 @@ export const PopoverContent = forwardRef((props, reference) => {
6871
return () => setFocusLock(false)
6972
}, [autoFocus, focusLocked, tooltipElement])
7073

71-
if (!isOpenContext && !isOpen) {
74+
if (!keepInDOM && !isOpenContext && !isOpen) {
7275
return null
7376
}
7477

@@ -83,6 +86,7 @@ export const PopoverContent = forwardRef((props, reference) => {
8386
aria-modal={ariaModel}
8487
rootRender={renderRoot}
8588
className={classNames(styles.popover, otherProps.className)}
89+
forceHidden={keepInDOM && !isOpenContext && !isOpen}
8690
>
8791
{focusLocked ? (
8892
<FocusLock disabled={!focusLock} returnFocus={true}>

client/wildcard/src/components/Popover/tether/services/tether-render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function render(tether: Tether, eventTarget: HTMLElement | null, preserve
3636
const layout = getLayout(tether)
3737
const state = getState(layout)
3838

39-
if (state === null || !isVisible(tether.target)) {
39+
if (state === null || !isVisible(tether.target) || tether.forceHidden) {
4040
setVisibility(tether.element, false)
4141
setVisibility((tether.marker as HTMLElement) ?? null, false)
4242

client/wildcard/src/components/Popover/tether/services/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface Tether {
5555

5656
overflowToScrollParents?: boolean
5757
constrainToScrollParents?: boolean
58+
59+
forceHidden?: boolean
5860
}
5961

6062
export type MarkerElement = HTMLElement | SVGElement

0 commit comments

Comments
 (0)