From 1ad73b69679a55001d50e97db1d40484d3e04c8c Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Fri, 14 Jun 2024 18:35:44 +0200 Subject: [PATCH] feat(Select): support form (#1644) --- src/components/Select/Select.tsx | 21 ++- .../Select/__stories__/Select.stories.tsx | 100 ++++++++++----- .../Select/__tests__/Select.form.test.tsx | 120 ++++++++++++++++++ .../components/HiddenSelect/HiddenSelect.tsx | 53 ++++++++ .../SelectControl/SelectControl.tsx | 3 - src/components/Select/components/index.ts | 1 + src/components/Select/types.ts | 1 + src/hooks/private/index.ts | 1 + .../private/useFormResetHandler/index.ts | 37 ++++++ src/hooks/useSelect/types.ts | 1 + src/hooks/useSelect/useSelect.ts | 1 + 11 files changed, 299 insertions(+), 40 deletions(-) create mode 100644 src/components/Select/__tests__/Select.form.test.tsx create mode 100644 src/components/Select/components/HiddenSelect/HiddenSelect.tsx create mode 100644 src/hooks/private/useFormResetHandler/index.ts diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 1b86f938c5..062fdcf21e 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -10,7 +10,14 @@ import {errorPropsMapper} from '../controls/utils'; import {useMobile} from '../mobile'; import type {CnMods} from '../utils/cn'; -import {EmptyOptions, SelectControl, SelectFilter, SelectList, SelectPopup} from './components'; +import { + EmptyOptions, + HiddenSelect, + SelectControl, + SelectFilter, + SelectList, + SelectPopup, +} from './components'; import {DEFAULT_VIRTUALIZATION_THRESHOLD, selectBlock} from './constants'; import {useQuickSearch} from './hooks'; import {getSelectFilteredOptions, useSelectOptions} from './hooks-public'; @@ -63,6 +70,7 @@ export const Select = React.forwardRef(function getOptionGroupHeight, filterOption, name, + form, className, controlClassName, popupClassName, @@ -130,6 +138,7 @@ export const Select = React.forwardRef(function open, activeIndex, toggleOpen, + setValue, handleSelection, handleClearValue, setActiveIndex, @@ -326,7 +335,6 @@ export const Select = React.forwardRef(function ref={handleControlRef} className={controlClassName} qa={qa} - name={name} view={view} size={size} pin={pin} @@ -347,7 +355,6 @@ export const Select = React.forwardRef(function renderCounter={renderCounter} title={title} /> - (function > {renderPopup({renderFilter: _renderFilter, renderList: _renderList})} - + ); }) as unknown as SelectComponent; diff --git a/src/components/Select/__stories__/Select.stories.tsx b/src/components/Select/__stories__/Select.stories.tsx index bcd8721f3d..92feca1f36 100644 --- a/src/components/Select/__stories__/Select.stories.tsx +++ b/src/components/Select/__stories__/Select.stories.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import type {Meta, StoryFn} from '@storybook/react'; +import type {Meta, StoryObj} from '@storybook/react'; import {Select} from '..'; import type {SelectProps} from '..'; +import {Button} from '../../Button'; import {SelectPopupWidthShowcase} from './SelectPopupWidthShowcase'; import {SelectShowcase} from './SelectShowcase'; import {UseSelectOptionsShowcase} from './UseSelectOptionsShowcase'; -export default { +const meta: Meta = { title: 'Components/Inputs/Select', component: Select, parameters: { @@ -26,35 +27,68 @@ export default { }, }, }, -} as Meta; - -const DefaultTemplate: StoryFn = (args) => ( - -); -const ShowcaseTemplate: StoryFn = (args: SelectProps) => ; -const SelectPopupWidthShowcaseTemplate: StoryFn = (args) => ( - -); -const UseSelectOptionsShowcaseTemplate = () => { - return ; -}; -export const Default = DefaultTemplate.bind({}); -export const Showcase = ShowcaseTemplate.bind({}); -export const PopupWidth = SelectPopupWidthShowcaseTemplate.bind({}); -export const UseSelectOptions = UseSelectOptionsShowcaseTemplate.bind({}); - -Showcase.args = { - view: 'normal', - size: 'm', - multiple: false, - filterable: false, - disabled: false, - placeholder: 'Values', - label: '', - hasClear: false, }; + +export default meta; + +type Story = StoryObj; + +export const Default = { + render: (args) => ( + + ), +} satisfies Story; + +export const Showcase = { + render: (args: SelectProps) => , + args: { + view: 'normal', + size: 'm', + multiple: false, + filterable: false, + disabled: false, + placeholder: 'Values', + label: '', + hasClear: false, + }, +} satisfies Story; + +export const PopupWidth = { + render: (args) => , +} satisfies Story; + +export const UseSelectOptions = { + render: () => , + parameters: { + controls: { + disabled: true, + }, + }, +} satisfies Story; + +export const Form = { + render: (args) => ( +
{ + event.preventDefault(); + alert(JSON.stringify([...new FormData(event.currentTarget).entries()])); + }} + > + +
+ + +
+
+ ), +} satisfies Story; diff --git a/src/components/Select/__tests__/Select.form.test.tsx b/src/components/Select/__tests__/Select.form.test.tsx new file mode 100644 index 0000000000..9aa60655b4 --- /dev/null +++ b/src/components/Select/__tests__/Select.form.test.tsx @@ -0,0 +1,120 @@ +/* eslint-disable testing-library/no-node-access */ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {render, screen, within} from '../../../../test-utils/utils'; +import {Select} from '../Select'; + +describe('Select form', () => { + it('should submit empty option by default', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = formData.getAll('select'); + }); + render( +
+ + +
, + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual(['']); + }); + + it('should submit default option', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = formData.getAll('select'); + }); + render( +
+ + +
, + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual(['one']); + }); + + it('should submit multiple option', async () => { + let value; + const onSubmit = jest.fn((e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + value = formData.getAll('select'); + }); + render( +
+ + +
, + ); + await userEvent.click(screen.getByTestId('submit')); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(value).toEqual(['one', 'three']); + }); + + it('supports form reset', async () => { + function Test() { + const [value, setValue] = React.useState(['one']); + return ( +
+ + +
+ ); + } + + render(); + const select = screen.getByTestId('select'); + let inputs = document.querySelectorAll('[name=select]'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toHaveValue('one'); + + await userEvent.click(select); + + const listbox = screen.getByRole('listbox'); + const items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(3); + + await userEvent.click(items[1]); + inputs = document.querySelectorAll('[name=select]'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toHaveValue('two'); + + const button = screen.getByTestId('reset'); + await userEvent.click(button); + inputs = document.querySelectorAll('[name=select]'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toHaveValue('one'); + }); +}); diff --git a/src/components/Select/components/HiddenSelect/HiddenSelect.tsx b/src/components/Select/components/HiddenSelect/HiddenSelect.tsx new file mode 100644 index 0000000000..30d96ac545 --- /dev/null +++ b/src/components/Select/components/HiddenSelect/HiddenSelect.tsx @@ -0,0 +1,53 @@ +'use client'; + +import React from 'react'; + +import {useFormResetHandler} from '../../../../hooks/private'; + +interface HiddenSelectProps { + name?: string; + value: string[]; + disabled?: boolean; + form?: string; + onReset: (value: string[]) => void; +} +//FIXME: current implementation is not accessible to screen readers and does not support browser autofill and +// form validation +export function HiddenSelect(props: HiddenSelectProps) { + const {name, value, disabled, form, onReset} = props; + + const ref = useFormResetHandler({onReset, initialValue: value}); + + if (!name || disabled) { + return null; + } + + if (value.length === 0) { + return ( + + ); + } + + return ( + + {value.map((v, i) => ( + + ))} + + ); +} diff --git a/src/components/Select/components/SelectControl/SelectControl.tsx b/src/components/Select/components/SelectControl/SelectControl.tsx index c54f72a5bf..aa9652c217 100644 --- a/src/components/Select/components/SelectControl/SelectControl.tsx +++ b/src/components/Select/components/SelectControl/SelectControl.tsx @@ -31,7 +31,6 @@ type ControlProps = { size: NonNullable; pin: NonNullable; selectedOptionsContent: React.ReactNode; - name?: string; className?: string; qa?: string; label?: string; @@ -58,7 +57,6 @@ export const SelectControl = React.forwardRef(( selectedOptionsContent, className, qa, - name, label, placeholder, isErrorVisible, @@ -186,7 +184,6 @@ export const SelectControl = React.forwardRef(( ? undefined : `${selectId}-list-item-${activeIndex}` } - name={name} disabled={disabled} onClick={handleControlClick} onKeyDown={onKeyDown} diff --git a/src/components/Select/components/index.ts b/src/components/Select/components/index.ts index d4d1b86fed..75920b308f 100644 --- a/src/components/Select/components/index.ts +++ b/src/components/Select/components/index.ts @@ -3,3 +3,4 @@ export * from './SelectControl/SelectControl'; export {SelectFilter} from './SelectFilter/SelectFilter'; export {SelectList} from './SelectList/SelectList'; export * from './SelectPopup/SelectPopup'; +export {HiddenSelect} from './HiddenSelect/HiddenSelect'; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts index 9c589fe9a7..5b2d14e92f 100644 --- a/src/components/Select/types.ts +++ b/src/components/Select/types.ts @@ -122,6 +122,7 @@ export type SelectProps = QAProps & /**Shows selected options count if multiple selection is avalable */ hasCounter?: boolean; title?: string; + form?: string; }; export type SelectOption = QAProps & diff --git a/src/hooks/private/index.ts b/src/hooks/private/index.ts index a11e78b6f1..9102dcd2f8 100644 --- a/src/hooks/private/index.ts +++ b/src/hooks/private/index.ts @@ -10,3 +10,4 @@ export * from './useRadioGroup'; export * from './useRestoreFocus'; export * from './useUpdateEffect'; export * from './useTooltipVisible'; +export * from './useFormResetHandler'; diff --git a/src/hooks/private/useFormResetHandler/index.ts b/src/hooks/private/useFormResetHandler/index.ts new file mode 100644 index 0000000000..9d83a89130 --- /dev/null +++ b/src/hooks/private/useFormResetHandler/index.ts @@ -0,0 +1,37 @@ +import React from 'react'; + +export function useFormResetHandler({ + initialValue, + onReset, +}: { + initialValue: T; + onReset: (value: T) => void; +}) { + const [formElement, setFormElement] = React.useState(null); + + const resetValue = React.useRef(initialValue); + + React.useEffect(() => { + if (!formElement) { + return undefined; + } + + const handleReset = () => { + onReset(resetValue.current); + }; + + formElement.addEventListener('reset', handleReset); + return () => { + formElement.removeEventListener('reset', handleReset); + }; + }, [formElement, onReset]); + + const ref = React.useCallback( + (node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null) => { + setFormElement(node?.form ?? null); + }, + [], + ); + + return ref; +} diff --git a/src/hooks/useSelect/types.ts b/src/hooks/useSelect/types.ts index c86e7454d0..6024ab286e 100644 --- a/src/hooks/useSelect/types.ts +++ b/src/hooks/useSelect/types.ts @@ -20,6 +20,7 @@ export type UseSelectResult = { activeIndex: number | undefined; handleSelection: (option: UseSelectOption) => void; handleClearValue: () => void; + setValue: (value: string[]) => void; toggleOpen: (val?: boolean | undefined) => void; setActiveIndex: React.Dispatch>; }; diff --git a/src/hooks/useSelect/useSelect.ts b/src/hooks/useSelect/useSelect.ts index 01338cce4a..0b7d62658f 100644 --- a/src/hooks/useSelect/useSelect.ts +++ b/src/hooks/useSelect/useSelect.ts @@ -66,6 +66,7 @@ export const useSelect = ({ return { value, activeIndex, + setValue, handleSelection, handleClearValue, toggleOpen,