Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): support form #1644

Merged
merged 1 commit into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading