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

74 dropdown redesign #104

Merged
merged 11 commits into from
Jul 27, 2023
2 changes: 1 addition & 1 deletion src/components/Divider/Divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ interface DividerProperties {
export default function Divider({
className = '',
...properties
}: DividerProperties): JSX.Element {
}: DividerProperties & React.HTMLProps<HTMLDivElement>): JSX.Element {
return <div className={`content_line ${className}`} {...properties} />;
}
95 changes: 59 additions & 36 deletions src/components/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Button } from '../Button/Button';
import type { SelectOption } from './Dropdown';
import { Dropdown } from './Dropdown';
import { MockOptions } from './utils';

Expand Down Expand Up @@ -55,14 +56,7 @@ export const Disabled: Story = {
export const MultiSelect: Story = {
args: {
...DefaultDropdown.args,
options: [
...MockOptions,
{
value: 'long',
label:
'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious'
}
],
options: [...MockOptions],
id: 'multi',
isMulti: true,
label: 'Multi-select'
Expand All @@ -72,32 +66,31 @@ export const MultiSelect: Story = {
export const MultiSelectWithDefaultValue: Story = {
args: {
...DefaultDropdown.args,
options: [
...MockOptions,
{
value: 'long',
label:
'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious'
}
],
options: [...MockOptions],
defaultValue: [MockOptions[0]],
id: 'multi',
isMulti: true,
label: 'Multi-select'
}
};

export const MultiSelectWithCheckboxes: Story = {
args: {
...DefaultDropdown.args,
options: [...MockOptions],
defaultValue: [MockOptions[0]],
id: 'multi',
isMulti: true,
label: 'Multi-select',
pillAlign: 'bottom',
withCheckbox: true
}
};

export const MultiSelectWithPillsAlignedBottom: Story = {
args: {
...DefaultDropdown.args,
options: [
...MockOptions,
{
value: 'long',
label:
'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious'
}
],
options: [...MockOptions],
defaultValue: [MockOptions[0]],
id: 'multi',
isMulti: true,
Expand All @@ -106,14 +99,48 @@ export const MultiSelectWithPillsAlignedBottom: Story = {
}
};

const ControlledDropdown = arguments_ => {
const [selected, setSelected] = useState([arguments_.options[0]]);
export const MultiSelectWithoutPills: Story = {
args: {
...DefaultDropdown.args,
options: [...MockOptions],
defaultValue: [MockOptions[0]],
id: 'multi',
isMulti: true,
label: 'Multi-select',
pillAlign: 'hide',
withCheckbox: true
}
};

export const MultiSelectWithoutClearAllButton: Story = {
args: {
...DefaultDropdown.args,
options: [...MockOptions],
defaultValue: [MockOptions[0]],
id: 'multi',
isMulti: true,
label: 'Multi-select',
pillAlign: 'bottom',
withCheckbox: true,
showClearAllSelectedButton: false
}
};

interface ControlledArguments {
options: SelectOption[];
}
const ControlledDropdown = ({
options,
...arguments_
}: ControlledArguments): JSX.Element => {
const [selected, setSelected] = useState([options[0]]);

return (
<>
<div className='m-btn-group u-mb30'>
<Button
label='Add all options'
onClick={(): void => setSelected([...arguments_.options])}
onClick={(): void => setSelected([...options])}
/>
<Button
label='Clear all options'
Expand All @@ -122,9 +149,12 @@ const ControlledDropdown = arguments_ => {
/>
</div>
<Dropdown
{...arguments_}
id='controlled-dropdown'
options={options}
showClearAllSelectedButton={false}
onSelect={(newValue): void => setSelected(newValue)}
value={selected}
{...arguments_}
/>
</>
);
Expand All @@ -134,14 +164,7 @@ export const AsAControlledComponent: Story = {
render: arguments_ => <ControlledDropdown {...arguments_} />,
args: {
...DefaultDropdown.args,
options: [
...MockOptions,
{
value: 'long',
label:
'Multiselect options can also contain long words that will be wrapped like supercalifragilisticexpialidocious'
}
],
options: [...MockOptions],
defaultValue: [MockOptions[0]],
id: 'multi',
isMulti: true,
Expand Down
11 changes: 6 additions & 5 deletions src/components/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('Default Dropdown', () => {
options: MockOptions,
onSelect,
placeholder,
defaultValue: MockOptions.at(-1)
defaultValue: MockOptions.at(2)
}}
/>
);
Expand Down Expand Up @@ -136,7 +136,7 @@ describe('Multi-select Dropdown', () => {
});

const pills = screen.queryAllByRole('listitem');
expect(pills.length).toBe(1);
expect(pills.length).toBe(2);

const selectedOption = pills[0];
expect(selectedOption).toHaveClass('pill');
Expand Down Expand Up @@ -167,7 +167,7 @@ describe('Multi-select Dropdown', () => {
// Verify pill displayed
expect(screen.getByText(optionLabel)).toBeInTheDocument();
const afterSelection = screen.queryAllByRole('listitem');
expect(afterSelection.length).toBe(1);
expect(afterSelection.length).toBe(2);
expect(afterSelection[0]).toHaveClass('pill');
expect(afterSelection[0]).toHaveTextContent(optionLabel);

Expand All @@ -190,7 +190,8 @@ describe('Multi-select Dropdown', () => {

// Verify pills displayed
const afterSelection = screen.queryAllByRole('listitem');
expect(afterSelection.length).toBe(3);
// All options + Clear All button
expect(afterSelection.length).toBe(5);
expect(afterSelection[2]).toHaveClass('pill');
expect(afterSelection[2]).toHaveTextContent(optionLabel);

Expand All @@ -202,7 +203,7 @@ describe('Multi-select Dropdown', () => {

// Verify correct option's pill was removed, while others remain
const afterDelete = screen.queryAllByRole('listitem');
expect(afterDelete.length).toBe(2);
expect(afterDelete.length).toBe(4);
expect(afterDelete[0]).toHaveTextContent('Option B');
expect(afterDelete[1]).toHaveTextContent('Option C');
});
Expand Down
94 changes: 67 additions & 27 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import type {
CSSObjectWithLabel,
ControlProps,
GroupBase,
InputActionMeta,
OnChangeValue,
OptionsOrGroups,
PropsValue,
SelectInstance
} from 'react-select';
import Select, { createFilter } from 'react-select';
import Select, { components, createFilter } from 'react-select';
import type { StateManagerProps } from 'react-select/dist/declarations/src/useStateManager';
import { Label } from '../Label/Label';
import CheckboxInputOption from './DropdownInputWithCheckbox';
import { DropdownPills } from './DropdownPills';

export interface SelectOption {
Expand Down Expand Up @@ -44,14 +47,16 @@ const extendedSelectStyles = {
* @param options Available options
* @param selected Selected options
* @param isMulti Is a multi-select component?
* @param showAllOptions Force all options to be displayed for selection
* @returns A list of selectable options
*/
const filterOptions = (
options: PropsValue<SelectOption>,
selected: PropsValue<SelectOption>,
isMulti: boolean
isMulti: boolean,
showAllOptions: boolean
): OptionsOrGroups<SelectOption, GroupBase<SelectOption>> => {
if (!selected || !isMulti)
if (showAllOptions || !selected || !isMulti)
return options as OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;

return (options as SelectOption[]).filter(
Expand All @@ -67,10 +72,19 @@ interface DropdownProperties {
label?: string;
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
options: SelectOption[];
pillAlign?: 'bottom' | 'top';
pillAlign?: 'bottom' | 'hide' | 'top'; // Display pills below/above the select input or hide them
showClearAllSelectedButton?: boolean; // Show/Hide our custom 'Clear All...' button
value?: PropsValue<SelectOption>;
withCheckbox?: boolean; // Show/Hide checkbox next to optios
}

// Make it easier for the user to delete/edit search text by highlighting all input text onFocus
const onSelectInputFocus = (
event: React.ChangeEvent<HTMLInputElement>
): void => {
event.target.select();
};

/**
* A dropdown input component that supports multi-select.
*
Expand All @@ -86,21 +100,31 @@ export function Dropdown({
options,
pillAlign = 'top',
value,
...rest
}: DropdownProperties): JSX.Element {
withCheckbox = false,
isClearable = true, // Show/Hide react-select X in select input that clears all selections
showClearAllSelectedButton = true,
...properties
}: DropdownProperties & StateManagerProps): JSX.Element {
const [searchString, setSearchString] = useState<string>('');
const [selected, setSelected] = useState<PropsValue<SelectOption>>(
defaultValue ?? []
);

// Retain user search input between interactions
const onInputChange = (inputValue: string, event: InputActionMeta): void => {
if (event.action !== 'input-change') return;
setSearchString(inputValue);
};

// Support acting as controlled component
useEffect(() => {
// Support acting as controlled component
if (value) setSelected(value);
}, [value]);

const selectReference = useRef<SelectInstance>(null);

// Store updated list of selected items
const onChange = useCallback(
const onSelectionChange = useCallback(
(option: PropsValue<SelectOption>) => {
onSelect(option);
setSelected(option);
Expand All @@ -124,42 +148,58 @@ export function Dropdown({

return (
<div className='m-form-field m-form-field__select'>
{!!label && (
<Label id={labelID} htmlFor={id} onClick={onLabelClick}>
{label}
</Label>
)}
<Label
id={labelID}
htmlFor={id}
onClick={onLabelClick}
className='u-mt60'
>
{label}
</Label>
{pillAlign === 'top' && (
<DropdownPills
selectRef={selectReference}
selected={selected}
isMulti={isMulti}
onChange={onChange}
onChange={onSelectionChange}
showClearAllSelectedButton={showClearAllSelectedButton}
/>
)}
<Select
inputId={id}
id={`${id}-select`}
aria-labelledby={labelID}
className='o-multiselect'
closeMenuOnSelect={!isMulti}
controlShouldRenderValue={!isMulti}
components={{
Option: withCheckbox ? CheckboxInputOption : components.Option
}}
filterOption={createFilter({ ignoreAccents: false })}
hideSelectedOptions={false}
inputId={id}
inputValue={searchString}
isClearable={isClearable}
isMulti={isMulti}
onChange={onSelectionChange}
onFocus={onSelectInputFocus}
onInputChange={onInputChange}
onKeyDown={onKeyDown}
openMenuOnFocus
options={filterOptions(options, selected, isMulti, withCheckbox)}
ref={selectReference as Ref<any>}
styles={extendedSelectStyles}
tabSelectsValue={false}
onKeyDown={onKeyDown}
isMulti={isMulti}
className='o-multiselect'
value={value ?? selected}
options={filterOptions(options, selected, isMulti)}
onChange={onChange}
filterOption={createFilter({ ignoreAccents: false })}
controlShouldRenderValue={!isMulti}
closeMenuOnSelect={!isMulti}
styles={extendedSelectStyles}
{...rest}
{...properties}
/>
{pillAlign === 'bottom' && (
<DropdownPills
selected={selected}
isMulti={isMulti}
onChange={onChange}
onChange={onSelectionChange}
pillAlign='bottom'
selected={selected}
selectRef={selectReference}
showClearAllSelectedButton={showClearAllSelectedButton}
/>
)}
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Dropdown/DropdownInputWithCheckbox.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.OptionWithCheckbox .checkbox {
margin-right: 5px;
}