diff --git a/packages/vkui/src/components/ChipsInput/Readme.md b/packages/vkui/src/components/ChipsInput/Readme.md index c51cef8da9..fab35ae67e 100644 --- a/packages/vkui/src/components/ChipsInput/Readme.md +++ b/packages/vkui/src/components/ChipsInput/Readme.md @@ -63,7 +63,7 @@ const Example = () => { diff --git a/packages/vkui/src/components/ChipsInput/useChipsInput.ts b/packages/vkui/src/components/ChipsInput/useChipsInput.ts index 638df2d271..42654d5fec 100644 --- a/packages/vkui/src/components/ChipsInput/useChipsInput.ts +++ b/packages/vkui/src/components/ChipsInput/useChipsInput.ts @@ -104,6 +104,7 @@ export const useChipsInput = ({ ); const clearInput = React.useCallback(() => { + /* istanbul ignore if */ if (!inputRef.current) { return; } diff --git a/packages/vkui/src/components/ChipsInputBase/Chip/Chip.test.tsx b/packages/vkui/src/components/ChipsInputBase/Chip/Chip.test.tsx index 767b4b41c4..e9d0c1c781 100644 --- a/packages/vkui/src/components/ChipsInputBase/Chip/Chip.test.tsx +++ b/packages/vkui/src/components/ChipsInputBase/Chip/Chip.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { render, screen } from '@testing-library/react'; -import { baselineComponent, userEvent } from '../../../testing/utils'; +import { act, render, screen } from '@testing-library/react'; +import { baselineComponent, fakeTimers, userEvent } from '../../../testing/utils'; import { Chip } from './Chip'; -describe('Chip', () => { +describe(Chip, () => { baselineComponent(Chip, { // TODO [a11y]: "Certain ARIA roles must be contained by particular parents (aria-required-parent)" // https://dequeuniversity.com/rules/axe/4.5/aria-required-parent?application=axeAPI @@ -12,6 +12,8 @@ describe('Chip', () => { a11y: false, }); + fakeTimers(); + it('removes chip on onRemove click', async () => { const onRemove = jest.fn(); @@ -21,8 +23,45 @@ describe('Chip', () => { , ); - await userEvent.click(screen.getByRole('button')); + await act(() => userEvent.click(screen.getByRole('button'))); expect(onRemove).toHaveBeenCalled(); }); + + it('hides remove button if readOnly', async () => { + const result = render(Белый); + + expect(screen.getByRole('button')).toBeTruthy(); + + result.rerender( + + Белый + , + ); + expect(() => screen.getByRole('button')).toThrow(); + }); + + it.each([{ readOnly: false }, { readOnly: true }])( + 'calls user events (`readOnly` prop is `$readOnly`)', + async ({ readOnly }) => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + render( + , + ); + + await act(() => userEvent.tab()); + await act(() => userEvent.tab({ shift: true })); + + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + }, + ); }); diff --git a/packages/vkui/src/components/ChipsInputBase/Chip/Chip.tsx b/packages/vkui/src/components/ChipsInputBase/Chip/Chip.tsx index 381587250b..29a80412ff 100644 --- a/packages/vkui/src/components/ChipsInputBase/Chip/Chip.tsx +++ b/packages/vkui/src/components/ChipsInputBase/Chip/Chip.tsx @@ -27,6 +27,7 @@ export const Chip = ({ before, after, disabled, + readOnly, children, className, onFocus: onFocusProp, @@ -38,17 +39,17 @@ export const Chip = ({ const focusVisibleClassName = useFocusVisibleClassName({ focusVisible }); const handleFocus = (event: React.FocusEvent) => { - onFocus(event); if (onFocusProp) { onFocusProp(event); } + onFocus(event); }; const handleBlur = (event: React.FocusEvent) => { - onBlur(event); if (onBlurProp) { onBlurProp(event); } + onBlur(event); }; const onRemoveWrapper = React.useCallback( @@ -68,6 +69,7 @@ export const Chip = ({ focusVisibleClassName, className, )} + aria-readonly={readOnly} aria-disabled={disabled} onFocus={disabled ? undefined : handleFocus} onBlur={disabled ? undefined : handleBlur} @@ -77,7 +79,7 @@ export const Chip = ({ {children} {hasReactNode(after) &&
{after}
} - {removable && ( + {!readOnly && removable && (
diff --git a/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.module.css b/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.module.css index c590b86fec..6e986379e6 100644 --- a/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.module.css +++ b/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.module.css @@ -33,7 +33,7 @@ appearance: none; } -.ChipsInputBase__el:focus { +.ChipsInputBase__el:not(:read-only):focus { min-inline-size: 64px; } diff --git a/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.test.tsx b/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.test.tsx index 7e00c089eb..30c2685ab9 100644 --- a/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.test.tsx +++ b/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; +import { act, render, within } from '@testing-library/react'; import { baselineComponent, userEvent, withRegExp } from '../../testing/utils'; import { ChipsInputBase } from './ChipsInputBase'; -import type { ChipOption, ChipsInputBasePrivateProps } from './types'; +import type { ChipsInputBasePrivateProps } from './types'; const ChipsInputBaseTest = ({ inputValue: inputValueProp, @@ -22,10 +22,13 @@ const ChipsInputBaseTest = ({ ); }; -const TEST_OPTION = { value: 'red', label: 'Красный' }; -const chipsInputValue: ChipOption[] = [TEST_OPTION]; +const RED_OPTION = { value: 'red', label: 'Красный' }; -describe('ChipsInputBase', () => { +const BLUE_OPTION = { value: 'blue', label: 'Синий' }; + +const YELLOW_OPTION = { value: 'yellow', label: 'Жёлтый' }; + +describe(ChipsInputBase, () => { baselineComponent(ChipsInputBaseTest, { // доступность должна быть реализована в обёртках над ChipsInputBase a11y: false, @@ -49,7 +52,7 @@ describe('ChipsInputBase', () => { it('renders values passed to it', () => { const result = render( , @@ -57,7 +60,7 @@ describe('ChipsInputBase', () => { expect(result.queryByText('Красный')).not.toBeNull(); }); - it('adds chips', async () => { + it('adds chip', async () => { const result = render( { onRemoveChipOption={onRemoveChipOption} />, ); - await userEvent.type(result.getByTestId('chips-input'), 'Красный{enter}'); + await act(() => userEvent.type(result.getByTestId('chips-input'), 'Красный{enter}')); expect(onAddChipOption).toHaveBeenCalledWith('Красный'); }); - it('focuses to chip on hitting backspace', async () => { + it('adds value on blur event if `addOnBlur` is `true`', async () => { const result = render( , ); - const chipsInputLocator = result.getByTestId('chips-input'); - await userEvent.type(chipsInputLocator, '{backspace}'); - expect(chipsInputLocator).toHaveFocus(); - await userEvent.type(chipsInputLocator, '{backspace}'); - expect(chipsInputLocator.previousSibling).toHaveFocus(); + await act(() => userEvent.type(result.getByTestId('chips-input'), 'Красный')); + await act(() => userEvent.click(document.body)); + expect(onAddChipOption).toHaveBeenCalledWith('Красный'); }); - it.each(['delete', 'backspace'])( - 'does not delete chips on hitting "%s" key in readonly mode', - async (type) => { - const result = render( - , - ); - const chipEl = result.getByRole('option', { name: withRegExp(TEST_OPTION.label) }); - await userEvent.click(chipEl); - await userEvent.type(chipEl, `{${type}}`); - expect(onRemoveChipOption).not.toHaveBeenCalled(); - }, - ); - - it('focuses ChipsInputBase on surrounding container click', async () => { + it('removes chip with icon button', async () => { const result = render( , ); - await userEvent.click(result.getByTestId('chips-input')); - expect(result.getByTestId('chips-input')).toHaveFocus(); + const chipRedLocator = result.getByRole('option', { name: withRegExp(RED_OPTION.label) }); + const removeButton = within(chipRedLocator).getByRole('button'); + await act(() => userEvent.click(removeButton)); + expect(onRemoveChipOption).toHaveBeenCalledWith(RED_OPTION.value); }); - it('focuses on chip after click', async () => { + it.each(['Delete', 'Backspace'])('removes chip when pressing {%s}', async (type) => { const result = render( , ); - const chipEl = result.getByRole('option', { name: withRegExp(TEST_OPTION.label) }); - await userEvent.click(chipEl); - expect(chipEl).toHaveFocus(); + await act(() => userEvent.tab()); + await act(() => + userEvent.type( + result.getByRole('option', { name: withRegExp(RED_OPTION.label) }), + `{${type}}`, + ), + ); + expect(onRemoveChipOption).toHaveBeenCalledWith(RED_OPTION.value); }); - it.todo('focuses on input field after removing only one chip'); + it.each(['Delete', 'Backspace'])( + 'does not delete chips when pressing {%s} in readonly mode', + async (type) => { + const result = render( + , + ); + await act(() => userEvent.tab()); + await act(() => + userEvent.type( + result.getByRole('option', { name: withRegExp(RED_OPTION.label) }), + `{${type}}`, + ), + ); + expect(onRemoveChipOption).not.toHaveBeenCalled(); + }, + ); - it.todo('focuses on nearest chip after removing one of chip'); + describe('focus', () => { + it('focuses on input field after clicking to container', async () => { + const result = render( + , + ); + const containerEl = result.getByRole('listbox').closest('div')!; + await act(() => userEvent.click(containerEl)); + expect(result.getByTestId('chips-input')).toHaveFocus(); + }); - it.todo('focuses on last focused chip after focus to component'); + it('focuses to first chip after clicking to container', async () => { + const result = render( + , + ); + const containerEl = result.getByRole('listbox').closest('div')!; + await act(() => userEvent.click(containerEl)); + expect(result.getByRole('option', { name: withRegExp(RED_OPTION.label) })).toHaveFocus(); + }); - it.todo('focuses on last focused chip after enter to component with hitting "tab" key'); + it('focuses on input field on clicking on them', async () => { + const result = render( + , + ); + await act(() => userEvent.click(result.getByTestId('chips-input'))); + expect(result.getByTestId('chips-input')).toHaveFocus(); + }); - it.todo('focuses on last focused chip after hitting "shift + tab" key'); + it('focuses on chip after clicking on them', async () => { + const result = render( + , + ); + const chipLocator = result.getByRole('option', { name: withRegExp(RED_OPTION.label) }); + await act(() => userEvent.click(chipLocator)); + expect(chipLocator).toHaveFocus(); + }); - it.todo('navigates between chip with arrow buttons'); + it('focuses to last chip when pressing {Backspace} in input field', async () => { + const result = render( + , + ); + const chipsInputLocator = result.getByTestId('chips-input'); - it('add value on blur event if addOnBlur=true', async () => { - const result = render( - , - ); - await userEvent.type(result.getByTestId('chips-input'), 'Красный'); - await userEvent.click(document.body); - expect(onAddChipOption).toHaveBeenCalledWith('Красный'); + await act(() => userEvent.type(chipsInputLocator, '{Backspace}')); + expect(chipsInputLocator).toHaveFocus(); + + await act(() => userEvent.type(chipsInputLocator, '{Backspace}')); + expect(chipsInputLocator.previousSibling).toHaveFocus(); + }); + + it('navigates between chips with arrow buttons (it should be cycle)', async () => { + const value = [RED_OPTION, BLUE_OPTION, YELLOW_OPTION]; + const result = render( + , + ); + const [chipRedLocator, chipBlueLocator, chipYellowLocator] = value.map(({ label }) => + result.getByRole('option', { name: withRegExp(label) }), + ); + + await act(() => userEvent.type(chipRedLocator, '{ArrowRight}')); + expect(chipBlueLocator).toHaveFocus(); + + await act(() => userEvent.type(chipBlueLocator, '{ArrowRight}')); + expect(chipYellowLocator).toHaveFocus(); + + await act(() => userEvent.type(chipYellowLocator, '{ArrowRight}')); + expect(chipRedLocator).toHaveFocus(); + + await act(() => userEvent.type(chipRedLocator, '{ArrowLeft}')); + expect(chipYellowLocator).toHaveFocus(); + + await act(() => userEvent.type(chipYellowLocator, '{ArrowLeft}')); + expect(chipBlueLocator).toHaveFocus(); + + await act(() => userEvent.type(chipBlueLocator, '{ArrowRight}')); + expect(chipYellowLocator).toHaveFocus(); + }); + + it('navigates with {Tab}', async () => { + const value = [RED_OPTION, BLUE_OPTION, YELLOW_OPTION]; + const result = render( + , + ); + const chipsInputLocator = result.getByTestId('chips-input'); + const [chipRedLocator, chipBlueLocator] = value.map(({ label }) => + result.getByRole('option', { name: withRegExp(label) }), + ); + + await act(() => userEvent.tab()); + expect(chipRedLocator).toHaveFocus(); + + await act(() => userEvent.tab()); + expect(chipsInputLocator).toHaveFocus(); + + await act(() => userEvent.tab({ shift: true })); + expect(chipRedLocator).toHaveFocus(); + + await act(() => userEvent.type(chipRedLocator, '{ArrowRight}')); + expect(chipBlueLocator).toHaveFocus(); + + await act(() => userEvent.tab()); + expect(chipsInputLocator).toHaveFocus(); + + await act(() => userEvent.tab({ shift: true })); + expect(chipBlueLocator).toHaveFocus(); + + await act(() => userEvent.tab({ shift: true })); + expect(document.body).toHaveFocus(); + + await act(() => userEvent.tab()); + expect(chipBlueLocator).toHaveFocus(); + }); + + it.each([ + { value: [RED_OPTION], description: 'input field' }, + { value: [RED_OPTION, BLUE_OPTION, YELLOW_OPTION], description: 'next chip' }, + { value: [YELLOW_OPTION, RED_OPTION, BLUE_OPTION], description: 'next chip (last)' }, + { value: [YELLOW_OPTION, BLUE_OPTION, RED_OPTION], description: 'prev chip' }, + ])('focuses on $description after remove chip', async ({ value }) => { + const result = render( + , + ); + const chipRedLocator = result.getByRole('option', { name: withRegExp(RED_OPTION.label) }); + await act(() => userEvent.type(chipRedLocator, '{Delete}')); + const nextLocatorWithFocus = + value.length <= 1 + ? result.getByTestId('chips-input') + : result.getByRole('option', { name: withRegExp(BLUE_OPTION.label) }); + expect(nextLocatorWithFocus).toHaveFocus(); + }); }); + + it.each([{ readOnly: false }, { readOnly: true }])( + 'calls user events (`readOnly` prop is `$readOnly`)', + async ({ readOnly }) => { + const onBlur = jest.fn(); + render( + , + ); + + await act(() => userEvent.tab()); + await act(() => userEvent.tab({ shift: true })); + + expect(onBlur).toHaveBeenCalled(); + }, + ); }); diff --git a/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.tsx b/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.tsx index 039f64bd61..1ddc56d203 100644 --- a/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.tsx +++ b/packages/vkui/src/components/ChipsInputBase/ChipsInputBase.tsx @@ -4,7 +4,11 @@ import { isHTMLElement } from '@vkontakte/vkui-floating-ui/utils/dom'; import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useExternRef } from '../../hooks/useExternRef'; import { getHorizontalFocusGoTo, Keys } from '../../lib/accessibility'; -import { contains as checkTargetIsInputEl } from '../../lib/dom'; +import { + contains as checkTargetIsInputEl, + contains, + getActiveElementByAnotherElement, +} from '../../lib/dom'; import { FormField } from '../FormField/FormField'; import { Text } from '../Typography/Text/Text'; import { DEFAULT_INPUT_VALUE, DEFAULT_VALUE, renderChipDefault } from './constants'; @@ -58,11 +62,10 @@ export const ChipsInputBase = ({ const valueLength = value.length; const withPlaceholder = valueLength === 0; - const isDisabled = disabled || readOnly; - const [chipFocusedIndex, setChipFocusedIndex] = React.useState(0); + const [lastFocusedChipOptionIndex, setLastFocusedChipOptionIndex] = React.useState(0); const resetChipOptionFocusToInputEl = (inputEl: HTMLInputElement) => { - setChipFocusedIndex(0); + setLastFocusedChipOptionIndex(0); inputEl.focus(); }; @@ -76,14 +79,13 @@ export const ChipsInputBase = ({ const foundEl = listboxEl.querySelector(`[data-index="${index}"]`); if (foundEl) { - setChipFocusedIndex(index); + setLastFocusedChipOptionIndex(index); foundEl.focus(); - } else { - setChipFocusedIndex(0); } }; const removeChipOption = (o: O | ChipOptionValue, index: number) => { + /* istanbul ignore if: невозможный кейс (в SSR вызова этой функции не будет) */ if (!inputRef.current || !listboxRef.current) { return; } @@ -103,20 +105,18 @@ export const ChipsInputBase = ({ const handleListboxKeyDown = (event: React.KeyboardEvent) => { const targetEl = event.target; - if ( - event.defaultPrevented || - !inputRef.current || - !listboxRef.current || - !isHTMLElement(targetEl) - ) { + /* istanbul ignore if: невозможный кейс (в SSR вызова этой функции не будет) */ + if (event.defaultPrevented || !listboxRef.current || !isHTMLElement(targetEl)) { return; } switch (event.key) { case Keys.ENTER: { if ( + !readOnly && checkTargetIsInputEl(targetEl, inputRef.current) && - !isInputValueEmpty(inputRef.current.value) + inputRef.current && + !isInputValueEmpty(inputRef.current) ) { event.preventDefault(); onAddChipOption(inputRef.current.value); @@ -125,14 +125,14 @@ export const ChipsInputBase = ({ } case Keys.DELETE: case Keys.BACKSPACE: { - if (valueLength > 0) { + if (!readOnly && valueLength > 0) { if (!checkTargetIsInputEl(targetEl, inputRef.current)) { event.preventDefault(); removeChipOption( getChipOptionValueByHTMLElement(targetEl), getChipOptionIndexByHTMLElement(targetEl), ); - } else if (event.key === Keys.BACKSPACE && isInputValueEmpty(inputRef.current.value)) { + } else if (event.key === Keys.BACKSPACE && isInputValueEmpty(inputRef.current)) { event.preventDefault(); moveFocusToChipOption( getChipOptionIndexByHTMLElement(targetEl), @@ -176,6 +176,18 @@ export const ChipsInputBase = ({ removeChipOption(v, getChipOptionIndexByValueProp(v, value)); }; + const handleRootClick = (event: React.MouseEvent) => { + if (contains(event.currentTarget, getActiveElementByAnotherElement(event.currentTarget))) { + return; + } + + if (valueLength > 0 && listboxRef.current) { + moveFocusToChipOption(0, 'first', listboxRef.current); + } else if (inputRef.current) { + inputRef.current.focus(); + } + }; + return ( ({ status={status} mode={mode} className={className} + onClick={disabled ? undefined : handleRootClick} >
({ aria-orientation="horizontal" aria-disabled={disabled} aria-readonly={readOnly} - onKeyDown={isDisabled ? undefined : handleListboxKeyDown} + onKeyDown={disabled ? undefined : handleListboxKeyDown} > {value.map((option, index) => ( @@ -210,13 +223,14 @@ export const ChipsInputBase = ({ 'value': option.value, 'label': option.label, 'disabled': disabled, + 'readOnly': readOnly, 'className': styles['ChipsInputBase__chip'], 'onRemove': handleChipRemove, // чтобы можно было легче найти этот чип в DOM 'data-index': index, 'data-value': option.value, // для a11y - 'tabIndex': chipFocusedIndex === index ? 0 : -1, + 'tabIndex': lastFocusedChipOptionIndex === index ? 0 : -1, 'role': 'option', 'aria-selected': true, 'aria-posinset': index + 1, diff --git a/packages/vkui/src/components/ChipsInputBase/helpers.ts b/packages/vkui/src/components/ChipsInputBase/helpers.ts index 687d1d2036..8ba791ef9c 100644 --- a/packages/vkui/src/components/ChipsInputBase/helpers.ts +++ b/packages/vkui/src/components/ChipsInputBase/helpers.ts @@ -10,7 +10,8 @@ export const isValueLikeChipOptionObject = (v: O | ChipOpt /** * @private */ -export const isInputValueEmpty = (value: string) => value === DEFAULT_INPUT_VALUE; +export const isInputValueEmpty = (input: HTMLInputElement | null) => + input ? input.value === DEFAULT_INPUT_VALUE : true; /** * @private @@ -47,16 +48,21 @@ export const getNextChipOptionIndexByNavigateToProp = ( navigateTo: NavigateTo, length: number, ) => { + const FIRST_INDEX = 0; + const LAST_INDEX = length - 1; switch (navigateTo) { case 'first': - return 0; + return FIRST_INDEX; case 'prev': - return currentIndex - 1; + const prevIndex = currentIndex - 1; + return prevIndex < 0 ? LAST_INDEX : prevIndex; case 'next': - return currentIndex + 1; + const nextIndex = currentIndex + 1; + return nextIndex > LAST_INDEX ? 0 : nextIndex; case 'last': - return length - 1; + return LAST_INDEX; default: + /* istanbul ignore next */ return -1; } }; diff --git a/packages/vkui/src/components/ChipsInputBase/types.ts b/packages/vkui/src/components/ChipsInputBase/types.ts index 787bb7648b..3c731156f6 100644 --- a/packages/vkui/src/components/ChipsInputBase/types.ts +++ b/packages/vkui/src/components/ChipsInputBase/types.ts @@ -27,6 +27,7 @@ export interface ChipProps value?: ChipOptionValue; removable?: boolean; disabled?: boolean; + readOnly?: boolean; removeLabel?: string; before?: React.ReactNode; after?: React.ReactNode; diff --git a/packages/vkui/src/components/ChipsSelect/ChipsSelect.test.tsx b/packages/vkui/src/components/ChipsSelect/ChipsSelect.test.tsx index 804e241c6d..1638aa44b5 100644 --- a/packages/vkui/src/components/ChipsSelect/ChipsSelect.test.tsx +++ b/packages/vkui/src/components/ChipsSelect/ChipsSelect.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { act, fireEvent, render, within } from '@testing-library/react'; -import { getTextFromChildren } from '../../lib/children'; +import { act, render, within } from '@testing-library/react'; import { baselineComponent, + fakeTimers, userEvent, waitForFloatingPosition, withRegExp, @@ -18,253 +18,386 @@ const THIRD_OPTION = { value: 'navarin', label: 'Наваринского пла const colors: ChipOption[] = [FIRST_OPTION, SECOND_OPTION, THIRD_OPTION]; -const testValue = { value: 'testvalue', label: 'testvalue' }; - describe('ChipsSelect', () => { baselineComponent(ChipsSelect, { a11y: false }); - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - act(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - }); + fakeTimers(); it('renders empty text', async () => { - const result = render(); - await userEvent.click(result.getByRole('combobox')); + const result = render( + , + ); + await act(() => userEvent.click(result.getByRole('combobox'))); await waitForFloatingPosition(); - expect(result.queryByText('__empty__')).toBeTruthy(); + const dropdownLocator = result.getByTestId('dropdown'); + expect(within(dropdownLocator).queryByText('__empty__')).toBeTruthy(); }); it('filters options', async () => { const result = render( , ); - const inputEl = result.getByRole('combobox'); - await userEvent.type(inputEl, 'Син'); + await act(() => userEvent.type(result.getByRole('combobox'), 'Син')); await waitForFloatingPosition(); - await userEvent.click(inputEl); - expect(within(result.getByTestId('dropdown')).getAllByRole('option')).toHaveLength(1); - expect(result.getByRole('option', { name: 'Синий' })).toBeTruthy(); + const dropdownLocator = result.getByTestId('dropdown'); + expect(within(dropdownLocator).getAllByRole('option')).toHaveLength(1); + expect(within(dropdownLocator).getByRole('option', { name: 'Синий' })).toBeTruthy(); }); it('shows spinner if fetching', async () => { - const result = render(); - await userEvent.click(result.getByRole('combobox')); + const result = render(); + await act(() => userEvent.click(result.getByRole('combobox'))); await waitForFloatingPosition(); - expect(result.queryByRole('status')).toBeTruthy(); + const dropdownLocator = result.getByTestId('dropdown'); + expect(within(dropdownLocator).getByRole('status')).toBeTruthy(); }); - describe('controls dropdown', () => { - it.each(['click', 'focus'])('opens options on %s', async (eventType) => { - const result = render(); - if (eventType === 'focus') { - fireEvent.focus(result.getByRole('combobox')); - } - await userEvent.click(result.getByRole('combobox')); - await waitForFloatingPosition(); - expect(result.getAllByRole('option')[0]).toBeTruthy(); - }); + it.each(['click', 'focus'])('opens dropdown when %s on input field', async (eventType) => { + const result = render( + , + ); + const inputLocator = result.getByRole('combobox'); + if (eventType === 'focus') { + await act(() => userEvent.tab()); + } else { + await act(() => userEvent.click(inputLocator)); + } + await waitForFloatingPosition(); + const dropdownLocator = result.getByTestId('dropdown'); + expect(within(dropdownLocator).getAllByRole('option')).toHaveLength(colors.length); + }); + + it('closes options on click outside', async () => { + const result = render( + , + ); + await act(() => userEvent.click(result.getByRole('combobox'))); + await waitForFloatingPosition(); + expect(result.getByTestId('dropdown')).toBeTruthy(); + await act(() => userEvent.click(document.body)); + expect(() => result.getByTestId('dropdown')).toThrow(); + }); - it('closes options on click outside', async () => { + it.each(['{ArrowDown}', 'typing text'])( + 'closes dropdown on {Escape} and open when %s', + async (type) => { const result = render( , ); - await userEvent.click(result.getByRole('combobox')); + const inputLocator = result.getByRole('combobox'); + await act(() => userEvent.click(inputLocator)); + await waitForFloatingPosition(); - expect(result.getByTestId('dropdown')).not.toBeNull(); - await userEvent.click(document.body); + await act(() => userEvent.type(inputLocator, '{Escape}')); + await act(() => userEvent.type(inputLocator, '{Enter}')); // если dropdown'а пока нет, то выбор из списка на {Enter} должно игнорироваться (см. в коде `case Keys.ENTER` expect(() => result.getByTestId('dropdown')).toThrow(); - }); - it('closes options after select', async () => { - const result = render( - , - ); - await userEvent.click(result.getByRole('combobox')); + await act(() => userEvent.type(inputLocator, type)); await waitForFloatingPosition(); - await userEvent.click( + expect(result.getByTestId('dropdown')).toBeTruthy(); + }, + ); + + it.each([ + { closeAfterSelect: true, description: 'closes' }, + { closeAfterSelect: false, description: 'does not close' }, + ])('$description dropdown after select', async ({ closeAfterSelect }) => { + const result = render( + , + ); + await act(() => userEvent.click(result.getByRole('combobox'))); + await waitForFloatingPosition(); + await act(() => + userEvent.click( within(result.getByTestId('dropdown')).getByRole('option', { - name: getTextFromChildren(FIRST_OPTION.label), + name: withRegExp(FIRST_OPTION.label), }), - ); + ), + ); + if (closeAfterSelect) { expect(() => result.getByTestId('dropdown')).toThrow(); - }); + } else { + expect(result.getByTestId('dropdown')).toBeTruthy(); + } + }); - it('does not close options after select with selectedBehavior and closeAfterSelect={false}', async () => { + it.each([ + { selectedBehavior: 'highlight' as const, description: 'hides' }, + { selectedBehavior: 'hide' as const, description: 'does not hide' }, + ])( + '$description selected option if `selectedBehavior` is `"$selectedBehavior"`', + async ({ selectedBehavior }) => { const result = render( , ); - await userEvent.click(result.getByRole('combobox')); + + await act(() => userEvent.click(result.getByRole('combobox'))); await waitForFloatingPosition(); - await userEvent.click( + + const getFirstOption = () => { within(result.getByTestId('dropdown')).getByRole('option', { - name: getTextFromChildren(FIRST_OPTION.label), - }), - ); - expect(() => result.getByTestId('dropdown')).toBeTruthy(); - }); + name: withRegExp(FIRST_OPTION.label), + }); + }; - it('closes options on esc', async () => { - const result = render( - , - ); - await userEvent.click(result.getByRole('combobox')); - await waitForFloatingPosition(); - await userEvent.type(result.getByRole('combobox'), '{Escape}'); - expect(() => result.getByTestId('dropdown')).toThrow(); - }); + if (selectedBehavior === 'highlight') { + expect(getFirstOption).toBeTruthy(); + } else { + expect(getFirstOption).toThrow(); + } + }, + ); + + it('should cycle navigates with {ArrowUp} and {ArrowDown}', async () => { + const result = render( + , + ); + + const inputLocator = result.getByRole('combobox'); + await act(() => userEvent.click(inputLocator)); + await waitForFloatingPosition(); + + const boundDropdownLocator = within(result.getByTestId('dropdown')); + const [firstOptionLocator, , thirdOptionLocator] = [ + boundDropdownLocator.getByRole('option', { + name: withRegExp(FIRST_OPTION.label), + }), + boundDropdownLocator.getByRole('option', { + name: withRegExp(SECOND_OPTION.label), + }), + boundDropdownLocator.getByRole('option', { + name: withRegExp(THIRD_OPTION.label), + }), + ]; + + await act(() => userEvent.type(inputLocator, '{ArrowDown}')); + expect(firstOptionLocator).toHaveAttribute('data-hovered', 'true'); + + await act(() => userEvent.type(inputLocator, '{ArrowUp}')); + expect(thirdOptionLocator).toHaveAttribute('data-hovered', 'true'); + + await act(() => userEvent.type(inputLocator, '{ArrowDown}')); + expect(firstOptionLocator).toHaveAttribute('data-hovered', 'true'); }); - describe('selects', () => { - it('on click', async () => { - const onChange = jest.fn(); - const result = render( - , - ); - await userEvent.click(result.getByRole('combobox')); - await waitForFloatingPosition(); - await userEvent.click( - within(result.getByTestId('dropdown')).getByRole('option', { - name: getTextFromChildren(FIRST_OPTION.label), - }), - ); - expect(onChange).toHaveBeenCalledWith([FIRST_OPTION]); + it('adds chip from dropdown with click to option', async () => { + const onChangeStart = jest.fn(); + const onChange = jest.fn(); + const result = render( + , + ); + + const inputLocator = result.getByRole('combobox'); + await act(() => userEvent.click(inputLocator)); + await waitForFloatingPosition(); + + const dropdownOption = within(result.getByTestId('dropdown')).getByRole('option', { + name: withRegExp(FIRST_OPTION.label), }); + await act(() => userEvent.hover(dropdownOption)); // для вызова onDropdownMouseLeav + await act(() => userEvent.hover(inputLocator)); + await act(() => userEvent.click(dropdownOption)); - it('via keyboard', async () => { - const onChange = jest.fn(); - const options = new Array(20).fill(0).map((_, i) => ({ value: i, label: `Option #${i}` })); - const result = render( - , - ); + result.rerender( + , + ); + expect( + result.getByRole('option', { + name: withRegExp(FIRST_OPTION.label), + }), + ).toBeTruthy(); + expect(onChangeStart).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith([FIRST_OPTION]); + }); - await userEvent.click(result.getByRole('combobox')); - await waitForFloatingPosition(); - const dropdown = result.getByTestId('dropdown'); + it('adds chip from dropdown with {Enter} to option', async () => { + const onChangeStart = jest.fn(); + const onChange = jest.fn(); + const options = new Array(20).fill(0).map((_, i) => ({ value: i, label: `Option #${i}` })); + const result = render( + , + ); - // Focus on first element - await userEvent.keyboard('{arrowdown}'); + const inputLocator = result.getByRole('combobox'); + await act(() => userEvent.click(inputLocator)); + await waitForFloatingPosition(); - const idx = 7; - for (let i = 0; i < idx; i++) { - await userEvent.keyboard('{arrowdown}'); - } - await userEvent.keyboard('{enter}'); + const targetOptionIndex = 7; + await act(() => userEvent.type(inputLocator, '{ArrowDown}')); + for (let i = 0; i < targetOptionIndex; i += 1) { + await act(() => userEvent.type(inputLocator, '{ArrowDown}')); + } + await act(() => userEvent.type(inputLocator, '{Enter}')); - expect(within(dropdown).getByRole('option', { name: options[idx].label })).toBeTruthy(); - expect(onChange).toHaveBeenCalledWith([options[idx]]); - }); + const selectedOption = options[targetOptionIndex]; + result.rerender( + , + ); + expect( + result.getByRole('option', { + name: withRegExp(selectedOption.label), + }), + ).toBeTruthy(); + expect(onChangeStart).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith([selectedOption]); + }); - it('does not hide selected option from list', async () => { + it('does not focus input field on chip click', async () => { + const result = render( + , + ); + const chipEl = result.getByRole('option', { name: withRegExp(FIRST_OPTION.label) }); + await act(() => userEvent.click(chipEl)); + expect(result.getByRole('combobox')).not.toHaveFocus(); + }); + + describe('creatable', () => { + const customChip = { value: 'testvalue', label: 'testvalue' }; + + it.each([ + { creatable: true, description: 'adds custom chip' }, + { creatable: false, description: 'does not add custom chip' }, + ])('$description by pressing {Enter}', async ({ creatable }) => { + const onChange = jest.fn(); const result = render( , ); - await userEvent.click(result.getByRole('combobox')); - await waitForFloatingPosition(); - expect( - within(result.getByTestId('dropdown')).getByRole('option', { - name: getTextFromChildren(FIRST_OPTION.label), - }), - ).toBeTruthy(); + const inputLocator = result.getByRole('combobox'); + await act(() => userEvent.type(inputLocator, customChip.label)); + await act(() => userEvent.type(inputLocator, '{Enter}')); + if (creatable) { + expect(onChange).toHaveBeenCalledWith([customChip]); + } else { + expect(onChange).not.toHaveBeenCalled(); + } }); - it('hides selected option from list', async () => { + it('adds custom chip by add button in dropdown', async () => { + const onChange = jest.fn(); const result = render( , ); - await userEvent.click(result.getByRole('combobox')); + const inputLocator = result.getByRole('combobox'); + await act(() => userEvent.type(inputLocator, customChip.label)); await waitForFloatingPosition(); - expect(() => - within(result.getByTestId('dropdown')).getByRole('option', { - name: getTextFromChildren(FIRST_OPTION.label), - }), - ).toThrow(); - }); - it('deselects on chip click', async () => { - const handleChange = jest.fn(); - const result = render( - , + await act(() => + userEvent.click( + within(result.getByTestId('dropdown')).getByRole('option', { + name: withRegExp('Добавить новую опцию'), + }), + ), ); - await userEvent.click(result.getByText(`Удалить ${FIRST_OPTION.label}`).closest('button')!); - expect(handleChange).toHaveBeenCalledWith([]); + expect(onChange).toHaveBeenCalledWith([customChip]); }); - }); - it('does not focus ChipsSelect on chip click', async () => { - let selectedColors: ChipOption[] = [FIRST_OPTION, SECOND_OPTION]; - const setSelectedColors = (updatedColors: ChipOption[]) => { - selectedColors = [...updatedColors]; - }; - - const colorsChipsProps = { - value: selectedColors, - onChange: setSelectedColors, - options: colors, - top: 'Выберите или добавьте цвета', - placeholder: 'Не выбраны', - creatable: true, - }; - - const result = render(); - const chipEl = result.getByRole('option', { name: withRegExp(FIRST_OPTION.label) }); - await userEvent.click(within(chipEl).getByRole('button')); - expect(result.getByTestId('chips-select')).not.toHaveFocus(); + it.each([ + { creatable: true, description: 'adds custom chip' }, + { creatable: false, description: 'does not add custom chip' }, + ])( + '$description when `addOnBlur` provided and `creatable` is $creatable', + async ({ creatable }) => { + const onChange = jest.fn(); + const result = render( + , + ); + + await act(() => userEvent.type(result.getByRole('combobox'), customChip.label)); + await waitForFloatingPosition(); + await act(() => userEvent.click(document.body)); + + if (creatable) { + expect(onChange).toHaveBeenCalledWith([customChip]); + } else { + expect(onChange).not.toHaveBeenCalledWith([]); + } + }, + ); }); - describe('addOnBlur prop', () => { - it('add value on blur event if creatable=true', async () => { - let value; + it.each([{ readOnly: false }, { readOnly: true }])( + 'calls user events (`readOnly` prop is `$readOnly`)', + async ({ readOnly }) => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const onKeyDown = jest.fn(); const result = render( (value = e)} - creatable - addOnBlur + defaultValue={[]} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown} />, ); - await userEvent.type(result.getByRole('combobox'), testValue.label); - await waitForFloatingPosition(); - await userEvent.click(document.body); - expect(value).toEqual([testValue]); - }); - it('does not add value on blur event if creatable=false', async () => { - const onChange = jest.fn(); - const result = render( - , - ); - await userEvent.type(result.getByRole('combobox'), testValue.label); - await waitForFloatingPosition(); - await userEvent.click(document.body); - expect(onChange).not.toHaveBeenCalled(); - }); - }); + const inputLocator = result.getByTestId('input'); + + await act(() => userEvent.tab()); + await act(() => userEvent.type(inputLocator, '{ArrowUp}')); + await act(() => userEvent.tab({ shift: true })); + + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + expect(onKeyDown).toHaveBeenCalled(); + }, + ); }); diff --git a/packages/vkui/src/components/ChipsSelect/ChipsSelect.tsx b/packages/vkui/src/components/ChipsSelect/ChipsSelect.tsx index 07a18c5eaa..07ee64ecb7 100644 --- a/packages/vkui/src/components/ChipsSelect/ChipsSelect.tsx +++ b/packages/vkui/src/components/ChipsSelect/ChipsSelect.tsx @@ -118,16 +118,17 @@ export const ChipsSelect =