Skip to content

fix(dropdown-menu): Remove custom link behavior #90713

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 3 commits into from
May 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion static/app/components/core/menuListItem/index.chonk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ export const ChonkInnerWrap = chonkStyled('div', {
font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};

&,
&:hover {
&:hover,
&:focus,
&:focus-visible {
color: ${getTextColor};
box-shadow: none;
outline: none;
}
${p => p.disabled && `cursor: default;`}

Expand Down
23 changes: 13 additions & 10 deletions static/app/components/core/menuListItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {memo, useId, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {usePopper} from 'react-popper';
import isPropValid from '@emotion/is-prop-valid';
import {type Theme, useTheme} from '@emotion/react';
import {css, type Theme, useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import {mergeRefs} from '@react-aria/utils';

Expand Down Expand Up @@ -123,7 +123,6 @@ function BaseMenuListItem({

return (
<MenuItemWrap
role="menuitem"
aria-disabled={disabled}
aria-labelledby={labelId}
aria-describedby={detailId}
Expand Down Expand Up @@ -313,8 +312,12 @@ export const InnerWrap = withChonk(
font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};

&,
&:hover {
&:hover,
&:focus,
&:focus-visible {
color: ${getTextColor};
box-shadow: none;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These overrides are necessary because InnerWrap now gets focus instead of MenuItemWrap

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please make sure you do the same changes in the chonk version if they are needed there as well:

export const ChonkInnerWrap = chonkStyled('div', {

outline: none;
}
${p => p.disabled && `cursor: default;`}

Expand All @@ -330,13 +333,13 @@ export const InnerWrap = withChonk(

${p =>
p.isFocused &&
`
z-index: 1;
/* Background to hide the previous item's divider */
::before {
background: ${p.theme.backgroundElevated};
}
`}
css`
z-index: 1;
/* Background to hide the previous item's divider */
::before {
background: ${p.theme.backgroundElevated};
}
`}
`,
ChonkInnerWrap
);
Expand Down
72 changes: 21 additions & 51 deletions static/app/components/dropdownMenu/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {Fragment} from 'react';
import {RouterFixture} from 'sentry-fixture/routerFixture';

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

Expand All @@ -25,10 +24,7 @@ describe('DropdownMenu', function () {
},
]}
triggerLabel="This is a Menu"
/>,
{
deprecatedRouterMocks: true,
}
/>
);

// Open the mneu
Expand Down Expand Up @@ -71,10 +67,7 @@ describe('DropdownMenu', function () {
},
]}
triggerLabel="Menu"
/>,
{
deprecatedRouterMocks: true,
}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
Expand All @@ -95,10 +88,7 @@ describe('DropdownMenu', function () {
<Fragment>
<DropdownMenu items={[{key: 'item1', label: 'Item One'}]} triggerLabel="Menu A" />
<DropdownMenu items={[{key: 'item2', label: 'Item Two'}]} triggerLabel="Menu B" />
</Fragment>,
{
deprecatedRouterMocks: true,
}
</Fragment>
);

// Can be dismissed by clicking outside
Expand Down Expand Up @@ -164,10 +154,7 @@ describe('DropdownMenu', function () {
},
]}
triggerLabel="Menu"
/>,
{
deprecatedRouterMocks: true,
}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
Expand Down Expand Up @@ -254,10 +241,7 @@ describe('DropdownMenu', function () {
},
]}
triggerLabel="Menu"
/>,
{
deprecatedRouterMocks: true,
}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
Expand All @@ -271,10 +255,7 @@ describe('DropdownMenu', function () {
<DropdownMenu
items={[{key: 'item1', label: 'Item One', to: '/test'}]}
triggerLabel="Menu"
/>,
{
deprecatedRouterMocks: true,
}
/>
);

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

it('closes after clicking external link', async function () {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

render(
<DropdownMenu
items={[{key: 'item1', label: 'Item One', externalHref: 'https://example.com'}]}
triggerLabel="Menu"
/>,
{
deprecatedRouterMocks: true,
}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Item One'}));
await waitFor(() => {
expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument();
});

// JSDOM throws an error on navigation to random urls
expect(errorSpy).toHaveBeenCalledTimes(1);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already another test in this file which expects this error to be thrown, this already appeared to be an issue

});

it('navigates to link on enter', async function () {
const onAction = jest.fn();
const router = RouterFixture();
render(
const {router} = render(
<DropdownMenu
items={[
{key: 'item1', label: 'Item One', to: '/test'},
{key: 'item2', label: 'Item Two', to: '/test2', onAction},
]}
triggerLabel="Menu"
/>,
{
router,
deprecatedRouterMocks: true,
}
/>
);

await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('{Enter}');
await waitFor(() => {
expect(router.push).toHaveBeenCalledWith(
expect.objectContaining({pathname: '/test2'})
);
expect(router.location.pathname).toBe('/test2');
});
expect(onAction).toHaveBeenCalledTimes(1);
});

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

const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
Expand All @@ -344,11 +319,7 @@ describe('DropdownMenu', function () {
{key: 'item2', label: 'Item Two', to: '/test2', onAction},
]}
triggerLabel="Menu"
/>,
{
router,
deprecatedRouterMocks: true,
}
/>
);

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

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

const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

render(
<DropdownMenu
items={[
Expand All @@ -381,17 +353,15 @@ describe('DropdownMenu', function () {
},
]}
triggerLabel="Menu"
/>,
{
router,
deprecatedRouterMocks: true,
}
/>
);

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

expect(onAction).toHaveBeenCalledTimes(1);
// JSDOM throws an error on navigation
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
12 changes: 11 additions & 1 deletion static/app/components/dropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,17 @@ function DropdownMenu({
);
}

const activeItems = useMemo(() => removeHiddenItems(items), [items]);
const activeItems = useMemo(
() =>
removeHiddenItems(items).map(item => {
return {
...item,
// react-aria uses the href prop on item state to determine if the item is a link
href: item.to ?? item.externalHref,
};
}),
[items]
);
const defaultDisabledKeys = useMemo(() => getDisabledKeys(activeItems), [activeItems]);

function renderMenu() {
Expand Down
Loading
Loading