@codecademy/gamut

72.3.072.3.1-alpha.f273fd.0
RowActionsMenu.tsx
+RowActionsMenu.tsxNew file
+96
Index: RowActionsMenu.tsx
===================================================================
--- RowActionsMenu.tsx
+++ RowActionsMenu.tsx
@@ -0,0 +1,96 @@
+import {
+  FlexBox,
+  IconButton,
+  Menu,
+  MenuItem,
+  PopoverContainer,
+} from '@codecademy/gamut';
+import {
+  DeleteIcon,
+  DuplicateIcon,
+  EditIcon,
+  MiniKebabMenuIcon,
+} from '@codecademy/gamut-icons';
+import { Background } from '@codecademy/gamut-styles';
+import * as React from 'react';
+import { useRef, useState } from 'react';
+
+export interface RowActionsMenuProps {
+  /**
+   * Accessible name for the trigger button, and used as its ToolTip label.
+   * Include context about which row this menu belongs to when possible,
+   * e.g. `Actions for "Intro to JavaScript"`.
+   */
+  label?: string;
+  /** Called when the "Edit" action is selected. */
+  onEdit?: () => void;
+  /** Called when the "Duplicate" action is selected. */
+  onDuplicate?: () => void;
+  /** Called when the "Delete" action is selected. */
+  onDelete?: () => void;
+}
+
+/**
+ * A kebab-menu trigger button that reveals a floating list of row actions
+ * (Edit, Duplicate, Delete) positioned below it. The floating menu closes
+ * when an action is chosen, when the user clicks outside of it, or when
+ * Escape is pressed.
+ *
+ * Built by composing existing Gamut primitives (`IconButton`, `PopoverContainer`,
+ * `Menu`/`MenuItem`) rather than hand-rolled positioning, outside-click, or
+ * escape-key handling — `PopoverContainer` already wires a `FocusTrap` that
+ * handles both dismiss behaviors and restores focus to the trigger on close.
+ */
+export const RowActionsMenu: React.FC<RowActionsMenuProps> = ({
+  label = 'Row actions',
+  onEdit,
+  onDuplicate,
+  onDelete,
+}) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const triggerRef = useRef<HTMLButtonElement>(null);
+
+  const runAction = (action?: () => void) => {
+    action?.();
+    setIsOpen(false);
+  };
+
+  return (
+    <FlexBox display="inline-flex" position="relative">
+      <IconButton
+        aria-expanded={isOpen}
+        aria-haspopup="true"
+        icon={MiniKebabMenuIcon}
+        ref={triggerRef}
+        tip={label}
+        variant="secondary"
+        onClick={() => setIsOpen((open) => !open)}
+      />
+      <PopoverContainer
+        alignment="bottom-right"
+        inline
+        isOpen={isOpen}
+        offset={4}
+        targetRef={triggerRef}
+        onRequestClose={() => setIsOpen(false)}
+      >
+        <Background bg="background" borderRadius="lg">
+          <Menu role="menu" variant="popover" width="max-content">
+            <MenuItem icon={EditIcon} onClick={() => runAction(onEdit)}>
+              Edit
+            </MenuItem>
+            <MenuItem
+              icon={DuplicateIcon}
+              onClick={() => runAction(onDuplicate)}
+            >
+              Duplicate
+            </MenuItem>
+            <MenuItem icon={DeleteIcon} onClick={() => runAction(onDelete)}>
+              Delete
+            </MenuItem>
+          </Menu>
+        </Background>
+      </PopoverContainer>
+    </FlexBox>
+  );
+};