Skip to content

Commit acb9b1a

Browse files
authored
fix(dropdown-menu): Remove custom link behavior (#90713)
Currently we have custom logic in the DropdownMenu component which handles link items. The react-aria library should already be handling this, but we configured things a bit differently than react-aria expected which prevented it. This PR removes the custom behavior and provides a `href` for react-aria to use. I'm hoping that this change fixes some bugs I've seen on mobile devices. On iOS especially, taps on dropdown link items have no effect. Summary of changes: - Add `href` to the dropdown item state if `to` or `externalHref` is passed - Remove custom logic handling keyboard selection of a link item - Moves the dropdown item props from the container `li` element to the "inner item" (which is an anchor tag in the case of links) - Fixes tests that looked for `link` roles instead of `menuitemradio`
1 parent 065fe65 commit acb9b1a

File tree

14 files changed

+160
-171
lines changed

14 files changed

+160
-171
lines changed

static/app/components/core/menuListItem/index.chonk.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ export const ChonkInnerWrap = chonkStyled('div', {
7171
font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
7272
7373
&,
74-
&:hover {
74+
&:hover,
75+
&:focus,
76+
&:focus-visible {
7577
color: ${getTextColor};
78+
box-shadow: none;
79+
outline: none;
7680
}
7781
${p => p.disabled && `cursor: default;`}
7882

static/app/components/core/menuListItem/index.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {memo, useId, useRef, useState} from 'react';
22
import {createPortal} from 'react-dom';
33
import {usePopper} from 'react-popper';
44
import isPropValid from '@emotion/is-prop-valid';
5-
import {type Theme, useTheme} from '@emotion/react';
5+
import {css, type Theme, useTheme} from '@emotion/react';
66
import styled from '@emotion/styled';
77
import {mergeRefs} from '@react-aria/utils';
88

@@ -123,7 +123,6 @@ function BaseMenuListItem({
123123

124124
return (
125125
<MenuItemWrap
126-
role="menuitem"
127126
aria-disabled={disabled}
128127
aria-labelledby={labelId}
129128
aria-describedby={detailId}
@@ -313,8 +312,12 @@ export const InnerWrap = withChonk(
313312
font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
314313
315314
&,
316-
&:hover {
315+
&:hover,
316+
&:focus,
317+
&:focus-visible {
317318
color: ${getTextColor};
319+
box-shadow: none;
320+
outline: none;
318321
}
319322
${p => p.disabled && `cursor: default;`}
320323
@@ -330,13 +333,13 @@ export const InnerWrap = withChonk(
330333
331334
${p =>
332335
p.isFocused &&
333-
`
334-
z-index: 1;
335-
/* Background to hide the previous item's divider */
336-
::before {
337-
background: ${p.theme.backgroundElevated};
338-
}
339-
`}
336+
css`
337+
z-index: 1;
338+
/* Background to hide the previous item's divider */
339+
::before {
340+
background: ${p.theme.backgroundElevated};
341+
}
342+
`}
340343
`,
341344
ChonkInnerWrap
342345
);

static/app/components/dropdownMenu/index.spec.tsx

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {Fragment} from 'react';
2-
import {RouterFixture} from 'sentry-fixture/routerFixture';
32

43
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
54

@@ -25,10 +24,7 @@ describe('DropdownMenu', function () {
2524
},
2625
]}
2726
triggerLabel="This is a Menu"
28-
/>,
29-
{
30-
deprecatedRouterMocks: true,
31-
}
27+
/>
3228
);
3329

3430
// Open the mneu
@@ -71,10 +67,7 @@ describe('DropdownMenu', function () {
7167
},
7268
]}
7369
triggerLabel="Menu"
74-
/>,
75-
{
76-
deprecatedRouterMocks: true,
77-
}
70+
/>
7871
);
7972

8073
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
@@ -95,10 +88,7 @@ describe('DropdownMenu', function () {
9588
<Fragment>
9689
<DropdownMenu items={[{key: 'item1', label: 'Item One'}]} triggerLabel="Menu A" />
9790
<DropdownMenu items={[{key: 'item2', label: 'Item Two'}]} triggerLabel="Menu B" />
98-
</Fragment>,
99-
{
100-
deprecatedRouterMocks: true,
101-
}
91+
</Fragment>
10292
);
10393

10494
// Can be dismissed by clicking outside
@@ -164,10 +154,7 @@ describe('DropdownMenu', function () {
164154
},
165155
]}
166156
triggerLabel="Menu"
167-
/>,
168-
{
169-
deprecatedRouterMocks: true,
170-
}
157+
/>
171158
);
172159

173160
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
@@ -254,10 +241,7 @@ describe('DropdownMenu', function () {
254241
},
255242
]}
256243
triggerLabel="Menu"
257-
/>,
258-
{
259-
deprecatedRouterMocks: true,
260-
}
244+
/>
261245
);
262246

263247
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
@@ -271,10 +255,7 @@ describe('DropdownMenu', function () {
271255
<DropdownMenu
272256
items={[{key: 'item1', label: 'Item One', to: '/test'}]}
273257
triggerLabel="Menu"
274-
/>,
275-
{
276-
deprecatedRouterMocks: true,
277-
}
258+
/>
278259
);
279260

280261
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
@@ -285,54 +266,48 @@ describe('DropdownMenu', function () {
285266
});
286267

287268
it('closes after clicking external link', async function () {
269+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
270+
288271
render(
289272
<DropdownMenu
290273
items={[{key: 'item1', label: 'Item One', externalHref: 'https://example.com'}]}
291274
triggerLabel="Menu"
292-
/>,
293-
{
294-
deprecatedRouterMocks: true,
295-
}
275+
/>
296276
);
297277

298278
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
299279
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Item One'}));
300280
await waitFor(() => {
301281
expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument();
302282
});
283+
284+
// JSDOM throws an error on navigation to random urls
285+
expect(errorSpy).toHaveBeenCalledTimes(1);
303286
});
304287

305288
it('navigates to link on enter', async function () {
306289
const onAction = jest.fn();
307-
const router = RouterFixture();
308-
render(
290+
const {router} = render(
309291
<DropdownMenu
310292
items={[
311293
{key: 'item1', label: 'Item One', to: '/test'},
312294
{key: 'item2', label: 'Item Two', to: '/test2', onAction},
313295
]}
314296
triggerLabel="Menu"
315-
/>,
316-
{
317-
router,
318-
deprecatedRouterMocks: true,
319-
}
297+
/>
320298
);
321299

322300
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
323301
await userEvent.keyboard('{ArrowDown}');
324302
await userEvent.keyboard('{Enter}');
325303
await waitFor(() => {
326-
expect(router.push).toHaveBeenCalledWith(
327-
expect.objectContaining({pathname: '/test2'})
328-
);
304+
expect(router.location.pathname).toBe('/test2');
329305
});
330306
expect(onAction).toHaveBeenCalledTimes(1);
331307
});
332308

333309
it('navigates to link on meta key', async function () {
334310
const onAction = jest.fn();
335-
const router = RouterFixture();
336311
const user = userEvent.setup();
337312

338313
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@@ -344,11 +319,7 @@ describe('DropdownMenu', function () {
344319
{key: 'item2', label: 'Item Two', to: '/test2', onAction},
345320
]}
346321
triggerLabel="Menu"
347-
/>,
348-
{
349-
router,
350-
deprecatedRouterMocks: true,
351-
}
322+
/>
352323
);
353324

354325
await user.click(screen.getByRole('button', {name: 'Menu'}));
@@ -366,9 +337,10 @@ describe('DropdownMenu', function () {
366337

367338
it('navigates to external link enter', async function () {
368339
const onAction = jest.fn();
369-
const router = RouterFixture();
370340
const user = userEvent.setup();
371341

342+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
343+
372344
render(
373345
<DropdownMenu
374346
items={[
@@ -381,17 +353,15 @@ describe('DropdownMenu', function () {
381353
},
382354
]}
383355
triggerLabel="Menu"
384-
/>,
385-
{
386-
router,
387-
deprecatedRouterMocks: true,
388-
}
356+
/>
389357
);
390358

391359
await user.click(screen.getByRole('button', {name: 'Menu'}));
392360
await user.keyboard('{ArrowDown}');
393361
await user.keyboard('{Enter}');
394362

395363
expect(onAction).toHaveBeenCalledTimes(1);
364+
// JSDOM throws an error on navigation
365+
expect(errorSpy).toHaveBeenCalledTimes(1);
396366
});
397367
});

static/app/components/dropdownMenu/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,17 @@ function DropdownMenu({
218218
);
219219
}
220220

221-
const activeItems = useMemo(() => removeHiddenItems(items), [items]);
221+
const activeItems = useMemo(
222+
() =>
223+
removeHiddenItems(items).map(item => {
224+
return {
225+
...item,
226+
// react-aria uses the href prop on item state to determine if the item is a link
227+
href: item.to ?? item.externalHref,
228+
};
229+
}),
230+
[items]
231+
);
222232
const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);
223233

224234
function renderMenu() {

0 commit comments

Comments
 (0)