Skip to content

Commit

Permalink
feat(Select): support form (#1644)
Browse files Browse the repository at this point in the history
  • Loading branch information
ValeraS authored Jun 14, 2024
1 parent d0bdede commit 1ad73b6
Show file tree
Hide file tree
Showing 11 changed files with 299 additions and 40 deletions.
21 changes: 17 additions & 4 deletions src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,6 +70,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
getOptionGroupHeight,
filterOption,
name,
form,
className,
controlClassName,
popupClassName,
Expand Down Expand Up @@ -130,6 +138,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
open,
activeIndex,
toggleOpen,
setValue,
handleSelection,
handleClearValue,
setActiveIndex,
Expand Down Expand Up @@ -326,7 +335,6 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
ref={handleControlRef}
className={controlClassName}
qa={qa}
name={name}
view={view}
size={size}
pin={pin}
Expand All @@ -347,7 +355,6 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
renderCounter={renderCounter}
title={title}
/>

<SelectPopup
ref={controlWrapRef}
className={popupClassName}
Expand All @@ -363,11 +370,17 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
>
{renderPopup({renderFilter: _renderFilter, renderList: _renderList})}
</SelectPopup>

<OuterAdditionalContent
errorMessage={isErrorMsgVisible ? errorMessage : null}
errorMessageId={errorMessageId}
/>
<HiddenSelect
name={name}
value={value}
disabled={disabled}
form={form}
onReset={setValue}
/>
</div>
);
}) as unknown as SelectComponent;
Expand Down
100 changes: 67 additions & 33 deletions src/components/Select/__stories__/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -26,35 +27,68 @@ export default {
},
},
},
} as Meta;

const DefaultTemplate: StoryFn<SelectProps> = (args) => (
<Select {...args} title="Select sample">
<Select.Option value="val1" content="Value1" />
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</Select>
);
const ShowcaseTemplate: StoryFn<SelectProps> = (args: SelectProps) => <SelectShowcase {...args} />;
const SelectPopupWidthShowcaseTemplate: StoryFn<SelectProps> = (args) => (
<SelectPopupWidthShowcase {...args} />
);
const UseSelectOptionsShowcaseTemplate = () => {
return <UseSelectOptionsShowcase />;
};
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<SelectProps>;

export const Default = {
render: (args) => (
<Select {...args} title="Select sample">
<Select.Option value="val1" content="Value1" />
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</Select>
),
} satisfies Story;

export const Showcase = {
render: (args: SelectProps) => <SelectShowcase {...args} />,
args: {
view: 'normal',
size: 'm',
multiple: false,
filterable: false,
disabled: false,
placeholder: 'Values',
label: '',
hasClear: false,
},
} satisfies Story;

export const PopupWidth = {
render: (args) => <SelectPopupWidthShowcase {...args} />,
} satisfies Story;

export const UseSelectOptions = {
render: () => <UseSelectOptionsShowcase />,
parameters: {
controls: {
disabled: true,
},
},
} satisfies Story;

export const Form = {
render: (args) => (
<form
id="form"
onSubmit={(event) => {
event.preventDefault();
alert(JSON.stringify([...new FormData(event.currentTarget).entries()]));
}}
>
<label style={{display: 'flex', gap: 8, alignItems: 'center'}}>
Value: {Default.render({name: 'value', ...args})}
</label>
<div style={{marginBlockStart: '1em', display: 'flex', gap: 8}}>
<Button type="submit" view="action">
Submit
</Button>
<Button type="reset">Reset</Button>
</div>
</form>
),
} satisfies Story;
120 changes: 120 additions & 0 deletions src/components/Select/__tests__/Select.form.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<form data-qa="form" onSubmit={onSubmit}>
<Select name="select" label="Test">
<Select.Option value="one">One</Select.Option>
<Select.Option value="two">Two</Select.Option>
<Select.Option value="three">Three</Select.Option>
</Select>
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
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(
<form data-qa="form" onSubmit={onSubmit}>
<Select defaultValue={['one']} name="select">
<Select.Option value="one">One</Select.Option>
<Select.Option value="two">Two</Select.Option>
<Select.Option value="three">Three</Select.Option>
</Select>
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
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(
<form data-qa="form" onSubmit={onSubmit}>
<Select defaultValue={['one', 'three']} name="select" multiple>
<Select.Option value="one">One</Select.Option>
<Select.Option value="two">Two</Select.Option>
<Select.Option value="three">Three</Select.Option>
</Select>
<button type="submit" data-qa="submit">
submit
</button>
</form>,
);
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 (
<form>
<Select name="select" value={value} onUpdate={setValue} qa="select">
<Select.Option value="one">One</Select.Option>
<Select.Option value="two">Two</Select.Option>
<Select.Option value="three">Three</Select.Option>
</Select>
<input type="reset" data-qa="reset" />
</form>
);
}

render(<Test />);
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');
});
});
53 changes: 53 additions & 0 deletions src/components/Select/components/HiddenSelect/HiddenSelect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<input
ref={ref}
type="hidden"
name={name}
value={value}
form={form}
disabled={disabled}
/>
);
}

return (
<React.Fragment>
{value.map((v, i) => (
<input
key={v}
ref={i === 0 ? ref : undefined}
value={v}
type="hidden"
name={name}
form={form}
disabled={disabled}
/>
))}
</React.Fragment>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ type ControlProps = {
size: NonNullable<SelectProps['size']>;
pin: NonNullable<SelectProps['pin']>;
selectedOptionsContent: React.ReactNode;
name?: string;
className?: string;
qa?: string;
label?: string;
Expand All @@ -58,7 +57,6 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
selectedOptionsContent,
className,
qa,
name,
label,
placeholder,
isErrorVisible,
Expand Down Expand Up @@ -186,7 +184,6 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
? undefined
: `${selectId}-list-item-${activeIndex}`
}
name={name}
disabled={disabled}
onClick={handleControlClick}
onKeyDown={onKeyDown}
Expand Down
1 change: 1 addition & 0 deletions src/components/Select/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/components/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export type SelectProps<T = any> = QAProps &
/**Shows selected options count if multiple selection is avalable */
hasCounter?: boolean;
title?: string;
form?: string;
};

export type SelectOption<T = any> = QAProps &
Expand Down
Loading

0 comments on commit 1ad73b6

Please sign in to comment.