import { ComponentType, ReactNode, PropsWithChildren, useState, useEffect, useRef, useContext, createContext, } from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import { Plus, Edit } from 'lucide-react'; import { MenuItem } from '@reach/menu-button'; import { UIRouter } from '@uirouter/react'; import { MenuButton, MenuButtonLink, MenuButtonProps } from './MenuButton'; type MockCommonProps = Record; type MockWithChildren = { children?: ReactNode }; type MockMenuButtonProps = MockWithChildren & { as?: ComponentType; } & MockCommonProps; type MockMenuItemProps = MockWithChildren & { onSelect?: () => void; disabled?: boolean; } & MockCommonProps; type MockMenuLinkProps = MockWithChildren & { onClick?: () => void; href?: string; className?: string; } & MockCommonProps; type MockMenuProps = MockWithChildren; type MockMenuFns = { Menu: (props: MockMenuProps) => ReactNode; MenuButton: (props: MockMenuButtonProps) => ReactNode; MenuPopover: (props: MockWithChildren) => ReactNode; MenuItem: (props: MockMenuItemProps) => ReactNode; MenuLink: (props: MockMenuLinkProps) => ReactNode; MenuList: (props: MockWithChildren) => ReactNode; }; const mockUseSref = vi.hoisted(() => vi.fn()); vi.mock('@uirouter/react', () => ({ UIRouter: ({ children }: MockWithChildren) => children, useSref: mockUseSref, })); vi.mock('@reach/menu-button', () => { type Ctx = { isOpen: boolean; setOpen: (v: boolean) => void; menuRef: React.RefObject; }; const MenuCtx = createContext(null); function Menu({ children }: MockWithChildren) { const [isOpen, setOpen] = useState(false); const menuRef = useRef(null); useEffect(() => { function handleDocDown(e: MouseEvent) { const target = e.target as Node | null; if ( isOpen && menuRef.current && target && !menuRef.current.contains(target) ) { setOpen(false); } } document.addEventListener('mousedown', handleDocDown); return () => document.removeEventListener('mousedown', handleDocDown); }, [isOpen]); return (
{children}
); } function MenuButton({ children, as: Component, ...props }: MockMenuButtonProps) { const ctx = useContext(MenuCtx); function onClick() { ctx?.setOpen(!ctx.isOpen); } if (Component) { return ( {children} ); } return ( ); } function MenuPopover({ children }: MockWithChildren) { const ctx = useContext(MenuCtx); if (!ctx?.isOpen) return null; return
{children}
; } function MenuItem({ children, onSelect, disabled, ...props }: MockMenuItemProps) { const ctx = useContext(MenuCtx); function handleClick() { if (!disabled) { onSelect?.(); ctx?.setOpen(false); } } return ( ); } function MenuLink({ children, onClick, href, className, ...props }: MockMenuLinkProps) { const ctx = useContext(MenuCtx); function handleClick() { onClick?.(); ctx?.setOpen(false); } return ( { e.preventDefault(); handleClick(); }} {...props} > {children} ); } function MenuList({ children }: MockWithChildren) { const ctx = useContext(MenuCtx); if (!ctx?.isOpen) return null; return
{children}
; } const exported: MockMenuFns = { Menu, MenuButton, MenuPopover, MenuItem, MenuLink, MenuList, }; return exported; }); function mapItems(items: Array) { return items.map((item) => item.element); } type MockMenuButtonItem = { id: string; element: ReactNode; handler?: () => void; }; function createMockMenuItem({ id, label, icon, onClick, disabled, }: { id: string; label: string; icon?: ComponentType; onClick?: () => void; disabled?: boolean; }): MockMenuButtonItem { const IconComponent = icon; const handler = onClick; return { id, handler, element: ( {})}> {IconComponent ? : null} {label} ), }; } const mockItems: Array = [ createMockMenuItem({ id: 'create', label: 'Create new', icon: Plus, onClick: vi.fn(), }), createMockMenuItem({ id: 'edit', label: 'Edit existing', icon: Edit, onClick: vi.fn(), }), ]; type RenderOptions = Omit< Partial>, 'items' > & { items?: Array; }; function renderDefault({ items = mockItems, children = 'Test Menu', color = 'primary', size = 'small', disabled = false, ...props }: RenderOptions = {}) { return render( {children} ); } beforeEach(() => { vi.clearAllMocks(); // Set default mock implementation mockUseSref.mockReturnValue({ href: '#default', onClick: vi.fn(), }); }); test('should display MenuButton with correct text and chevron icon', async () => { const children = 'Test Actions'; renderDefault({ children }); const button = await screen.findByText(children); expect(button).toBeTruthy(); // Check for chevron down icon (it should be in the DOM) const chevronIcon = button.closest('button')?.querySelector('svg'); expect(chevronIcon).toBeTruthy(); }); test('should not show menu items by default', async () => { renderDefault(); expect(screen.queryByRole('menuitem')).toBeNull(); }); test('should show menu items when clicked', async () => { renderDefault(); const trigger = await screen.findByText('Test Menu'); fireEvent.click(trigger); expect(await screen.findByText('Create new')).toBeTruthy(); expect(await screen.findByText('Edit existing')).toBeTruthy(); }); test('should hide menu items when something else is clicked', async () => { renderDefault(); const trigger = await screen.findByText('Test Menu'); fireEvent.click(trigger); expect(await screen.findByText('Create new')).toBeTruthy(); // click outside fireEvent.mouseDown(document.body); // items should disappear expect(screen.queryByText('Create new')).toBeNull(); expect(screen.queryByText('Edit existing')).toBeNull(); }); test('should call onClick when menu item is clicked', async () => { renderDefault(); const trigger = await screen.findByText('Test Menu'); fireEvent.click(trigger); const createItem = await screen.findByText('Create new'); fireEvent.click(createItem); expect(mockItems[0].handler).toHaveBeenCalled(); }); test('should not call onClick when disabled item is clicked', async () => { const disabledItemMock = createMockMenuItem({ id: 'disabled', label: 'Disabled action', disabled: true, onClick: vi.fn(), }); renderDefault({ items: [disabledItemMock] }); const trigger = await screen.findByText('Test Menu'); fireEvent.click(trigger); const disabledItem = await screen.findByText('Disabled action'); fireEvent.click(disabledItem); expect(disabledItemMock.handler).not.toHaveBeenCalled(); }); test('should support link items', async () => { const mockOnClick = vi.fn(); // Set up the mock to return our test onClick function mockUseSref.mockReturnValue({ href: '#kubernetes.deploy', onClick: mockOnClick, }); render( Deploy , ]} color="primary" data-cy="menu-button-link" > Mixed ); const trigger = await screen.findByText('Mixed'); fireEvent.click(trigger); const link = await screen.findByText('Deploy'); fireEvent.click(link); expect(mockOnClick).toHaveBeenCalled(); }); test('should be disabled when disabled prop is true', async () => { renderDefault({ disabled: true }); const button = await screen.findByText('Test Menu'); expect(button.closest('button')).toBeDisabled(); expect(button.closest('button')).toHaveClass('disabled'); }); test('should render menu items with icons', async () => { renderDefault(); const trigger = await screen.findByText('Test Menu'); fireEvent.click(trigger); // Check that menu items have icons (SVG elements) const createItem = await screen.findByText('Create new'); const editItem = await screen.findByText('Edit existing'); expect( createItem.closest('[role="menuitem"]')?.querySelector('svg') ).toBeTruthy(); expect( editItem.closest('[role="menuitem"]')?.querySelector('svg') ).toBeTruthy(); }); test('should render with custom className', async () => { renderDefault({ className: 'custom-class' }); const button = await screen.findByText('Test Menu'); expect(button.closest('button')).toHaveClass('custom-class'); }); test('should have proper accessibility attributes for screen readers', async () => { const mockOnClick = vi.fn(); // Set up the mock to return our test onClick function mockUseSref.mockReturnValue({ href: '#kubernetes.deploy', onClick: mockOnClick, }); render( Deploy , ]} color="primary" data-cy="menu-button-keyboard" > Accessibility Test ); const trigger = await screen.findByText('Accessibility Test'); // Open menu to reveal the link fireEvent.click(trigger); const link = await screen.findByText('Deploy'); expect(link).toBeVisible(); // Test that the link has proper accessibility attributes expect(link).toHaveAttribute('aria-label', 'Deploy'); // Screen reader support expect(link).toHaveAttribute('role', 'menuitem'); // Proper ARIA role expect(link).toHaveAttribute('href'); // Has navigation href });