Skip to content

Commit

Permalink
Improve MultiSelect component (#1556)
Browse files Browse the repository at this point in the history
* Improve MultiSelect component

* Improve search input styling
  • Loading branch information
robines authored Oct 29, 2024
1 parent 3120471 commit e233ea5
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 123 deletions.
46 changes: 28 additions & 18 deletions frontend/src/Components/MultiSelect/MultiSelect.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,39 @@

@import 'src/mixins';

.row {
display: flex;
gap: 10px;
flex: 1;
.item {
margin-bottom: 5px;
justify-content: flex-start;
}

@include for-mobile-down {
flex-direction: column;
}
.search_input {
margin-top: 0;
}

.container {
display: grid;
gap: 1rem
}

.col {
flex-grow: 1;
min-height: 300px;
padding: 7px;
background-color: #dddddd;
@include rounded;

@include theme-dark {
background-color: $grey-3;
.box_container {
display: grid;
gap: 1rem;

@include for-tablet-down {
grid-template-rows: 1fr auto 1fr
}

@include for-desktop-up {
grid-template-columns: 1fr auto 1fr
}
}

.item {
margin-bottom: 5px;
.button_container {
display: flex;
gap: 0.25rem;
justify-content: center;

@include for-desktop-up {
flex-direction: column;
}
}
117 changes: 52 additions & 65 deletions frontend/src/Components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { useMemo, useState } from 'react';
import { InputField } from '~/Components/InputField';
import { Icon } from '@iconify/react';
import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '~/Components';
import { useDesktop } from '~/hooks';
import { KEY } from '~/i18n/constants';
import { Button } from '../Button';
import type { DropDownOption } from '../Dropdown/Dropdown';
import styles from './MultiSelect.module.scss';
import { SelectBox } from './SelectBox';
import { exists, searchFilter } from './utils';

type MultiSelectProps<T> = {
label?: string;
optionsLabel?: string;
selectedLabel?: string;
selectAllBtnTxt?: string;
unselectAllBtnTxt?: string;
selected?: DropDownOption<T>[];
options?: DropDownOption<T>[];
onChange?: (values: T[]) => void;
Expand All @@ -22,88 +25,72 @@ type MultiSelectProps<T> = {
* `selected`: Selected values if state is managed outside this component.
*/
export function MultiSelect<T>({
label,
optionsLabel,
selectedLabel,
selectAllBtnTxt = '+',
unselectAllBtnTxt = '-',
className,
selected: initialValues = [],
options = [],
onChange,
}: MultiSelectProps<T>) {
const [searchUnselected, setSearchUnselected] = useState('');
const [searchSelected, setSearchSelected] = useState('');
const { t } = useTranslation();
const isDesktop = useDesktop();

const [search, setSearch] = useState('');
const [selected, setSelected] = useState<DropDownOption<T>[]>(initialValues);

const filteredOptions = useMemo(
() => options.filter((item) => searchFilter(item, searchUnselected)).filter((item) => !exists(item, selected)),
[options, searchUnselected, selected],
() => options.filter((item) => searchFilter(item, search)).filter((item) => !exists(item, selected)),
[options, search, selected],
);

const filteredSelected = useMemo(
() => selected.filter((item) => searchFilter(item, searchSelected)),
[searchSelected, selected],
);
const filteredSelected = useMemo(() => selected.filter((item) => searchFilter(item, search)), [search, selected]);

useEffect(() => {
onChange?.(selected.map((item) => item.value));
}, [selected, onChange]);

function selectItem(item: DropDownOption<T>) {
const updatedSelected = [...selected, item];
setSelected(updatedSelected);
onChange?.(updatedSelected.map((_item) => _item.value));
setSelected((selected) => [...selected, item]);
}

function unselectItem(item: DropDownOption<T>) {
const updatedSelected = selected.filter((_item) => _item !== item);
setSelected(updatedSelected);
onChange?.(updatedSelected.map((_item) => _item.value));
setSelected((selected) => selected.filter((_item) => _item !== item));
}

function selectAll() {
setSelected((selected) => [...selected, ...filteredOptions]);
}

function unselectAll() {
setSelected(selected.filter((item) => !filteredSelected.includes(item)));
}

return (
<label className={className}>
{label}
<div className={styles.row}>
<div className={styles.col}>
{optionsLabel}
<InputField<string> placeholder={'Search...'} onChange={(e) => setSearchUnselected(e)} />
{filteredOptions.map((item, i) => (
<Button
className={styles.item}
key={`${i}-${item.value}`}
display="block"
theme="samf"
onClick={() => selectItem(item)}
>
{item.label}
</Button>
))}
{filteredOptions.length > 0 && (
<Button theme="blue" onClick={() => setSelected(options)}>
{selectAllBtnTxt}
</Button>
)}
</div>
<div className={classNames(styles.container, className)}>
<Input
type="text"
onChange={(e) => setSearch(e as string)}
value={search}
placeholder={`${t(KEY.common_search)}...`}
className={styles.search_input}
/>
<div className={styles.box_container}>
<SelectBox items={filteredOptions} onItemClick={selectItem} label={optionsLabel} />

<div className={styles.col}>
{selectedLabel}
<InputField<string> placeholder={'Search...'} onChange={(e) => setSearchSelected(e)} />
{filteredSelected.map((item, i) => (
<Button
className={styles.item}
key={`${i}-${item.value}`}
display="block"
theme="white"
onClick={() => unselectItem(item)}
>
{item.label}
</Button>
))}
{filteredSelected.length > 0 && (
<Button theme="blue" onClick={() => setSelected([])}>
{unselectAllBtnTxt}
</Button>
)}
<div className={styles.button_container}>
<Button type="button" theme="blue" onClick={selectAll} disabled={filteredOptions.length === 0}>
{t(KEY.common_select_all)}
<Icon icon="radix-icons:double-arrow-right" rotate={isDesktop ? 0 : 1} />
</Button>

<Button type="button" theme="blue" onClick={unselectAll} disabled={filteredSelected.length === 0}>
<Icon icon="radix-icons:double-arrow-right" rotate={isDesktop ? 2 : 3} />
{t(KEY.common_unselect_all)}
</Button>
</div>

<SelectBox items={filteredSelected} onItemClick={unselectItem} itemButtonTheme="white" label={selectedLabel} />
</div>
</label>
</div>
);
}
36 changes: 36 additions & 0 deletions frontend/src/Components/MultiSelect/SelectBox.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@import 'src/constants';

@import 'src/mixins';

.label {
margin-bottom: 0.25rem;
font-weight: 700;
}

.box {
background: $grey-4;
border-radius: 5px;
padding: 0.5rem;

@include theme-dark {
background: $black-2;
}
}

.inner {
display: flex;
flex-direction: column;
gap: 0.125rem;
overflow-y: auto;
height: 14rem;

@include for-desktop-up {
height: 26rem;
}
}

.button {
display: block;
width: 100%;
text-align: left;
}
35 changes: 35 additions & 0 deletions frontend/src/Components/MultiSelect/SelectBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Button } from '~/Components';
import type { ButtonTheme } from '~/Components/Button';
import type { DropDownOption } from '~/Components/Dropdown/Dropdown';
import styles from './SelectBox.module.scss';

type Props<T> = {
items: DropDownOption<T>[];
label?: string;
onItemClick?: (item: DropDownOption<T>) => void;
itemButtonTheme?: ButtonTheme;
};

export function SelectBox<T>({ items, label, onItemClick, itemButtonTheme = 'samf' }: Props<T>) {
return (
<div className={styles.container}>
{label && <div className={styles.label}>{label}</div>}
<div className={styles.box}>
<div className={styles.inner}>
{items.map((item, i) => (
<Button
type="button"
// biome-ignore lint/suspicious/noArrayIndexKey: no other unique value available
key={i}
onClick={() => onItemClick?.(item)}
theme={itemButtonTheme}
className={styles.button}
>
{item.label}
</Button>
))}
</div>
</div>
</div>
);
}
41 changes: 1 addition & 40 deletions frontend/src/Pages/ComponentPage/ComponentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,46 +35,7 @@ export function ComponentPage() {

<br />

<MultiSelect
options={[
{
label: '1',
value: 1,
},
{
label: '2',
value: 2,
},
{
label: '3',
value: 3,
},
{
label: '4',
value: 4,
},
{
label: '5',
value: 5,
},
{
label: '6',
value: 6,
},
{
label: '7',
value: 7,
},
{
label: '8',
value: 8,
},
{
label: '9',
value: 9,
},
]}
/>
<MultiSelect options={Array.from({ length: 20 }).map((_, i) => ({ label: String(i), value: i }))} />
<br />
<br />
<br />
Expand Down
1 change: 1 addition & 0 deletions frontend/src/_constants.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ $yellow: #e0a014;
$white: #ffffff;
$black: #000000;
$black-1: #161616; // small contrast to black
$black-2: #222222;
$grey-5: #f4f4f4;
$grey-4: #eeeeee;
$grey-35: #cccccc;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const COLORS = {
white: '#ffffff',
black: '#000000',
black_1: '#161616',
black_2: '#222222',
grey_5: '#f4f4f4',
grey_4: '#eeeeee',
grey_35: '#cccccc',
Expand Down

0 comments on commit e233ea5

Please sign in to comment.