Просмотр исходного кода

ref(discoverTable): Use `DropdownMenu` to display cell actions (#50657)

**Before ——**
<img width="173" alt="Screenshot 2023-06-09 at 11 27 12 AM"
src="https://github.com/getsentry/sentry/assets/44172267/db26e1bb-f7a5-4497-8337-304a8537b7db">

**After ——**
<img width="173" alt="Screenshot 2023-06-09 at 11 26 29 AM"
src="https://github.com/getsentry/sentry/assets/44172267/ab4a99ad-262d-4fb2-98fa-dba50899ac29">
Vu Luong 1 год назад
Родитель
Сommit
22ffa82374

+ 58 - 60
static/app/views/discover/table/cellAction.spec.jsx

@@ -68,33 +68,14 @@ describe('Discover -> CellAction', function () {
   };
   const view = EventView.fromLocation(location);
 
-  async function hoverContainer() {
-    await userEvent.hover(screen.getByText('some content'));
-  }
-
-  async function unhoverContainer() {
-    await userEvent.unhover(screen.getByText('some content'));
-  }
-
   async function openMenu() {
-    await hoverContainer();
-    await userEvent.click(screen.getByRole('button'));
+    await userEvent.click(screen.getByRole('button', {name: 'Actions'}));
   }
 
   describe('hover menu button', function () {
     it('shows no menu by default', function () {
       renderComponent(view);
-      expect(screen.queryByRole('button')).not.toBeInTheDocument();
-    });
-
-    it('shows a menu on hover, and hides again', async function () {
-      renderComponent(view);
-
-      await hoverContainer();
-      expect(screen.getByRole('button')).toBeInTheDocument();
-
-      await unhoverContainer();
-      expect(screen.queryByRole('button')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', {name: 'Actions'})).toBeInTheDocument();
     });
   });
 
@@ -102,7 +83,9 @@ describe('Discover -> CellAction', function () {
     it('toggles the menu on click', async function () {
       renderComponent(view);
       await openMenu();
-      expect(screen.getByRole('button', {name: 'Add to filter'})).toBeInTheDocument();
+      expect(
+        screen.getByRole('menuitemradio', {name: 'Add to filter'})
+      ).toBeInTheDocument();
     });
   });
 
@@ -116,7 +99,7 @@ describe('Discover -> CellAction', function () {
     it('add button appends condition', async function () {
       renderComponent(view, handleCellAction);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+      await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
       expect(handleCellAction).toHaveBeenCalledWith('add', 'best-transaction');
     });
@@ -124,7 +107,9 @@ describe('Discover -> CellAction', function () {
     it('exclude button adds condition', async function () {
       renderComponent(view, handleCellAction);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Exclude from filter'}));
+      await userEvent.click(
+        screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+      );
 
       expect(handleCellAction).toHaveBeenCalledWith('exclude', 'best-transaction');
     });
@@ -135,7 +120,9 @@ describe('Discover -> CellAction', function () {
       });
       renderComponent(excludeView, handleCellAction);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Exclude from filter'}));
+      await userEvent.click(
+        screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+      );
 
       expect(handleCellAction).toHaveBeenCalledWith('exclude', 'best-transaction');
     });
@@ -143,7 +130,7 @@ describe('Discover -> CellAction', function () {
     it('go to release button goes to release health page', async function () {
       renderComponent(view, handleCellAction, 3);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Go to release'}));
+      await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'}));
 
       expect(handleCellAction).toHaveBeenCalledWith(
         'release',
@@ -155,7 +142,7 @@ describe('Discover -> CellAction', function () {
       renderComponent(view, handleCellAction, 2);
       await openMenu();
       await userEvent.click(
-        screen.getByRole('button', {name: 'Show values greater than'})
+        screen.getByRole('menuitemradio', {name: 'Show values greater than'})
       );
 
       expect(handleCellAction).toHaveBeenCalledWith(
@@ -167,7 +154,9 @@ describe('Discover -> CellAction', function () {
     it('less than button adds condition', async function () {
       renderComponent(view, handleCellAction, 2);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Show values less than'}));
+      await userEvent.click(
+        screen.getByRole('menuitemradio', {name: 'Show values less than'})
+      );
 
       expect(handleCellAction).toHaveBeenCalledWith(
         'show_less_than',
@@ -178,7 +167,7 @@ describe('Discover -> CellAction', function () {
     it('error.handled with null adds condition', async function () {
       renderComponent(view, handleCellAction, 7, defaultData);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+      await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
       expect(handleCellAction).toHaveBeenCalledWith('add', 1);
     });
@@ -186,7 +175,7 @@ describe('Discover -> CellAction', function () {
     it('error.type with array values adds condition', async function () {
       renderComponent(view, handleCellAction, 8, defaultData);
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+      await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
       expect(handleCellAction).toHaveBeenCalledWith('add', [
         'ServerException',
@@ -202,7 +191,7 @@ describe('Discover -> CellAction', function () {
         'error.handled': [0],
       });
       await openMenu();
-      await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+      await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
       expect(handleCellAction).toHaveBeenCalledWith('add', [0]);
     });
@@ -211,15 +200,17 @@ describe('Discover -> CellAction', function () {
       renderComponent(view, handleCellAction, 0);
       await openMenu();
 
-      expect(screen.getByRole('button', {name: 'Add to filter'})).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Exclude from filter'})
+        screen.getByRole('menuitemradio', {name: 'Add to filter'})
       ).toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Show values greater than'})
+        screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+      ).toBeInTheDocument();
+      expect(
+        screen.queryByRole('menuitemradio', {name: 'Show values greater than'})
       ).not.toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Show values less than'})
+        screen.queryByRole('menuitemradio', {name: 'Show values less than'})
       ).not.toBeInTheDocument();
     });
 
@@ -227,9 +218,11 @@ describe('Discover -> CellAction', function () {
       renderComponent(view, handleCellAction, 4);
       await openMenu();
 
-      expect(screen.getByRole('button', {name: 'Add to filter'})).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Exclude from filter'})
+        screen.getByRole('menuitemradio', {name: 'Add to filter'})
+      ).toBeInTheDocument();
+      expect(
+        screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
       ).toBeInTheDocument();
     });
 
@@ -238,16 +231,16 @@ describe('Discover -> CellAction', function () {
       await openMenu();
 
       expect(
-        screen.queryByRole('button', {name: 'Add to filter'})
+        screen.queryByRole('menuitemradio', {name: 'Add to filter'})
       ).not.toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Exclude from filter'})
+        screen.queryByRole('menuitemradio', {name: 'Exclude from filter'})
       ).not.toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values greater than'})
+        screen.getByRole('menuitemradio', {name: 'Show values greater than'})
       ).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values less than'})
+        screen.getByRole('menuitemradio', {name: 'Show values less than'})
       ).toBeInTheDocument();
     });
 
@@ -255,15 +248,17 @@ describe('Discover -> CellAction', function () {
       renderComponent(view, handleCellAction, 2);
       await openMenu();
 
-      expect(screen.getByRole('button', {name: 'Add to filter'})).toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Exclude from filter'})
+        screen.getByRole('menuitemradio', {name: 'Add to filter'})
+      ).toBeInTheDocument();
+      expect(
+        screen.queryByRole('menuitemradio', {name: 'Exclude from filter'})
       ).not.toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values greater than'})
+        screen.getByRole('menuitemradio', {name: 'Show values greater than'})
       ).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values less than'})
+        screen.getByRole('menuitemradio', {name: 'Show values less than'})
       ).toBeInTheDocument();
     });
 
@@ -271,7 +266,9 @@ describe('Discover -> CellAction', function () {
       renderComponent(view, handleCellAction, 3);
       await openMenu();
 
-      expect(screen.getByRole('button', {name: 'Go to release'})).toBeInTheDocument();
+      expect(
+        screen.getByRole('menuitemradio', {name: 'Go to release'})
+      ).toBeInTheDocument();
     });
 
     it('show appropriate actions for empty release cells', async function () {
@@ -279,7 +276,7 @@ describe('Discover -> CellAction', function () {
       await openMenu();
 
       expect(
-        screen.queryByRole('button', {name: 'Go to release'})
+        screen.queryByRole('menuitemradio', {name: 'Go to release'})
       ).not.toBeInTheDocument();
     });
 
@@ -288,16 +285,16 @@ describe('Discover -> CellAction', function () {
       await openMenu();
 
       expect(
-        screen.queryByRole('button', {name: 'Add to filter'})
+        screen.queryByRole('menuitemradio', {name: 'Add to filter'})
       ).not.toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Exclude from filter'})
+        screen.queryByRole('menuitemradio', {name: 'Exclude from filter'})
       ).not.toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values greater than'})
+        screen.getByRole('menuitemradio', {name: 'Show values greater than'})
       ).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values less than'})
+        screen.getByRole('menuitemradio', {name: 'Show values less than'})
       ).toBeInTheDocument();
     });
 
@@ -308,15 +305,17 @@ describe('Discover -> CellAction', function () {
       });
       await openMenu();
 
-      expect(screen.getByRole('button', {name: 'Add to filter'})).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Exclude from filter'})
+        screen.getByRole('menuitemradio', {name: 'Add to filter'})
+      ).toBeInTheDocument();
+      expect(
+        screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
       ).toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Show values greater than'})
+        screen.queryByRole('menuitemradio', {name: 'Show values greater than'})
       ).not.toBeInTheDocument();
       expect(
-        screen.queryByRole('button', {name: 'Show values less than'})
+        screen.queryByRole('menuitemradio', {name: 'Show values less than'})
       ).not.toBeInTheDocument();
     });
 
@@ -325,20 +324,19 @@ describe('Discover -> CellAction', function () {
       await openMenu();
 
       expect(
-        screen.getByRole('button', {name: 'Show values greater than'})
+        screen.getByRole('menuitemradio', {name: 'Show values greater than'})
       ).toBeInTheDocument();
       expect(
-        screen.getByRole('button', {name: 'Show values less than'})
+        screen.getByRole('menuitemradio', {name: 'Show values less than'})
       ).toBeInTheDocument();
     });
 
-    it('show appropriate actions for empty numeric function cells', async function () {
+    it('show appropriate actions for empty numeric function cells', function () {
       renderComponent(view, handleCellAction, 6, {
         ...defaultData,
         'percentile(measurements.fcp, 0.5)': null,
       });
-      await hoverContainer();
-      expect(screen.queryByRole('button')).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', {name: 'Actions'})).not.toBeInTheDocument();
     });
   });
 });

+ 66 - 343
static/app/views/discover/table/cellAction.tsx

@@ -1,9 +1,8 @@
 import React, {Component} from 'react';
-import {createPortal} from 'react-dom';
-import {Manager, Popper, Reference} from 'react-popper';
 import styled from '@emotion/styled';
-import color from 'color';
 
+import {Button} from 'sentry/components/button';
+import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
 import {IconEllipsis} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -175,11 +174,20 @@ function makeCellActions({
   ) {
     value = 1;
   }
-  const actions: React.ReactNode[] = [];
+  const actions: MenuItemProps[] = [];
 
-  function addMenuItem(action: Actions, menuItem: React.ReactNode) {
+  function addMenuItem(
+    action: Actions,
+    itemLabel: React.ReactNode,
+    itemTextValue?: string
+  ) {
     if ((Array.isArray(allowActions) && allowActions.includes(action)) || !allowActions) {
-      actions.push(menuItem);
+      actions.push({
+        key: action,
+        label: itemLabel,
+        textValue: itemTextValue,
+        onAction: () => handleCellAction(action, value),
+      });
     }
   }
 
@@ -187,28 +195,10 @@ function makeCellActions({
     !['duration', 'number', 'percentage'].includes(column.type) ||
     (value === null && column.column.kind === 'field')
   ) {
-    addMenuItem(
-      Actions.ADD,
-      <ActionItem
-        key="add-to-filter"
-        data-test-id="add-to-filter"
-        onClick={() => handleCellAction(Actions.ADD, value)}
-      >
-        {t('Add to filter')}
-      </ActionItem>
-    );
+    addMenuItem(Actions.ADD, t('Add to filter'));
 
     if (column.type !== 'date') {
-      addMenuItem(
-        Actions.EXCLUDE,
-        <ActionItem
-          key="exclude-from-filter"
-          data-test-id="exclude-from-filter"
-          onClick={() => handleCellAction(Actions.EXCLUDE, value)}
-        >
-          {t('Exclude from filter')}
-        </ActionItem>
-      );
+      addMenuItem(Actions.EXCLUDE, t('Exclude from filter'));
     }
   }
 
@@ -216,53 +206,17 @@ function makeCellActions({
     ['date', 'duration', 'integer', 'number', 'percentage'].includes(column.type) &&
     value !== null
   ) {
-    addMenuItem(
-      Actions.SHOW_GREATER_THAN,
-      <ActionItem
-        key="show-values-greater-than"
-        data-test-id="show-values-greater-than"
-        onClick={() => handleCellAction(Actions.SHOW_GREATER_THAN, value)}
-      >
-        {t('Show values greater than')}
-      </ActionItem>
-    );
+    addMenuItem(Actions.SHOW_GREATER_THAN, t('Show values greater than'));
 
-    addMenuItem(
-      Actions.SHOW_LESS_THAN,
-      <ActionItem
-        key="show-values-less-than"
-        data-test-id="show-values-less-than"
-        onClick={() => handleCellAction(Actions.SHOW_LESS_THAN, value)}
-      >
-        {t('Show values less than')}
-      </ActionItem>
-    );
+    addMenuItem(Actions.SHOW_LESS_THAN, t('Show values less than'));
   }
 
   if (column.column.kind === 'field' && column.column.field === 'release' && value) {
-    addMenuItem(
-      Actions.RELEASE,
-      <ActionItem
-        key="release"
-        data-test-id="release"
-        onClick={() => handleCellAction(Actions.RELEASE, value)}
-      >
-        {t('Go to release')}
-      </ActionItem>
-    );
+    addMenuItem(Actions.RELEASE, t('Go to release'));
   }
 
   if (column.column.kind === 'function' && column.column.function[0] === 'count_unique') {
-    addMenuItem(
-      Actions.DRILLDOWN,
-      <ActionItem
-        key="drilldown"
-        data-test-id="per-cell-drilldown"
-        onClick={() => handleCellAction(Actions.DRILLDOWN, value)}
-      >
-        {t('View Stacks')}
-      </ActionItem>
-    );
+    addMenuItem(Actions.DRILLDOWN, t('View Stacks'));
   }
 
   if (
@@ -272,15 +226,10 @@ function makeCellActions({
   ) {
     addMenuItem(
       Actions.EDIT_THRESHOLD,
-      <ActionItem
-        key="edit_threshold"
-        data-test-id="edit-threshold"
-        onClick={() => handleCellAction(Actions.EDIT_THRESHOLD, value)}
-      >
-        {tct('Edit threshold ([threshold]ms)', {
-          threshold: dataRow.project_threshold_config[1],
-        })}
-      </ActionItem>
+      tct('Edit threshold ([threshold]ms)', {
+        threshold: dataRow.project_threshold_config[1],
+      }),
+      t('Edit threshold')
     );
   }
 
@@ -299,167 +248,41 @@ type State = {
 };
 
 class CellAction extends Component<Props, State> {
-  constructor(props: Props) {
-    super(props);
-    let portal = document.getElementById('cell-action-portal');
-    if (!portal) {
-      portal = document.createElement('div');
-      portal.setAttribute('id', 'cell-action-portal');
-      document.body.appendChild(portal);
-    }
-    this.portalEl = portal;
-    this.menuEl = null;
-  }
-
-  state: State = {
-    isHovering: false,
-    isOpen: false,
-  };
-
-  componentDidUpdate(_props: Props, prevState: State) {
-    if (this.state.isOpen && prevState.isOpen === false) {
-      document.addEventListener('click', this.handleClickOutside, true);
-    }
-    if (this.state.isOpen === false && prevState.isOpen) {
-      document.removeEventListener('click', this.handleClickOutside, true);
-    }
-  }
-
-  componentWillUnmount() {
-    document.removeEventListener('click', this.handleClickOutside, true);
-  }
-
-  private portalEl: Element;
-  private menuEl: Element | null;
-
-  handleClickOutside = (event: MouseEvent) => {
-    if (!this.menuEl) {
-      return;
-    }
-    if (!(event.target instanceof Element)) {
-      return;
-    }
-    if (this.menuEl.contains(event.target)) {
-      return;
-    }
-    this.setState({isOpen: false, isHovering: false});
-  };
-
-  handleMouseEnter = () => {
-    this.setState({isHovering: true});
-  };
-
-  handleMouseLeave = () => {
-    this.setState(state => {
-      // Don't hide the button if the menu is open.
-      if (state.isOpen) {
-        return state;
-      }
-      return {...state, isHovering: false};
-    });
-  };
-
-  handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
-    event.preventDefault();
-    this.setState({isOpen: !this.state.isOpen});
-  };
-
-  renderMenu() {
-    const {isOpen} = this.state;
-
-    const actions = makeCellActions(this.props);
-
-    if (actions === null) {
-      // do not render the menu if there are no per cell actions
-      return null;
-    }
-
-    const modifiers = [
-      {
-        name: 'hide',
-        enabled: false,
-      },
-      {
-        name: 'preventOverflow',
-        enabled: true,
-        options: {
-          padding: 10,
-          altAxis: true,
-        },
-      },
-      {
-        name: 'offset',
-        options: {
-          offset: [0, ARROW_SIZE / 2],
-        },
-      },
-      {
-        name: 'computeStyles',
-        options: {
-          // Using the `transform` attribute causes our borders to get blurry
-          // in chrome. See [0]. This just causes it to use `top` / `left`
-          // positions, which should be fine.
-          //
-          // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
-          gpuAcceleration: false,
-        },
-      },
-    ];
-
-    const menu = !isOpen
-      ? null
-      : createPortal(
-          <Popper placement="top" modifiers={modifiers}>
-            {({ref: popperRef, style, placement, arrowProps}) => (
-              <Menu
-                ref={ref => {
-                  (popperRef as Function)(ref);
-                  this.menuEl = ref;
-                }}
-                style={style}
-              >
-                <MenuArrow
-                  ref={arrowProps.ref}
-                  data-placement={placement}
-                  style={arrowProps.style}
-                />
-                <MenuButtons onClick={event => event.stopPropagation()}>
-                  {actions}
-                </MenuButtons>
-              </Menu>
-            )}
-          </Popper>,
-          this.portalEl
-        );
-
-    return (
-      <MenuRoot>
-        <Manager>
-          <Reference>
-            {({ref}) => (
-              <MenuButton ref={ref} onClick={this.handleMenuToggle}>
-                <IconEllipsis size="sm" data-test-id="cell-action" color="linkColor" />
-              </MenuButton>
-            )}
-          </Reference>
-          {menu}
-        </Manager>
-      </MenuRoot>
-    );
-  }
-
   render() {
     const {children} = this.props;
-    const {isHovering} = this.state;
+    const cellActions = makeCellActions(this.props);
 
     return (
-      <Container
-        onMouseEnter={this.handleMouseEnter}
-        onMouseLeave={this.handleMouseLeave}
-        data-test-id="cell-action-container"
-      >
+      <Container data-test-id="cell-action-container">
         {children}
-        {isHovering && this.renderMenu()}
+        {cellActions?.length && (
+          <DropdownMenu
+            items={cellActions}
+            usePortal
+            size="sm"
+            offset={4}
+            position="bottom"
+            preventOverflowOptions={{padding: 4}}
+            flipOptions={{
+              fallbackPlacements: [
+                'top',
+                'right-start',
+                'right-end',
+                'left-start',
+                'left-end',
+              ],
+            }}
+            trigger={triggerProps => (
+              <ActionMenuTrigger
+                {...triggerProps}
+                translucentBorder
+                aria-label={t('Actions')}
+                icon={<IconEllipsis size="xs" />}
+                size="zero"
+              />
+            )}
+          />
+        )}
       </Container>
     );
   }
@@ -476,121 +299,21 @@ const Container = styled('div')`
   justify-content: center;
 `;
 
-const MenuRoot = styled('div')`
-  position: absolute;
-  top: 0;
-  right: 0;
-`;
-
-const Menu = styled('div')`
-  z-index: ${p => p.theme.zIndex.tooltip};
-`;
-
-const MenuButtons = styled('div')`
-  background: ${p => p.theme.background};
-  border: 1px solid ${p => p.theme.border};
-  border-radius: ${p => p.theme.borderRadius};
-  box-shadow: ${p => p.theme.dropShadowHeavy};
-  overflow: hidden;
-`;
-
-const ARROW_SIZE = 12;
-
-const MenuArrow = styled('span')`
-  pointer-events: none;
+const ActionMenuTrigger = styled(Button)`
   position: absolute;
-  width: ${ARROW_SIZE}px;
-  height: ${ARROW_SIZE}px;
-
-  &::before,
-  &::after {
-    content: '';
-    display: block;
-    position: absolute;
-    height: ${ARROW_SIZE}px;
-    width: ${ARROW_SIZE}px;
-    border: solid 6px transparent;
-  }
-
-  &[data-placement|='bottom'] {
-    top: -${ARROW_SIZE}px;
-    &::before {
-      bottom: 1px;
-      border-bottom-color: ${p => p.theme.translucentBorder};
-    }
-    &::after {
-      border-bottom-color: ${p => p.theme.backgroundElevated};
-    }
-  }
-
-  &[data-placement|='top'] {
-    bottom: -${ARROW_SIZE}px;
-    &::before {
-      top: 1px;
-      border-top-color: ${p => p.theme.translucentBorder};
-    }
-    &::after {
-      border-top-color: ${p => p.theme.backgroundElevated};
-    }
-  }
-
-  &[data-placement|='right'] {
-    left: -${ARROW_SIZE}px;
-    &::before {
-      right: 1px;
-      border-right-color: ${p => p.theme.translucentBorder};
-    }
-    &::after {
-      border-right-color: ${p => p.theme.backgroundElevated};
-    }
-  }
-
-  &[data-placement|='left'] {
-    right: -${ARROW_SIZE}px;
-    &::before {
-      left: 1px;
-      border-left-color: ${p => p.theme.translucentBorder};
-    }
-    &::after {
-      border-left-color: ${p => p.theme.backgroundElevated};
-    }
-  }
-`;
-
-const ActionItem = styled('button')`
-  display: block;
-  width: 100%;
-  padding: ${space(1)} ${space(2)};
-  background: transparent;
+  top: 50%;
+  right: -1px;
+  transform: translateY(-50%);
+  padding: ${space(0.5)};
 
-  outline: none;
-  border: 0;
-  border-bottom: 1px solid ${p => p.theme.innerBorder};
-
-  font-size: ${p => p.theme.fontSizeMedium};
-  text-align: left;
-  line-height: 1.2;
-
-  &:hover {
-    background: ${p => p.theme.backgroundSecondary};
-  }
-
-  &:last-child {
-    border-bottom: 0;
-  }
-`;
-
-const MenuButton = styled('button')`
   display: flex;
-  width: 24px;
-  height: 24px;
-  padding: 0;
-  justify-content: center;
   align-items: center;
 
-  background: ${p => color(p.theme.background).alpha(0.85).string()};
-  border-radius: ${p => p.theme.borderRadius};
-  border: 1px solid ${p => p.theme.border};
-  cursor: pointer;
-  outline: none;
+  opacity: 0;
+  transition: opacity 0.1s;
+  &.focus-visible,
+  &[aria-expanded='true'],
+  ${Container}:hover & {
+    opacity: 1;
+  }
 `;

+ 24 - 13
static/app/views/discover/table/tableView.spec.jsx

@@ -53,8 +53,7 @@ describe('TableView > CellActions', function () {
     const firstRow = screen.getAllByRole('row')[1];
     const emptyValueCell = within(firstRow).getAllByRole('cell')[cellIndex];
 
-    await userEvent.hover(within(emptyValueCell).getByTestId('cell-action-container'));
-    await userEvent.click(within(emptyValueCell).getByRole('button'));
+    await userEvent.click(within(emptyValueCell).getByRole('button', {name: 'Actions'}));
   }
 
   beforeEach(function () {
@@ -139,7 +138,7 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, eventView);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -156,7 +155,7 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, view);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -172,7 +171,7 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, view);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -187,7 +186,7 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, eventView);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Add to filter'}));
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -201,7 +200,9 @@ describe('TableView > CellActions', function () {
   it('handles exclude cell action on string value', async function () {
     renderComponent(initialData, rows, eventView);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Exclude from filter'}));
+    await userEvent.click(
+      screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+    );
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -217,7 +218,9 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, view);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Exclude from filter'}));
+    await userEvent.click(
+      screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+    );
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -232,7 +235,9 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, eventView);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Exclude from filter'}));
+    await userEvent.click(
+      screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+    );
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -249,7 +254,9 @@ describe('TableView > CellActions', function () {
 
     renderComponent(initialData, rows, view);
     await openContextMenu(1);
-    await userEvent.click(screen.getByRole('button', {name: 'Exclude from filter'}));
+    await userEvent.click(
+      screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+    );
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -262,7 +269,9 @@ describe('TableView > CellActions', function () {
   it('handles greater than cell action on number value', async function () {
     renderComponent(initialData, rows, eventView);
     await openContextMenu(3);
-    await userEvent.click(screen.getByRole('button', {name: 'Show values greater than'}));
+    await userEvent.click(
+      screen.getByRole('menuitemradio', {name: 'Show values greater than'})
+    );
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -275,7 +284,9 @@ describe('TableView > CellActions', function () {
   it('handles less than cell action on number value', async function () {
     renderComponent(initialData, rows, eventView);
     await openContextMenu(3);
-    await userEvent.click(screen.getByRole('button', {name: 'Show values less than'}));
+    await userEvent.click(
+      screen.getByRole('menuitemradio', {name: 'Show values less than'})
+    );
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: location.pathname,
@@ -306,7 +317,7 @@ describe('TableView > CellActions', function () {
   it('handles go to release', async function () {
     renderComponent(initialData, rows, eventView);
     await openContextMenu(5);
-    await userEvent.click(screen.getByRole('button', {name: 'Go to release'}));
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'}));
 
     expect(browserHistory.push).toHaveBeenCalledWith({
       pathname: '/organizations/org-slug/releases/v1.0.2/',

+ 15 - 11
static/app/views/performance/table.spec.tsx

@@ -200,21 +200,25 @@ describe('Performance > Table', function () {
 
       const cellActionContainers = screen.getAllByTestId('cell-action-container');
       expect(cellActionContainers).toHaveLength(18); // 9 cols x 2 rows
-      await userEvent.hover(cellActionContainers[8]);
-      const cellActions = await screen.findByTestId('cell-action');
-      expect(cellActions).toBeInTheDocument();
-      await userEvent.click(cellActions);
+      const cellActionTriggers = screen.getAllByRole('button', {name: 'Actions'});
+      expect(cellActionTriggers[8]).toBeInTheDocument();
+      await userEvent.click(cellActionTriggers[8]);
 
-      expect(await screen.findByTestId('add-to-filter')).toBeInTheDocument();
-      expect(screen.getByTestId('exclude-from-filter')).toBeInTheDocument();
+      expect(
+        screen.getByRole('menuitemradio', {name: 'Add to filter'})
+      ).toBeInTheDocument();
+      expect(
+        screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
+      ).toBeInTheDocument();
 
-      await userEvent.hover(cellActionContainers[0]); // Transaction name
-      const transactionCellActions = await screen.findAllByTestId('cell-action');
-      expect(transactionCellActions[0]).toBeInTheDocument();
-      await userEvent.click(transactionCellActions[0]);
+      await userEvent.keyboard('{Escape}'); // Close actions menu
+
+      const transactionCellTrigger = cellActionTriggers[0]; // Transaction name
+      expect(transactionCellTrigger).toBeInTheDocument();
+      await userEvent.click(transactionCellTrigger);
 
       expect(browserHistory.push).toHaveBeenCalledTimes(0);
-      await userEvent.click(screen.getByTestId('add-to-filter'));
+      await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
 
       expect(browserHistory.push).toHaveBeenCalledTimes(1);
       expect(browserHistory.push).toHaveBeenNthCalledWith(1, {