import React from 'react'; import { configure, queryAllByAttribute, queryAllByTestId, queryByAttribute, queryByText, render, RenderResult, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import chalk from 'chalk'; import isArray from 'lodash/isArray'; import isNull from 'lodash/isNull'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { BaseComboboxProps, ComboboxMultiselectProps } from '../Combobox'; import { OptionObject } from '../ComboboxOption'; import { Combobox, ComboboxGroup, ComboboxOption } from '..'; export interface NestedObject { label: string; children: Array; } export type Select = 'single' | 'multiple'; type renderComboboxProps = { options?: Array; } & BaseComboboxProps & ComboboxMultiselectProps; export const defaultOptions: Array = [ { value: 'apple', displayName: 'Apple', isDisabled: false, description: 'Description text', }, { value: 'banana', displayName: 'Banana', isDisabled: false, }, { value: 'carrot', displayName: 'Carrot', isDisabled: false, }, ]; export const groupedOptions: Array = [ { label: 'Fruit', children: [ { value: 'apple', displayName: 'Apple', isDisabled: false, }, { value: 'banana', displayName: 'Banana', isDisabled: false, }, ], }, { label: 'Vegetables', children: [ { value: 'carrot', displayName: 'Carrot', isDisabled: false, }, { value: 'eggplant', displayName: 'Eggplant', isDisabled: false, }, ], }, ]; /** * @param props Combobox props * @returns Combobox JSX */ export const getComboboxJSX = (props?: renderComboboxProps) => { const isNested = (object: any): object is NestedObject => object.label && object.children; const renderOption = (option: NestedObject | OptionObject | string) => { if (isNested(option)) { return ( {option.children.map(renderOption)} ); } else { const isTypeofString = typeof option === 'string'; const value = isTypeofString ? option : option.value; const displayName = isTypeofString ? undefined : option.displayName; const isDisabled = isTypeofString ? false : option.isDisabled; const description = isTypeofString ? undefined : option.description; const onClick = isTypeofString ? undefined : option.onClick; return ( ); } }; const label = props?.label ?? 'Some label'; const options = props?.options ?? defaultOptions; const children = props?.children; return ( {children ?? options.map(renderOption)} ); }; /** * Renders a combobox * @param select `'single' | 'multiple'` * @param props `renderComboboxProps` * @returns Object of combobox elements & utility functions */ export function renderCombobox( select: T = 'single' as T, props?: renderComboboxProps, ): RenderResult & Record { const multiselect = select === 'multiple'; const options = props?.options || defaultOptions; props = { options, multiselect, ...props }; const renderResult = render(getComboboxJSX(props)); const containerEl = renderResult.getByTestId('combobox-container'); const labelEl = containerEl.getElementsByTagName('label')[0]; const comboboxEl = renderResult.getByRole('combobox'); const inputEl = containerEl.getElementsByTagName('input')[0]; const clearButtonEl = renderResult.queryByLabelText('Clear selection'); /** * Since menu elements won't exist until component is interacted with, * call this after opening the menu. * @returns Object of menu elements */ function getMenuElements() { const menuContainerEl = renderResult.queryByRole('listbox'); const popoverEl = menuContainerEl?.firstChild; const menuEl = menuContainerEl?.getElementsByTagName('ul')[0]; const optionElements = menuContainerEl?.getElementsByTagName('li'); const selectedElements = menuEl ? select === 'single' ? queryByAttribute('aria-selected', menuEl, 'true') : queryAllByAttribute('aria-selected', menuEl, 'true') : undefined; return { menuContainerEl, popoverEl, menuEl, optionElements, selectedElements: selectedElements as | (T extends 'single' ? HTMLElement : Array) | null, }; } /** * Opens the menu by simulating a click on the combobox. * @returns Object of menu elements */ const openMenu = () => { userEvent.click(inputEl); return getMenuElements(); }; /** * Rerenders the combobox with new props * @param newProps * @returns */ const rerenderCombobox = (newProps: renderComboboxProps) => { const rerenderProps = { ...props, ...newProps }; return renderResult.rerender( getComboboxJSX(rerenderProps as renderComboboxProps), ); }; /** * @returns all chip elements */ function queryAllChips(): Array { return queryAllByTestId(containerEl, 'lg-combobox-chip'); } /** * Get the chip(s) with the provided display name(s) * @param names: `string` | `Array` * @returns A single HTMLElement or array of HTMLElements */ function queryChipsByName(names: string): HTMLElement | null; function queryChipsByName(names: Array): Array | null; function queryChipsByName( names: string | Array, ): HTMLElement | Array | null { if (typeof names === 'string') { const span = queryByText(comboboxEl, names); return span ? span.parentElement : null; } else { const spans = names .map((name: any) => queryByText(comboboxEl, name)) .filter(span => !isNull(span)) .map(span => span?.parentElement); return spans.length > 0 ? (spans as Array) : null; } } function queryChipsByIndex(index: number): HTMLElement | null; function queryChipsByIndex(index: 'first' | 'last'): HTMLElement | null; function queryChipsByIndex(index: Array): Array | null; function queryChipsByIndex( index: 'first' | 'last' | number | Array, ): HTMLElement | Array | null { const allChips = queryAllChips(); if (allChips.length > 0) { if (typeof index === 'number' && index <= allChips.length) { return allChips[index]; } else if (typeof index === 'string') { return index === 'first' ? allChips[0] : allChips[allChips.length - 1]; } else if (isArray(index) && index.every(i => i <= allChips.length)) { return index.map(i => allChips[i]); } } return null; } return { ...renderResult, rerenderCombobox, queryChipsByName, queryChipsByIndex, queryAllChips, getMenuElements, openMenu, containerEl, labelEl, comboboxEl, inputEl, clearButtonEl, }; } /** * Conditionally runs a test * @param condition * @returns `test` */ export const testif = (condition: boolean) => (condition ? test : test.skip); configure({ getElementError: message => new Error(message ?? ''), }); expect.extend({ toContainFocus(received: HTMLElement) { return received.contains(document.activeElement) ? { pass: true, message: () => `\t Expected element not to contain focus: \n\t\t ${chalk.red( received.outerHTML, )} \n\t Element with focus: \n\t\t ${chalk.blue( // @ts-ignore document.activeElement?.outerHTML, )}`, } : { pass: false, message: () => `\t Expected element to contain focus: \n\t\t ${chalk.green( received.outerHTML, )} \n\t Element with focus: \n\t\t ${chalk.red( // @ts-ignore document.activeElement?.outerHTML, )}`, }; }, }); declare global { namespace jest { interface Matchers { toContainFocus(): R; } } }