From 600e8c6d4896a47becf62a89ae0edbb36824f4b2 Mon Sep 17 00:00:00 2001 From: Mayank Sharma Date: Mon, 24 Aug 2026 16:02:21 +0530 Subject: [PATCH] fix(ActionButton): align dropdown menu to button group Signed-off-by: Mayank Sharma --- src/__testing__/ActionButton.test.tsx | 142 +++++++++++++++++++++++ src/custom/ActionButton/ActionButton.tsx | 49 ++++++-- 2 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 src/__testing__/ActionButton.test.tsx diff --git a/src/__testing__/ActionButton.test.tsx b/src/__testing__/ActionButton.test.tsx new file mode 100644 index 000000000..0a802b0e7 --- /dev/null +++ b/src/__testing__/ActionButton.test.tsx @@ -0,0 +1,142 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { ActionButton, Option } from '../custom/ActionButton'; + +describe('ActionButton Component', () => { + const mockOptions: Option[] = [ + { + label: 'Validate', + icon: V, + onClick: jest.fn() + }, + { + label: 'Dry Run', + icon: D, + onClick: jest.fn() + }, + { + label: 'Deploy', + icon: Dep, + onClick: jest.fn(), + disabled: true + }, + { + label: 'Hidden Option', + icon: H, + onClick: jest.fn(), + show: false + }, + { + label: 'Divider', + icon: null, + onClick: jest.fn(), + isDivider: true + }, + { + label: 'Undeploy', + icon: U, + onClick: jest.fn() + } + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with default label "Action" when no label is passed', () => { + render(); + expect(screen.getByRole('button', { name: /Action/i })).not.toBeNull(); + }); + + it('renders with custom label when provided', () => { + render(); + expect(screen.getByRole('button', { name: /^Actions$/i })).not.toBeNull(); + }); + + it('executes defaultActionClick when primary button is clicked and callback provided', () => { + const handleDefaultClick = jest.fn(); + render( + + ); + + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + fireEvent.click(mainButton); + expect(handleDefaultClick).toHaveBeenCalledTimes(1); + }); + + it('toggles dropdown menu when primary button is clicked and defaultActionClick is not provided', () => { + render(); + + expect(screen.queryByRole('menu')).toBeNull(); + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + fireEvent.click(mainButton); + + expect(screen.getByRole('menu')).not.toBeNull(); + expect(screen.getByText('Validate')).not.toBeNull(); + }); + + it('toggles dropdown menu when the dropdown arrow button is clicked', () => { + render(); + + expect(screen.queryByRole('menu')).toBeNull(); + + const buttons = screen.getAllByRole('button'); + const dropdownArrowButton = buttons[1]; + + // Open menu + fireEvent.click(dropdownArrowButton); + expect(screen.getByRole('menu')).not.toBeNull(); + expect(screen.getByText('Validate')).not.toBeNull(); + expect(screen.getByText('Dry Run')).not.toBeNull(); + + // Toggle menu closed + fireEvent.click(dropdownArrowButton); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('calls option onClick handler and closes menu when an option is clicked', () => { + render(); + + const buttons = screen.getAllByRole('button'); + const dropdownArrowButton = buttons[1]; + fireEvent.click(dropdownArrowButton); + + const validateItem = screen.getByText('Validate'); + fireEvent.click(validateItem); + + expect(mockOptions[0].onClick).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).toBeNull(); + }); + + it('does not invoke onClick for disabled options', () => { + render(); + + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[1]); + + const deployItem = screen.getByText('Deploy'); + fireEvent.click(deployItem); + + expect(mockOptions[2].onClick).not.toHaveBeenCalled(); + }); + + it('does not render options with show set to false', () => { + render(); + + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[1]); + + expect(screen.queryByText('Hidden Option')).toBeNull(); + }); + + it('disables primary button when defaultActionDisabled is true', () => { + render(); + + const mainButton = screen.getByRole('button', { name: /^Actions$/i }); + expect(mainButton.hasAttribute('disabled')).toBe(true); + }); +}); diff --git a/src/custom/ActionButton/ActionButton.tsx b/src/custom/ActionButton/ActionButton.tsx index 791f72e97..fc5bd04c4 100644 --- a/src/custom/ActionButton/ActionButton.tsx +++ b/src/custom/ActionButton/ActionButton.tsx @@ -10,55 +10,77 @@ import { Popper } from '../../base'; import { DropDownIcon } from '../../icons'; + export interface Option { icon: React.ReactNode; label: string; onClick: (event: React.MouseEvent, index: number) => void; isDivider?: boolean; show?: boolean; + disabled?: boolean; } export interface ActionButtonProps { - defaultActionClick: () => void; + defaultActionClick?: () => void; defaultActionDisabled?: boolean; options: Option[]; - label: string; + label?: string; + placement?: 'bottom-start' | 'bottom' | 'bottom-end' | 'top-start' | 'top' | 'top-end'; } export default function ActionButton({ defaultActionClick, defaultActionDisabled = false, options, - label + label = 'Action', + placement = 'bottom-start' }: ActionButtonProps): JSX.Element { const [open, setOpen] = React.useState(false); - const [anchorEl, setAnchorEl] = React.useState(null); + const anchorRef = React.useRef(null); + const handleMenuItemClick = () => { setOpen(false); }; const handleToggle = (event: React.MouseEvent) => { event.stopPropagation(); - setAnchorEl(event.currentTarget); setOpen((prevOpen) => !prevOpen); }; - const handleClose = () => { - setAnchorEl(null); + const handleClose = (event: MouseEvent | TouchEvent) => { + if (anchorRef.current && anchorRef.current.contains(event.target as Node)) { + return; + } setOpen(false); }; + const handleMainClick = (event: React.MouseEvent) => { + if (defaultActionClick) { + defaultActionClick(); + } else { + handleToggle(event); + } + }; + return ( - - @@ -67,8 +89,9 @@ export default function ActionButton({ zIndex: 1 }} open={open} - anchorEl={anchorEl} + anchorEl={anchorRef.current} role={undefined} + placement={placement} > @@ -77,11 +100,15 @@ export default function ActionButton({ .filter((option) => option?.show !== false) .map((option, index) => option.isDivider ? ( - + ) : ( { + if (option.disabled) { + return; + } handleMenuItemClick(); option.onClick(event, index); }}