Skip to content

Commit

Permalink
74 dropdown redesign (#104)
Browse files Browse the repository at this point in the history
* feat: Well - make headingLevel prop optional

* feat: Icon - With/without background

* feat: Notification - allow conditional rendering; allow conditional display of icon

* feat: Label - Hide if no label text provided

* feat: Dropdown - Support display of checkbox next to options; Support hiding pills for multiselect; Support a `Clear Selected` button;

* Support passing additional HTML element props to Divider and Layout components

* Test: Update interactions to account for Clear All button

* Header - remove bottom margin

* Well - Propagate supported properties to HTML element

* Well - Propagate supported properties to HTML element

* Icon - Allow sizing of icon to match an element that is not it's parent
  • Loading branch information
meissadia committed Jul 27, 2023
1 parent 19e8899 commit 0512196
Show file tree
Hide file tree
Showing 17 changed files with 347 additions and 106 deletions.
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;
}

0 comments on commit 0512196

Please sign in to comment.