diff --git a/.changeset/rare-guests-rescue.md b/.changeset/rare-guests-rescue.md new file mode 100644 index 0000000000..39f6641bb0 --- /dev/null +++ b/.changeset/rare-guests-rescue.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/split-button': minor +--- + +Adds and exports test harnesses from SplitButton package diff --git a/packages/split-button/package.json b/packages/split-button/package.json index 0a97e5afeb..dfbe5c2bdf 100644 --- a/packages/split-button/package.json +++ b/packages/split-button/package.json @@ -33,12 +33,14 @@ "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/polymorphic": "workspace:^", "@leafygreen-ui/popover": "workspace:^", - "@leafygreen-ui/tokens": "workspace:^" + "@leafygreen-ui/tokens": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" }, "peerDependencies": { "@leafygreen-ui/leafygreen-provider": "workspace:^" }, "devDependencies": { "@lg-tools/build": "workspace:^" + } } diff --git a/packages/split-button/src/SplitButton/SplitButton.spec.tsx b/packages/split-button/src/SplitButton/SplitButton.spec.tsx index 03d512cb07..d110ba7235 100644 --- a/packages/split-button/src/SplitButton/SplitButton.spec.tsx +++ b/packages/split-button/src/SplitButton/SplitButton.spec.tsx @@ -1,253 +1,520 @@ import React, { createRef } from 'react'; import { - fireEvent, - getAllByRole as globalGetAllByRole, render, waitFor, waitForElementToBeRemoved, - within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; -import { Optional } from '@leafygreen-ui/lib'; -import { getLgIds as getMenuLgIds, MenuItem } from '@leafygreen-ui/menu'; -import { InferredPolymorphicPropsWithRef } from '@leafygreen-ui/polymorphic'; +import { Size } from '@leafygreen-ui/button'; +import { MenuItem } from '@leafygreen-ui/menu'; import { RenderMode } from '@leafygreen-ui/popover'; -import { getLgIds } from '../utils/getLgIds'; - -import { MenuItemsType, SplitButtonProps } from './SplitButton.types'; -import { SplitButton } from '.'; - -const lgIds = getLgIds(); -const menuLgIds = getMenuLgIds(lgIds.menu); - -const getMenuItems = (): MenuItemsType => { - return [ - - Menu Item With Description - , - - Disabled Menu Item - , - Menu Item, - Another Menu Item, - ]; +import { getTestUtils } from '../testing'; + +import { SplitButton } from './SplitButton'; +import { Align, Justify, Variant } from './SplitButton.types'; + +// Test data +const defaultMenuItems = [ + Edit, + Duplicate, + + Delete + , + + Archive + , +]; + +const basicProps = { + label: 'Primary Action', + menuItems: defaultMenuItems, }; -const defaultProps = { - label: 'Button Label', - menuItems: getMenuItems(), -}; - -type RenderSplitButtonProps = Optional< - InferredPolymorphicPropsWithRef<'button', SplitButtonProps>, - keyof typeof defaultProps ->; - -function renderSplitButton(props: RenderSplitButtonProps = {}) { - const renderResult = render(); - const wrapper = renderResult.getByTestId(lgIds.root); - const primaryButton = renderResult.getByTestId(lgIds.button); - const menuTrigger = renderResult.getByTestId(lgIds.trigger); - - /** - * Since menu elements won't exist until component is interacted with, - * call this after opening the menu. - * @returns Object of menu elements - */ - // TODO: Consolidate with Menu component util - async function findMenuElements(): Promise<{ - menuEl: HTMLElement | null; - menuItemElements: Array; - }> { - const menuEl = await renderResult.findByTestId(menuLgIds.root); - const menuItemElements = await within(menuEl).findAllByRole('menuitem'); - - return { - menuEl, - menuItemElements, - }; - } - - /** - * Opens the menu, and manually fires transition events - */ - async function openMenu(options?: { withKeyboard: boolean }) { - if (options?.withKeyboard) { - menuTrigger.focus(); - userEvent.keyboard('{enter}'); - } else { - userEvent.click(menuTrigger); - } - - const menuElements = await findMenuElements(); - fireEvent.transitionEnd(menuElements.menuEl as Element); // JSDOM does not automatically fire these events - return menuElements; - } +// Helper function to render SplitButton with getTestUtils +function renderSplitButtonWithUtils(props: any = {}) { + const allProps = { ...basicProps, ...props }; + const renderResult = render(); + const utils = getTestUtils(props['data-lgid']); return { ...renderResult, - primaryButton, - menuTrigger, - wrapper, - findMenuElements, - openMenu, + utils, }; } -describe('packages/split-button', () => { - describe('a11y', () => { - test('does not have basic accessibility issues', async () => { - const { container } = renderSplitButton({}); +describe('SplitButton - Comprehensive Test Suite', () => { + describe('Accessibility', () => { + test('has no accessibility violations', async () => { + const { container } = renderSplitButtonWithUtils(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + test('has no accessibility violations when menu is open', async () => { + const { container, utils } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + const results = await axe(container); expect(results).toHaveNoViolations(); }); + + test('has proper ARIA attributes', () => { + const { utils } = renderSplitButtonWithUtils(); + + const button = utils.getButton(); + const trigger = utils.getTrigger(); + + expect(button).toHaveAttribute('type', 'button'); + expect(trigger).toHaveAttribute('aria-haspopup', 'true'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + test('updates aria-expanded when menu opens/closes', async () => { + const { utils } = renderSplitButtonWithUtils(); + const trigger = utils.getTrigger(); + + // Initially closed + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + // Open menu + await utils.openMenu(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); }); - describe('Wrapper container', () => { - test('renders correct className', () => { - const { wrapper } = renderSplitButton({ - className: 'split-button-class', + describe('Rendering', () => { + test('renders primary button with correct label', () => { + const { utils } = renderSplitButtonWithUtils({ label: 'Custom Label' }); + + const button = utils.getButton(); + expect(button).toHaveTextContent('Custom Label'); + expect(button).toBeVisible(); + }); + + test('renders menu trigger button', () => { + const { utils } = renderSplitButtonWithUtils(); + + const trigger = utils.getTrigger(); + expect(trigger).toBeVisible(); + expect(trigger).toHaveAttribute('aria-haspopup', 'true'); + }); + + test('renders with custom className', () => { + const { utils } = renderSplitButtonWithUtils({ + className: 'custom-split-button', + }); + + const root = utils.getRoot(); + expect(root).toHaveClass('custom-split-button'); + }); + + test('renders with custom lgId', () => { + const { utils } = renderSplitButtonWithUtils({ + 'data-lgid': 'custom-split-button', }); - expect(wrapper.classList.contains('split-button-class')).toBe(true); + const root = utils.getRoot(); + expect(root).toHaveAttribute('data-lgid', 'custom-split-button'); + }); + + test('accepts a portalRef', () => { + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + const portalRef = createRef(); + renderSplitButtonWithUtils({ + open: true, + portalContainer, + portalRef, + renderMode: RenderMode.Portal, + }); + expect(portalRef.current).toBeDefined(); + expect(portalRef.current).toBe(portalContainer); }); }); - describe('Primary button', () => { - test('renders the correct label', () => { - const { primaryButton } = renderSplitButton({}); + describe('Menu Functionality', () => { + test('opens menu when trigger is clicked', async () => { + const { utils } = renderSplitButtonWithUtils(); - expect(primaryButton.textContent).toBe(defaultProps.label); + // Menu should not be visible initially + expect(utils.queryMenu()).not.toBeInTheDocument(); + + // Open menu + await utils.openMenu(); + + // Menu should now be visible + expect(utils.getMenu()).toBeInTheDocument(); }); - describe('when disabled', () => { - test('is aria-disabled when disabled is true', () => { - const { primaryButton } = renderSplitButton({ - disabled: true, - }); + test('opens menu when trigger is activated with keyboard', async () => { + const { utils } = renderSplitButtonWithUtils(); - expect(primaryButton.getAttribute('aria-disabled')).toBe('true'); - }); + // Open menu with keyboard + await utils.openMenu({ withKeyboard: true }); - test('click handler does not fire when disabled', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ - onClick, - disabled: true, - }); + // Menu should be visible + expect(utils.getMenu()).toBeInTheDocument(); + }); + + test('displays correct number of menu items', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + expect(menuItems).toHaveLength(4); // 4 items as defined in defaultMenuItems + }); + + test('menu items have correct content', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + expect(menuItems[0]).toHaveTextContent('Edit'); + expect(menuItems[1]).toHaveTextContent('Duplicate'); + expect(menuItems[2]).toHaveTextContent('Delete'); + expect(menuItems[3]).toHaveTextContent('Archive'); + }); + + test('disabled menu items are properly marked', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils(); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + const archiveItem = menuItems[3]; // Archive is disabled + expect(archiveItem).toHaveAttribute('aria-disabled', 'true'); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('closes menu when clicking outside', async () => { + const { utils } = renderSplitButtonWithUtils(); + + // Open menu + await utils.openMenu(); + expect(utils.getMenu()).toBeInTheDocument(); + + // Close menu + // Works correctly in the browser + // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-1453666401 - this needs v13 of testing-library + // TODO: This is not triggered so the test fails + await utils.closeMenu(); + expect(utils.queryMenu()).not.toBeInTheDocument(); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('closes menu when pressing Escape', async () => { + const { utils } = renderSplitButtonWithUtils(); - userEvent.click(primaryButton); - expect(onClick).not.toHaveBeenCalled(); + // Open menu + await utils.openMenu(); + expect(utils.getMenu()).toBeInTheDocument(); + + // Close with keyboard + // Works correctly in the browser + // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-1453666401 - this needs v13 of testing-library + // TODO: This is not triggered so the test fails + await utils.closeMenu({ withKeyboard: true }); + expect(utils.queryMenu()).not.toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + test('primary button click fires onClick handler', () => { + const onClickSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ onClick: onClickSpy }); + + const button = utils.getButton(); + userEvent.click(button); + + expect(onClickSpy).toHaveBeenCalledTimes(1); + }); + + test('menu item selection fires onChange handler', async () => { + const onChangeSpy = jest.fn(); + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + onChange: onChangeSpy, }); - test('click handler does not fire when disabled and rendered as an anchor', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ - onClick, - disabled: true, - as: 'a', - }); + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + userEvent.click(menuItems[0]); // Click "Edit" + + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); - userEvent.click(primaryButton); - expect(onClick).not.toHaveBeenCalled(); + test('disabled menu items do not trigger onChange', async () => { + const onChangeSpy = jest.fn(); + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + onChange: onChangeSpy, }); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + userEvent.click(menuItems[3]); // Click disabled "Archive" item + + expect(onChangeSpy).not.toHaveBeenCalled(); }); - test('fires onClick handler once when clicked', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ - onClick, + test('trigger click fires onTriggerClick handler when provided', async () => { + const onTriggerClickSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ + onTriggerClick: onTriggerClickSpy, }); - userEvent.click(primaryButton); - expect(onClick).toHaveBeenCalledTimes(1); + const trigger = utils.getTrigger(); + userEvent.click(trigger); + + expect(onTriggerClickSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Disabled State', () => { + test('identifies disabled state correctly', () => { + const { utils } = renderSplitButtonWithUtils({ disabled: true }); + expect(utils.isDisabled()).toBeTruthy(); + expect(utils.getButton().getAttribute('aria-disabled')).toBeTruthy(); }); - test('does not fire onClick handler when disabled', () => { - const onClick = jest.fn(); - const { primaryButton } = renderSplitButton({ + test('disabled button does not respond to clicks', () => { + const onClickSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ disabled: true, - onClick, + onClick: onClickSpy, }); - userEvent.click(primaryButton); - expect(onClick).toHaveBeenCalledTimes(0); + const button = utils.getButton(); + userEvent.click(button); + + expect(onClickSpy).not.toHaveBeenCalled(); + }); + + test('disabled trigger does not open menu', async () => { + const { utils } = renderSplitButtonWithUtils({ disabled: true }); + + const trigger = utils.getTrigger(); + userEvent.click(trigger); + + // Menu should not appear + expect(utils.queryMenu()).not.toBeInTheDocument(); + }); + + test('disabled state affects both button and trigger', () => { + const { utils } = renderSplitButtonWithUtils({ disabled: true }); + + const button = utils.getButton(); + const trigger = utils.getTrigger(); + + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(trigger).toHaveAttribute('aria-disabled', 'true'); }); }); - describe('Menu', () => { - test('trigger opens the menu when clicked', () => { - const { menuTrigger, getByTestId } = renderSplitButton({}); + describe('Variants and Styling', () => { + test.each(Object.values(Variant))( + 'renders %s variant correctly', + variant => { + const { utils } = renderSplitButtonWithUtils({ variant }); + + const button = utils.getButton(); + expect(button).toBeInTheDocument(); + expect(button).toBeVisible(); + }, + ); + + test.each(Object.values(Size))('renders %s size correctly', size => { + const { utils } = renderSplitButtonWithUtils({ size }); + + const button = utils.getButton(); + expect(button).toBeInTheDocument(); + expect(button).toBeVisible(); + }); - userEvent.click(menuTrigger); + test('applies dark mode correctly', () => { + const { utils } = renderSplitButtonWithUtils({ darkMode: true }); - const menu = getByTestId(menuLgIds.root); + const button = utils.getButton(); + expect(button).toBeInTheDocument(); + }); + }); + + describe('Menu Positioning', () => { + test.each(Object.values(Align))('handles %s alignment', async align => { + const { utils } = renderSplitButtonWithUtils({ align }); + + await utils.openMenu(); + + const menu = utils.getMenu(); expect(menu).toBeInTheDocument(); }); - test('disabled trigger does not open the menu when clicked', () => { - const { menuTrigger, queryByTestId } = renderSplitButton({ - disabled: true, + test.each(Object.values(Justify))( + 'handles %s justification', + async justify => { + const { utils } = renderSplitButtonWithUtils({ justify }); + + await utils.openMenu(); + + const menu = utils.getMenu(); + expect(menu).toBeInTheDocument(); + }, + ); + }); + + describe('Polymorphic Behavior', () => { + test('renders as anchor when href is provided', () => { + const { utils } = renderSplitButtonWithUtils({ + href: 'https://example.com', }); - userEvent.click(menuTrigger); + const button = utils.getButton(); + expect(button.tagName.toLowerCase()).toBe('a'); + expect(button).toHaveAttribute('href', 'https://example.com'); + }); + + test('renders as button by default', () => { + const { utils } = renderSplitButtonWithUtils(); + + const button = utils.getButton(); + expect(button.tagName.toLowerCase()).toBe('button'); + expect(button).toHaveAttribute('type', 'button'); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('Accepts string as `as` prop', () => { + ; + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('Accepts component as `as` prop', () => { + const As = ({ children }: { children: React.ReactNode }) => ( + <>{children} + ); + render( + , + ); + }); + + // eslint-disable-next-line jest/no-disabled-tests + test.skip('types', () => { + const AnchorLikeWrapper = (props: JSX.IntrinsicElements['a']) => { + return content; + }; - const menu = queryByTestId(menuLgIds.root); - expect(menu).not.toBeInTheDocument(); + <> + + + + + ; }); + }); + + describe('Controlled State', () => { + test('respects controlled open state', async () => { + const setOpenSpy = jest.fn(); + const { utils, rerender } = renderSplitButtonWithUtils({ + open: false, + setOpen: setOpenSpy, + }); - test('has correct menu items', () => { - const { menuTrigger, getByTestId } = renderSplitButton({}); + // Initially closed + expect(utils.queryMenu()).not.toBeInTheDocument(); - userEvent.click(menuTrigger); + // Rerender with open=true + rerender( + , + ); - const menu = getByTestId(menuLgIds.root); - expect(menu.childElementCount).toEqual(4); + // Should now show menu + expect(utils.getMenu()).toBeInTheDocument(); }); - test('accepts a portalRef', () => { - const portalContainer = document.createElement('div'); - document.body.appendChild(portalContainer); - const portalRef = createRef(); - renderSplitButton({ - open: true, - portalContainer, - portalRef, - renderMode: RenderMode.Portal, + test('calls setOpen when trigger is clicked in controlled mode', () => { + const setOpenSpy = jest.fn(); + const { utils } = renderSplitButtonWithUtils({ + open: false, + setOpen: setOpenSpy, }); - expect(portalRef.current).toBeDefined(); - expect(portalRef.current).toBe(portalContainer); + + const trigger = utils.getTrigger(); + userEvent.click(trigger); + + expect(setOpenSpy).toHaveBeenCalledTimes(1); }); + }); + + describe('Edge Cases', () => { + test('handles empty menu items array', () => { + const { utils } = renderSplitButtonWithUtils({ menuItems: [] }); - describe('managed', () => { - test('calls setOpen when triggered', () => { - const setOpen = jest.fn(); - const { menuTrigger } = renderSplitButton({ open: false, setOpen }); + expect(utils.getButton()).toBeInTheDocument(); + expect(utils.getTrigger()).toBeInTheDocument(); + }); - userEvent.click(menuTrigger); - expect(setOpen).toHaveBeenCalledTimes(1); + test('handles single menu item', async () => { + const singleItem = [Single Item]; + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems: singleItem, }); + + await utils.openMenu(); + + const menuItems = getAllByRole('menuitem'); + expect(menuItems).toHaveLength(1); + expect(menuItems[0]).toHaveTextContent('Single Item'); }); - }); - describe('MenuItem', () => { - test('click triggers onChange callback', () => { - const onChange = jest.fn(); - const { menuTrigger, getByTestId } = renderSplitButton({ onChange }); + test('handles long button labels', () => { + const longLabel = + 'This is a very long button label that might cause layout issues'; + const { utils } = renderSplitButtonWithUtils({ label: longLabel }); - userEvent.click(menuTrigger); + const button = utils.getButton(); + expect(button).toHaveTextContent(longLabel); + }); + + test('handles special characters in labels', () => { + const specialLabel = 'Action <>&"\''; + const { utils } = renderSplitButtonWithUtils({ label: specialLabel }); - const menu = getByTestId(menuLgIds.root); - const options = globalGetAllByRole(menu, 'menuitem'); - userEvent.click(options[0]); - expect(onChange).toHaveBeenCalledTimes(1); + const button = utils.getButton(); + expect(button).toHaveTextContent(specialLabel); }); }); - // TODO: Consolidate tests with Menu component describe('Keyboard Interaction', () => { type CloseKeys = 'esc' | 'tab'; const closeKeys: Array> = [['esc'], ['tab']]; @@ -262,85 +529,79 @@ describe('packages/split-button', () => { describe.each(closeKeys)('%s key', key => { test('Closes menu', async () => { - const { openMenu } = renderSplitButton({}); - const { menuEl } = await openMenu(); + const { utils } = renderSplitButtonWithUtils({}); + + await utils.openMenu(); + const menuEl = utils.getMenu(); userEventInteraction(menuEl!, key); await waitForElementToBeRemoved(menuEl); expect(menuEl).not.toBeInTheDocument(); }); + test('Returns focus to trigger {renderMode: "portal"}', async () => { - const { openMenu, menuTrigger } = renderSplitButton({ + const { utils } = renderSplitButtonWithUtils({ renderMode: 'portal', }); - const { menuEl } = await openMenu(); + + await utils.openMenu(); + const menuEl = utils.getMenu(); + const trigger = utils.getTrigger(); userEventInteraction(menuEl!, key); await waitForElementToBeRemoved(menuEl); - expect(menuTrigger).toHaveFocus(); + expect(trigger).toHaveFocus(); }); test('Returns focus to trigger {renderMode: "inline"}', async () => { - const { openMenu, menuTrigger } = renderSplitButton({ + const { utils } = renderSplitButtonWithUtils({ renderMode: 'inline', }); - const { menuEl } = await openMenu(); + + await utils.openMenu(); + const menuEl = utils.getMenu(); + const trigger = utils.getTrigger(); userEventInteraction(menuEl!, key); await waitForElementToBeRemoved(menuEl); - expect(menuTrigger).toHaveFocus(); + expect(trigger).toHaveFocus(); }); }); + }); - type SelectionKeys = 'enter' | 'space'; - const selectionKeys: Array> = [['enter'], ['space']]; - - describe.each(selectionKeys)('%s key', key => { - const onClick = jest.fn(); - const menuItems = [ - - Menu Item With Description - , - - Disabled Menu Item - , - ]; + type SelectionKeys = 'enter' | 'space'; + const selectionKeys: Array> = [['enter'], ['space']]; + + describe.each(selectionKeys)('%s key', key => { + const onClick = jest.fn(); + const menuItems = [ + + Menu Item With Description + , + + Disabled Menu Item + , + ]; + + afterEach(() => { + onClick.mockReset(); + }); - afterEach(() => { - onClick.mockReset(); + test('Fires the click handler of the highlighted item', async () => { + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); - test('Fires the click handler of the highlighted item', async () => { - const { openMenu } = renderSplitButton({ - menuItems, - }); - const { menuItemElements } = await openMenu({ - withKeyboard: true, - }); - await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); - - userEvent.type(menuItemElements?.[0]!, `{${key}}`); - expect(onClick).toHaveBeenCalled(); + await utils.openMenu({ + withKeyboard: true, }); - // eslint-disable-next-line jest/no-disabled-tests - test.skip('Closes the menu', async () => { - // Works correctly in the browser - // https://github.com/testing-library/react-testing-library/issues/269#issuecomment-1453666401 - this needs v13 of testing-library - // TODO: This is not triggered so the test fails - const { openMenu, menuTrigger } = renderSplitButton({ - menuItems, - }); - const { menuEl, menuItemElements } = await openMenu(); - userEvent.type(menuItemElements?.[0]!, `{${key}}`); + const menuItemElements = getAllByRole('menuitem'); - expect(menuTrigger).toHaveFocus(); - await waitForElementToBeRemoved(menuEl); - }); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); + + userEvent.type(menuItemElements?.[0]!, `{${key}}`); + expect(onClick).toHaveBeenCalled(); }); describe('Arrow keys', () => { @@ -353,10 +614,14 @@ describe('packages/split-button', () => { describe('Down arrow', () => { test('highlights the next option in the menu', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); userEvent.type(menuEl!, '{arrowdown}'); @@ -364,10 +629,14 @@ describe('packages/split-button', () => { }); test('cycles highlight to the top', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); for (let i = 0; i < menuItemElements.length; i++) { @@ -380,10 +649,14 @@ describe('packages/split-button', () => { describe('Up arrow', () => { test('highlights the previous option in the menu', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); userEvent.type(menuEl!, '{arrowdown}'); @@ -394,10 +667,14 @@ describe('packages/split-button', () => { }); test('cycles highlight to the bottom', async () => { - const { openMenu } = renderSplitButton({ menuItems }); - const { menuEl, menuItemElements } = await openMenu({ - withKeyboard: true, + const { utils, getAllByRole } = renderSplitButtonWithUtils({ + menuItems, }); + await utils.openMenu({ withKeyboard: true }); + + const menuEl = utils.getMenu(); + const menuItemElements = getAllByRole('menuitem'); + await waitFor(() => expect(menuItemElements[0]).toHaveFocus()); const lastOption = menuItemElements[menuItemElements.length - 1]; @@ -407,97 +684,4 @@ describe('packages/split-button', () => { }); }); }); - - /* eslint-disable jest/no-disabled-tests */ - describe('Types behave as expected', () => { - test.skip('Accepts base props', () => { - <> - - Menu Item, - - Disabled Menu Item - , - - , Menu Item With Description - , - ]} - /> - {}} - disabled={true} - size="default" - variant="default" - darkMode={true} - align="top" - justify="start" - className="test" - renderMode="portal" - portalContainer={{} as HTMLElement} - scrollContainer={{} as HTMLElement} - portalClassName="classname" - data-testid="test-id" - open={false} - triggerAriaLabel="im the trigger silly" - /> - {/* @ts-expect-error - expects label and menuItems*/} - - {/* @ts-expect-error - expects menuItems */} - - {/* @ts-expect-error - expects label */} - - ; - }); - - test.skip('Accepts string as `as` prop', () => { - ; - }); - - test.skip('Accepts component as `as` prop', () => { - const As = ({ children }: { children: React.ReactNode }) => ( - <>{children} - ); - render( - , - ); - }); - - test.skip('types', () => { - const AnchorLikeWrapper = (props: JSX.IntrinsicElements['a']) => { - return content; - }; - - <> - - - - - ; - }); - }); }); diff --git a/packages/split-button/src/index.ts b/packages/split-button/src/index.ts index 21683a890f..59e5a5eaee 100644 --- a/packages/split-button/src/index.ts +++ b/packages/split-button/src/index.ts @@ -7,3 +7,6 @@ export { Variant, } from './SplitButton'; export { DEFAULT_LGID_ROOT, getLgIds, type GetLgIdsReturnType } from './utils'; + +// Testing utilities +export { getTestUtils, type GetTestUtilsReturnType } from './testing'; diff --git a/packages/split-button/src/testing/getTestUtils.spec.tsx b/packages/split-button/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..2a03c7b913 --- /dev/null +++ b/packages/split-button/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { MenuItem } from '@leafygreen-ui/menu'; + +import { SplitButton } from '../SplitButton'; + +import { getTestUtils } from './getTestUtils'; + +const menuItems = [ + Menu Item 1, + Menu Item 2, + + Disabled Menu Item + , +]; + +const defaultProps = { + label: 'Test SplitButton', + menuItems, +}; + +describe('packages/split-button/testing/getTestUtils', () => { + describe('getTestUtils', () => { + test('returns all expected utility functions', () => { + render(); + const utils = getTestUtils(); + + // Check all utility functions exist + expect(utils.findRoot).toBeDefined(); + expect(utils.getRoot).toBeDefined(); + expect(utils.queryRoot).toBeDefined(); + + expect(utils.findButton).toBeDefined(); + expect(utils.getButton).toBeDefined(); + expect(utils.queryButton).toBeDefined(); + + expect(utils.findTrigger).toBeDefined(); + expect(utils.getTrigger).toBeDefined(); + expect(utils.queryTrigger).toBeDefined(); + + expect(utils.findMenu).toBeDefined(); + expect(utils.getMenu).toBeDefined(); + expect(utils.queryMenu).toBeDefined(); + + expect(utils.findMenuItems).toBeDefined(); + expect(utils.getMenuItems).toBeDefined(); + expect(utils.queryMenuItems).toBeDefined(); + + expect(utils.isDisabled).toBeDefined(); + expect(utils.openMenu).toBeDefined(); + expect(utils.closeMenu).toBeDefined(); + }); + + test('gets root element correctly', () => { + render(); + const { getRoot } = getTestUtils(); + + const root = getRoot(); + expect(root).toBeInTheDocument(); + expect(root.getAttribute('data-lgid')).toBe('lg-split_button'); + }); + + test('gets button element correctly', () => { + render(); + const { getButton } = getTestUtils(); + + const button = getButton(); + expect(button).toBeInTheDocument(); + expect(button.textContent).toBe('Test SplitButton'); + }); + + test('gets trigger element correctly', () => { + render(); + const { getTrigger } = getTestUtils(); + + const trigger = getTrigger(); + expect(trigger).toBeInTheDocument(); + expect(trigger.getAttribute('aria-haspopup')).toBe('true'); + }); + + test('detects disabled state correctly', () => { + const { rerender } = render(); + const { isDisabled } = getTestUtils(); + + // Initially not disabled + expect(isDisabled()).toBe(false); + + // Rerender with disabled prop + rerender(); + expect(isDisabled()).toBe(true); + }); + + test('opens menu correctly', async () => { + render(); + const { openMenu, findMenu } = getTestUtils(); + + // Menu should not be visible initially + expect( + document.querySelector('[data-lgid="lg-split_button-menu"]'), + ).not.toBeInTheDocument(); + + // Open menu + await openMenu(); + + // Menu should now be visible + const menu = await findMenu(); + expect(menu).toBeInTheDocument(); + }); + + test('works with custom lgId', () => { + render( + , + ); + const { getRoot } = getTestUtils('lg-custom-split-button'); + + const root = getRoot(); + expect(root).toBeInTheDocument(); + expect(root.getAttribute('data-lgid')).toBe('lg-custom-split-button'); + }); + }); +}); diff --git a/packages/split-button/src/testing/getTestUtils.ts b/packages/split-button/src/testing/getTestUtils.ts new file mode 100644 index 0000000000..852f05d063 --- /dev/null +++ b/packages/split-button/src/testing/getTestUtils.ts @@ -0,0 +1,132 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { LgIdString } from '@leafygreen-ui/lib'; +import { getLgIds as getMenuLgIds } from '@leafygreen-ui/menu'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { GetTestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): GetTestUtilsReturnType => { + const lgIds = getLgIds(lgId); + const menuLgIds = getMenuLgIds(lgIds.menu); + + // Root/Wrapper utilities + const findRoot = () => findByLgId!(lgIds.root); + const getRoot = () => getByLgId!(lgIds.root); + const queryRoot = () => queryByLgId!(lgIds.root); + + // Primary button utilities + const findButton = () => findByLgId!(lgIds.button); + const getButton = () => getByLgId!(lgIds.button); + const queryButton = () => queryByLgId!(lgIds.button); + + // Menu trigger utilities + const findTrigger = () => findByLgId!(lgIds.trigger); + const getTrigger = () => getByLgId!(lgIds.trigger); + const queryTrigger = () => queryByLgId!(lgIds.trigger); + + // Menu utilities + const findMenu = () => findByLgId!(menuLgIds.root); + const getMenu = () => getByLgId!(menuLgIds.root); + const queryMenu = () => queryByLgId!(menuLgIds.root); + + // Menu items utilities + const findMenuItems = async (findAllByRole?: Function) => { + const menuEl = await findMenu(); + return findAllByRole?.(menuEl, 'menuitem'); + }; + + const getMenuItems = (getAllByRole?: Function) => { + const menuEl = getMenu(); + return getAllByRole?.(menuEl, 'menuitem'); + }; + + const queryMenuItems = (queryAllByRole?: Function) => { + const menuEl = queryMenu(); + if (!menuEl) return []; + return queryAllByRole?.(menuEl, 'menuitem'); + }; + + /** + * Returns the disabled state of the SplitButton. + * Checks both the primary button and trigger button disabled states. + */ + const isDisabled = () => { + const button = getButton(); + const trigger = getTrigger(); + + const buttonDisabled = button.getAttribute('aria-disabled') === 'true'; + const triggerDisabled = trigger.getAttribute('aria-disabled') === 'true'; + + return buttonDisabled && triggerDisabled; + }; + + /** + * Opens the menu by clicking or pressing Enter on the trigger + */ + const openMenu = async (options: { withKeyboard?: boolean } = {}) => { + const trigger = getTrigger(); + + if (options.withKeyboard) { + trigger.focus(); + await userEvent.keyboard('{enter}'); + } else { + await userEvent.click(trigger); + } + + // Wait for menu to appear + const menuEl = await findMenu(); + + // Manually fire transition events since JSDOM doesn't automatically fire them + fireEvent.transitionEnd(menuEl); + }; + + /** + * Closes the menu by pressing Escape or clicking outside + */ + const closeMenu = async (options: { withKeyboard?: boolean } = {}) => { + if (options.withKeyboard) { + await userEvent.keyboard('{escape}'); + } else { + // Click outside the menu to close it + await userEvent.click(document.body); + } + + // Wait for menu to disappear - we'll use a try/catch since the menu might not exist + try { + const menuEl = queryMenu(); + + if (menuEl) { + fireEvent.transitionEnd(menuEl); + } + } catch { + // Menu already closed + } + }; + + return { + findRoot, + getRoot, + queryRoot, + findButton, + getButton, + queryButton, + findTrigger, + getTrigger, + queryTrigger, + findMenu, + getMenu, + queryMenu, + findMenuItems, + getMenuItems, + queryMenuItems, + isDisabled, + openMenu, + closeMenu, + }; +}; diff --git a/packages/split-button/src/testing/getTestUtils.types.ts b/packages/split-button/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..8331103bbc --- /dev/null +++ b/packages/split-button/src/testing/getTestUtils.types.ts @@ -0,0 +1,27 @@ +import { FormUtils } from '@lg-tools/test-harnesses'; + +export interface GetTestUtilsReturnType { + findRoot: () => Promise; + getRoot: () => T; + queryRoot: () => T | null; + + findButton: () => Promise; + getButton: () => HTMLButtonElement; + queryButton: () => HTMLButtonElement | null; + + findTrigger: () => Promise; + getTrigger: () => HTMLButtonElement; + queryTrigger: () => HTMLButtonElement | null; + + findMenu: () => Promise; + getMenu: () => HTMLElement; + queryMenu: () => HTMLElement | null; + + findMenuItems: () => Promise>; + getMenuItems: () => Array; + queryMenuItems: () => Array; + + isDisabled: FormUtils['isDisabled']; + openMenu: (options?: { withKeyboard?: boolean }) => Promise; + closeMenu: (options?: { withKeyboard?: boolean }) => Promise; +} diff --git a/packages/split-button/src/testing/index.ts b/packages/split-button/src/testing/index.ts new file mode 100644 index 0000000000..31794560e1 --- /dev/null +++ b/packages/split-button/src/testing/index.ts @@ -0,0 +1,2 @@ +export * from './getTestUtils'; +export * from './getTestUtils.types'; diff --git a/packages/split-button/tsconfig.json b/packages/split-button/tsconfig.json index d66eb66219..700e44aaf2 100644 --- a/packages/split-button/tsconfig.json +++ b/packages/split-button/tsconfig.json @@ -50,6 +50,9 @@ }, { "path": "../leafygreen-provider" + }, + { + "path": "../../tools/test-harnesses" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8293b7ed0..b99c409743 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3079,6 +3079,9 @@ importers: '@leafygreen-ui/tokens': specifier: workspace:^ version: link:../tokens + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses devDependencies: '@lg-tools/build': specifier: workspace:^