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 [
- ,
- ,
- ,
- ,
- ];
+import { getTestUtils } from '../testing';
+
+import { SplitButton } from './SplitButton';
+import { Align, Justify, Variant } from './SplitButton.types';
+
+// Test data
+const defaultMenuItems = [
+ ,
+ ,
+ ,
+ ,
+];
+
+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 = [];
+ 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 = [
- ,
- ,
- ];
+ type SelectionKeys = 'enter' | 'space';
+ const selectionKeys: Array> = [['enter'], ['space']];
+
+ describe.each(selectionKeys)('%s key', key => {
+ const onClick = jest.fn();
+ const menuItems = [
+ ,
+ ,
+ ];
+
+ 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={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 = [
+ ,
+ ,
+ ,
+];
+
+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:^