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

[Dropdown] Propagate attributes #124

Merged
merged 7 commits into from
Aug 10, 2023
2 changes: 1 addition & 1 deletion src/components/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
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 type { SelectOption } from './Dropdown.types';
import { MockOptions } from './utils';

const meta: Meta<typeof Dropdown> = {
Expand Down
62 changes: 61 additions & 1 deletion src/components/Dropdown/Dropdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ describe('Multi-select Dropdown', () => {
options: MockOptions,
onSelect,
placeholder,
isMulti: true
isMulti: true,
withCheckbox: true
};

it('(Mouse) Selects an option and displays pill', async () => {
Expand Down Expand Up @@ -222,4 +223,63 @@ describe('Multi-select Dropdown', () => {
expect(screen.getByText('Option B')).toBeInTheDocument();
expect(screen.getByText('Option C')).toBeInTheDocument();
});

it('Allows search for option', async () => {
const targetOption = MockOptions[1].label;
const user = userEvent.setup();

render(
<Dropdown
{...{
...multiProperties,
defaultValue: [MockOptions[0]]
}}
/>
);

// Verify option is initially not selected
expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.queryByText(targetOption)).not.toBeInTheDocument();

// Simulate text input
const input = screen.getByLabelText(label);
input.focus();
await user.keyboard('B{Enter}{Esc}');

// Option found and selected
expect(screen.getByText(targetOption)).toBeInTheDocument();

// Remove option by clicking pill
const pill = screen.getByRole('button', {
name: `${targetOption}`
});

await user.click(pill);
expect(screen.queryByText(targetOption)).not.toBeInTheDocument();
});

it('Remove all options via clear button', async () => {
const user = userEvent.setup();

render(
<Dropdown
{...{
...multiProperties,
defaultValue: [MockOptions[0], MockOptions[1]]
}}
/>
);

// Verify option is initially not selected
expect(screen.getByText(MockOptions[0].label)).toBeInTheDocument();
expect(screen.getByText(MockOptions[1].label)).toBeInTheDocument();

// Clear all
const button = screen.getByText('Clear All Selected Institutions');
await user.click(button);

// Verify all clear
expect(screen.queryByText(MockOptions[0].label)).not.toBeInTheDocument();
expect(screen.queryByText(MockOptions[1].label)).not.toBeInTheDocument();
});
});
89 changes: 7 additions & 82 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,13 @@
import type { KeyboardEvent, Ref } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
CSSObjectWithLabel,
ControlProps,
GroupBase,
InputActionMeta,
OnChangeValue,
OptionsOrGroups,
PropsValue,
SelectInstance
} from 'react-select';
import type { InputActionMeta, PropsValue, SelectInstance } 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 type { DropdownProperties, SelectOption } from './Dropdown.types';
import CheckboxInputOption from './DropdownInputWithCheckbox';
import { DropdownPills } from './DropdownPills';

export interface SelectOption {
value: string;
label: string;
}

// Better align Select wih CFPB styles
const extendedSelectStyles = {
control: (
base: CSSObjectWithLabel,
state: ControlProps<SelectOption, boolean, GroupBase<SelectOption>>
): CSSObjectWithLabel => ({
...base,
borderColor: state.isFocused ? '#0072ce' : base.borderColor,
outline: state.isFocused ? '1px dotted #0072ce !important' : base.outline,
outlineOffset: state.isFocused ? '3px' : base.outlineOffset,
'&:hover': {
borderColor: '#0072ce',
outline: state.isFocused
? '1px dotted #0072ce !important'
: '1px solid #0072ce !important',
outlineOffset: state.isFocused ? '3px' : '0'
}
})
};

/**
* For multi-select, hides already selected options.
*
* @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,
showAllOptions: boolean
): OptionsOrGroups<SelectOption, GroupBase<SelectOption>> => {
if (showAllOptions || !selected || !isMulti)
return options as OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;

return (options as SelectOption[]).filter(
o => !(selected as SelectOption[]).map(s => s.value).includes(o.value)
);
};

interface DropdownProperties {
defaultValue?: PropsValue<SelectOption>;
id: string;
isDisabled?: boolean;
isMulti?: boolean;
label?: string;
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
options: SelectOption[];
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();
};
import { extendedSelectStyles } from './styles';
import { filterOptions, onSelectInputFocus } from './utils';

/**
* A dropdown input component that supports multi-select.
Expand All @@ -103,8 +27,9 @@ export function Dropdown({
withCheckbox = false,
isClearable = true, // Show/Hide react-select X in select input that clears all selections
showClearAllSelectedButton = true,
className = '',
...properties
}: DropdownProperties & StateManagerProps): JSX.Element {
}: DropdownProperties): JSX.Element {
const [searchString, setSearchString] = useState<string>('');
const [selected, setSelected] = useState<PropsValue<SelectOption>>(
defaultValue ?? []
Expand Down Expand Up @@ -147,7 +72,7 @@ export function Dropdown({
const labelID = `${id}-label`;

return (
<div className='m-form-field m-form-field__select'>
<div className={`m-form-field m-form-field__select ${className}`}>
<Label
id={labelID}
htmlFor={id}
Expand Down
21 changes: 21 additions & 0 deletions src/components/Dropdown/Dropdown.types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { OnChangeValue, PropsValue } from 'react-select';
import type { StateManagerProps } from 'react-select/dist/declarations/src/useStateManager';

export interface SelectOption {
value: string;
label: string;
}

export interface DropdownProperties extends StateManagerProps {
defaultValue?: PropsValue<SelectOption>;
id: string;
isDisabled?: boolean;
isMulti?: boolean;
label?: string;
onSelect: (event: OnChangeValue<SelectOption, boolean>) => void;
options: SelectOption[];
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
}
1 change: 1 addition & 0 deletions src/components/Dropdown/DropdownPills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const DropdownPill = ({
type='button'
onClick={onClose}
onKeyDown={(event): void => onKeyCloser(event, onClose)}
name={value}
>
<Label htmlFor={value} inline>
{value}
Expand Down
22 changes: 22 additions & 0 deletions src/components/Dropdown/styles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { CSSObjectWithLabel, ControlProps, GroupBase } from 'react-select';
import type { SelectOption } from './Dropdown.types';

// Better align Select wih CFPB styles
export const extendedSelectStyles = {
control: (
base: CSSObjectWithLabel,
state: ControlProps<SelectOption, boolean, GroupBase<SelectOption>>
): CSSObjectWithLabel => ({
...base,
borderColor: state.isFocused ? '#0072ce' : base.borderColor,
contolini marked this conversation as resolved.
Show resolved Hide resolved
outline: state.isFocused ? '1px dotted #0072ce !important' : base.outline,
outlineOffset: state.isFocused ? '3px' : base.outlineOffset,
'&:hover': {
borderColor: '#0072ce',
outline: state.isFocused
? '1px dotted #0072ce !important'
: '1px solid #0072ce !important',
outlineOffset: state.isFocused ? '3px' : '0'
}
})
};
33 changes: 33 additions & 0 deletions src/components/Dropdown/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { GroupBase, OptionsOrGroups, PropsValue } from 'react-select';
import type { SelectOption } from './Dropdown.types';

export const MockOptions = [
{ value: 'value1', label: 'Option A' },
{ value: 'value2', label: 'Option B' },
Expand All @@ -8,3 +11,33 @@ export const MockOptions = [
'Options can also contain long words that will be wrapped like supercalifragilisticexpialidocious'
}
];

/**
* For multi-select, hides already selected options.
*
* @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
*/
export const filterOptions = (
options: PropsValue<SelectOption>,
selected: PropsValue<SelectOption>,
isMulti: boolean,
showAllOptions: boolean
): OptionsOrGroups<SelectOption, GroupBase<SelectOption>> => {
if (showAllOptions || !selected || !isMulti)
return options as OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;

return (options as SelectOption[]).filter(
o => !(selected as SelectOption[]).map(s => s.value).includes(o.value)
);
};

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