Skip to content

Instantly share code, notes, and snippets.

@Toshinaki
Created January 13, 2024 03:03
Show Gist options
  • Save Toshinaki/d74adeca719a016389d709845a26037a to your computer and use it in GitHub Desktop.
Save Toshinaki/d74adeca719a016389d709845a26037a to your computer and use it in GitHub Desktop.
Custom Mantine MultiSelect takes in any object as option and a custom renderer. Basically a copy of the built-in [MultiSelect](https://mantine.dev/core/multi-select/).
export { default as MultiSelect } from './MultiSelect';
export type { MultiSelectProps } from './MultiSelect';
/* ------- OptionsDropdown ------- */
.optionsDropdownScrollArea {
margin-right: calc(var(--_combobox-padding) * -1);
@mixin rtl {
margin-left: calc(var(--_combobox-padding) * -1);
margin-right: 0;
}
}
.optionsDropdownOption {
display: flex;
align-items: center;
flex-direction: var(--_flex-direction, row);
gap: rem(8px);
&[data-reverse] {
justify-content: space-between;
}
}
.optionsDropdownCheckIcon {
opacity: 0.4;
width: 0.8em;
min-width: 0.8em;
height: 0.8em;
[data-combobox-selected] & {
opacity: 1;
}
}
import React, {
ReactNode,
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
} from 'react';
import { useId, useUncontrolled } from '@mantine/hooks';
import {
filterPickedValues,
findOptionByValue,
parseOptions,
validateOptions,
} from './utils';
import {
BoxProps,
Combobox,
extractStyleProps,
getOptionsLockup,
InputBase,
Pill,
PillsInput,
PillProps,
useCombobox,
useProps,
useResolvedStylesApi,
useStyles,
type __BaseInputProps,
type __CloseButtonProps,
type __InputStylesNames,
type ComboboxLikeStylesNames,
type ComboboxLikeProps,
type ElementProps,
type Factory,
type StylesApiProps,
Loader,
} from '@mantine/core';
import { OptionsDropdown } from './OptionsDropdown';
import type {
OptionsFilter,
ParsedOption,
SelectOptionGroupSrc,
SelectOptionSrc,
} from '../types';
export type MultiSelectStylesNames =
| __InputStylesNames
| ComboboxLikeStylesNames
| 'pill'
| 'pillsList'
| 'inputField';
export interface MultiSelectProps<T extends object = object>
extends BoxProps,
__BaseInputProps,
Omit<ComboboxLikeProps, 'data' | 'filter'>,
StylesApiProps<MultiSelectFactory<T>>,
ElementProps<'input', 'size' | 'value' | 'defaultValue' | 'onChange'> {
/** Controlled component value */
value?: string[];
/** Default value for uncontrolled component */
defaultValue?: string[];
/** Called whe value changes */
onChange?: (value: string[]) => void;
/** Controlled search value */
searchValue?: string;
/** Default search value */
defaultSearchValue?: string;
/** Called when search changes */
onSearchChange?: (value: string) => void;
/** Maximum number of values, `Infinity` by default */
maxValues?: number;
/** Determines whether the select should be searchable, `false` by default */
searchable?: boolean;
/** Message displayed when no option matched current search query, only applicable when `searchable` prop is set */
nothingFoundMessage?: React.ReactNode;
/** Determines whether check icon should be displayed near the selected option label, `true` by default */
withCheckIcon?: boolean;
/** Position of the check icon relative to the option label, `'left'` by default */
checkIconPosition?: 'left' | 'right';
/** Determines whether picked options should be removed from the options list, `false` by default */
hidePickedOptions?: boolean;
/** Determines whether it should be possible to deselect value by clicking on the selected option, `false` by default */
allowDeselect?: boolean;
/** Determines whether the clear button should be displayed in the right section when the component has value, `false` by default */
clearable?: boolean;
/** Props passed down to the clear button */
clearButtonProps?: __CloseButtonProps & ElementProps<'button'>;
/** Props passed down to the hidden input */
hiddenInputProps?: React.ComponentPropsWithoutRef<'input'>;
/** Divider used to separate values in the hidden input `value` attribute, `','` by default */
hiddenInputValuesDivider?: string;
data?: Array<SelectOptionSrc<T> | SelectOptionGroupSrc<T>>;
filter?: OptionsFilter<T>;
valueField: string;
labelField?: string;
renderOption?: (option: ParsedOption<T>, selected?: boolean) => ReactNode;
OptionComponent?: React.ElementType;
renderValue?: (option: ParsedOption<T>, props?: PillProps) => ReactNode;
ValueComponent?: React.ElementType;
disableSelected?: boolean;
creatable?: boolean;
onCreate?: (value: string) => void;
loading?: boolean;
}
export type MultiSelectFactory<T extends object> = Factory<{
props: MultiSelectProps<T>;
ref: HTMLInputElement;
stylesNames: MultiSelectStylesNames;
}>;
const defaultProps: Partial<MultiSelectProps<object>> = {
maxValues: Infinity,
withCheckIcon: true,
allowDeselect: false,
disableSelected: true,
checkIconPosition: 'left',
hiddenInputValuesDivider: ',',
size: 'sm',
};
const _MultiSelect = <T extends object>(
_props: MultiSelectProps<T>,
ref: React.ForwardedRef<HTMLInputElement>
) => {
const props = useProps('MultiSelect', defaultProps, _props);
const {
classNames,
className,
style,
styles,
unstyled,
vars: _vars,
size,
value,
defaultValue,
onChange,
onKeyDown,
variant,
data = [],
valueField,
labelField,
renderOption,
OptionComponent,
renderValue,
ValueComponent,
disableSelected,
dropdownOpened,
defaultDropdownOpened,
onDropdownOpen,
onDropdownClose,
selectFirstOptionOnChange,
onOptionSubmit,
comboboxProps,
filter,
limit,
withScrollArea,
maxDropdownHeight,
searchValue,
defaultSearchValue,
onSearchChange,
allowDeselect,
readOnly,
disabled,
onFocus,
onBlur,
onPaste: _onPaste,
radius,
rightSection,
rightSectionWidth,
rightSectionPointerEvents,
rightSectionProps,
leftSection,
leftSectionWidth,
leftSectionPointerEvents,
leftSectionProps,
inputContainer,
inputWrapperOrder,
withAsterisk,
labelProps,
descriptionProps,
errorProps,
wrapperProps,
description,
label,
error,
maxValues,
searchable,
nothingFoundMessage,
withCheckIcon,
checkIconPosition,
hidePickedOptions,
withErrorStyles,
name,
form,
id,
clearable,
clearButtonProps,
hiddenInputProps,
placeholder,
hiddenInputValuesDivider,
required,
creatable,
onCreate,
loading,
...others
} = props;
const parsedData = useMemo(
() => parseOptions(data, valueField, labelField),
[data, labelField, valueField]
);
validateOptions(parsedData);
const optionsLockup = useMemo(
() => getOptionsLockup(parsedData),
[parsedData]
);
const _id = useId(id);
const combobox = useCombobox({
opened: dropdownOpened,
defaultOpened: defaultDropdownOpened,
onDropdownOpen,
onDropdownClose: () => {
onDropdownClose?.();
combobox.resetSelectedOption();
},
});
const {
styleProps,
rest: { type: _type, ...rest },
} = extractStyleProps(others);
const [_value, setValue] = useUncontrolled({
value,
defaultValue,
finalValue: [],
onChange,
});
const [_searchValue, setSearchValue] = useUncontrolled({
value: searchValue,
defaultValue: defaultSearchValue,
finalValue: '',
onChange: onSearchChange,
});
const getStyles = useStyles<MultiSelectFactory<T>>({
name: 'MultiSelect',
classes: {},
props,
classNames,
styles,
unstyled,
});
const { resolvedClassNames, resolvedStyles } = useResolvedStylesApi<
MultiSelectFactory<T>
>({
props,
styles,
classNames,
});
const handleInputKeydown = (event: React.KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.key === ' ' && !searchable) {
event.preventDefault();
combobox.toggleDropdown();
}
if (
event.key === 'Backspace' &&
_searchValue.length === 0 &&
_value.length > 0
) {
setValue(_value.slice(0, _value.length - 1));
}
};
const optionRenderer = useCallback(
(option: ParsedOption<T>, selected?: boolean) =>
OptionComponent ? (
<OptionComponent option={option} selected={selected} />
) : (
renderOption?.(option, selected) || option.label
),
[OptionComponent, renderOption]
);
const valueRenderer = useCallback(
(value: string) => {
const option = findOptionByValue(parsedData, value);
const valueProps = {
withRemoveButton: !readOnly,
onRemove: () => setValue(_value.filter((i) => i !== option.value)),
unstyled: unstyled,
...getStyles('pill'),
};
return ValueComponent ? (
<ValueComponent key={value} option={option} {...valueProps} />
) : (
renderValue?.(option, valueProps) || (
<Pill key={value} {...valueProps}>
{optionsLockup[option.value]?.label || option.value}
</Pill>
)
);
},
[
ValueComponent,
_value,
getStyles,
optionsLockup,
parsedData,
readOnly,
renderValue,
setValue,
unstyled,
]
);
const values = _value.map((item) => valueRenderer(item));
useEffect(() => {
if (selectFirstOptionOnChange) {
combobox.selectFirstOption();
}
}, [combobox, selectFirstOptionOnChange, _value]);
const clearButton = clearable &&
_value.length > 0 &&
!disabled &&
!readOnly && (
<Combobox.ClearButton
size={size}
{...clearButtonProps}
onClear={() => {
setValue([]);
setSearchValue('');
}}
/>
);
const filteredData = filterPickedValues({ data: parsedData, value: _value });
return (
<>
<Combobox
store={combobox}
classNames={resolvedClassNames}
styles={resolvedStyles}
unstyled={unstyled}
size={size}
readOnly={readOnly}
__staticSelector='MultiSelect'
onOptionSubmit={(val) => {
onOptionSubmit?.(val);
setSearchValue('');
combobox.updateSelectedOptionIndex('selected');
if (val === '$create') {
onCreate?.(_searchValue);
} else if (_value.includes(optionsLockup[val].value)) {
if (allowDeselect) {
setValue(_value.filter((v) => v !== optionsLockup[val].value));
}
} else if (_value.length < maxValues!) {
setValue([..._value, optionsLockup[val].value]);
}
}}
{...comboboxProps}
>
<Combobox.DropdownTarget>
<PillsInput
{...styleProps}
__staticSelector='MultiSelect'
classNames={resolvedClassNames}
styles={resolvedStyles}
unstyled={unstyled}
size={size}
className={className}
style={style}
variant={variant}
disabled={disabled}
radius={radius}
rightSection={
loading ? (
<Loader />
) : (
rightSection ||
clearButton || (
<Combobox.Chevron
size={size}
error={error}
unstyled={unstyled}
/>
)
)
}
rightSectionPointerEvents={
rightSectionPointerEvents || (clearButton ? 'all' : 'none')
}
rightSectionWidth={rightSectionWidth}
rightSectionProps={rightSectionProps}
leftSection={leftSection}
leftSectionWidth={leftSectionWidth}
leftSectionPointerEvents={leftSectionPointerEvents}
leftSectionProps={leftSectionProps}
inputContainer={inputContainer}
inputWrapperOrder={inputWrapperOrder}
withAsterisk={withAsterisk}
labelProps={labelProps}
descriptionProps={descriptionProps}
errorProps={errorProps}
wrapperProps={wrapperProps}
description={description}
label={label}
error={error}
multiline
withErrorStyles={withErrorStyles}
__stylesApiProps={{
...props,
rightSectionPointerEvents:
rightSectionPointerEvents || (clearButton ? 'all' : 'none'),
multiline: true,
}}
pointer={!searchable && !creatable}
onClick={() =>
searchable || creatable
? combobox.openDropdown()
: combobox.toggleDropdown()
}
data-expanded={combobox.dropdownOpened || undefined}
id={_id}
required={required}
>
<Pill.Group
disabled={disabled}
unstyled={unstyled}
{...getStyles('pillsList')}
>
{values}
<Combobox.EventsTarget>
<PillsInput.Field
{...rest}
ref={ref}
id={_id}
placeholder={placeholder}
type={
!searchable && !creatable && !placeholder
? 'hidden'
: 'visible'
}
{...getStyles('inputField')}
unstyled={unstyled}
onFocus={(event) => {
onFocus?.(event);
(searchable || creatable) && combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setSearchValue('');
}}
onKeyDown={handleInputKeydown}
value={_searchValue}
onChange={(event) => {
setSearchValue(event.currentTarget.value);
(searchable || creatable) && combobox.openDropdown();
selectFirstOptionOnChange && combobox.selectFirstOption();
}}
disabled={disabled}
readOnly={readOnly || (!searchable && !creatable)}
pointer={!searchable && !creatable}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<OptionsDropdown
data={hidePickedOptions ? filteredData : parsedData}
optionRenderer={optionRenderer}
hidden={readOnly || disabled}
filter={filter}
search={_searchValue}
limit={limit}
hiddenWhenEmpty={
!searchable ||
!nothingFoundMessage ||
(hidePickedOptions &&
filteredData.length === 0 &&
_searchValue.trim().length === 0)
}
withScrollArea={withScrollArea}
maxDropdownHeight={maxDropdownHeight}
filterOptions={!!searchable}
value={_value}
checkIconPosition={checkIconPosition}
withCheckIcon={withCheckIcon}
nothingFoundMessage={nothingFoundMessage}
unstyled={unstyled}
disableSelected={disableSelected}
labelId={`${_id}-label`}
creatable={creatable}
/>
</Combobox>
<input
type='hidden'
name={name}
value={_value.join(hiddenInputValuesDivider)}
form={form}
disabled={disabled}
{...hiddenInputProps}
/>
</>
);
};
_MultiSelect.classes = { ...InputBase.classes, ...Combobox.classes };
_MultiSelect.displayName = 'MultiSelect';
const MultiSelect = memo(forwardRef(_MultiSelect)) as <T extends object>(
_props: MultiSelectProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> }
) => ReturnType<typeof _MultiSelect>;
export default MultiSelect;
import classes from './MultiSelect.module.css';
import React, { ReactNode, memo } from 'react';
import clsx from 'clsx';
import {
defaultOptionsFilter,
getFlatOptions,
isEmptyData,
isOptionGroup,
} from './utils';
import { CheckIcon, Combobox, ScrollArea } from '@mantine/core';
import type { OptionsFilter, ParsedOption, ParsedOptionGroup } from '../types';
interface OptionProps<T extends object> {
data: ParsedOption<T> | ParsedOptionGroup<T>;
withCheckIcon?: boolean;
value?: string[] | null;
checkIconPosition?: 'left' | 'right';
unstyled: boolean | undefined;
optionRenderer: (option: ParsedOption<T>, selected?: boolean) => ReactNode;
disableSelected?: boolean;
}
const Option = memo(
<T extends object>({
data,
withCheckIcon,
value,
checkIconPosition,
unstyled,
optionRenderer,
disableSelected,
}: OptionProps<T>) => {
if (!isOptionGroup(data)) {
const selected = value?.includes(data.value);
const check = withCheckIcon && selected && (
<CheckIcon className={classes.optionsDropdownCheckIcon} />
);
return (
<Combobox.Option
value={data.value}
disabled={data.disabled || (disableSelected && selected)}
className={clsx({ [classes.optionsDropdownOption]: !unstyled })}
data-reverse={checkIconPosition === 'right' || undefined}
data-checked={selected || undefined}
aria-selected={selected}
>
{checkIconPosition === 'left' && check}
<span>{optionRenderer(data, selected)}</span>
{checkIconPosition === 'right' && check}
</Combobox.Option>
);
}
const options = data.items.map((item) => (
<Option
key={item.value}
data={item}
value={value}
unstyled={unstyled}
withCheckIcon={withCheckIcon}
checkIconPosition={checkIconPosition}
optionRenderer={optionRenderer}
disableSelected={disableSelected}
/>
));
return <Combobox.Group label={data.group}>{options}</Combobox.Group>;
}
);
Option.displayName = 'Option';
export interface OptionsDropdownProps<T extends object> {
data: Array<ParsedOption<T> | ParsedOptionGroup<T>>;
optionRenderer: (option: ParsedOption<T>, selected?: boolean) => ReactNode;
disableSelected?: boolean;
filter: OptionsFilter<T> | undefined;
search: string | undefined;
limit: number | undefined;
withScrollArea: boolean | undefined;
maxDropdownHeight: number | string | undefined;
hidden?: boolean;
hiddenWhenEmpty?: boolean;
filterOptions?: boolean;
withCheckIcon?: boolean;
value?: string[] | null;
checkIconPosition?: 'left' | 'right';
nothingFoundMessage?: React.ReactNode;
unstyled: boolean | undefined;
labelId: string;
creatable?: boolean;
}
export const OptionsDropdown = memo(
<T extends object>({
data = [],
optionRenderer,
disableSelected,
hidden,
hiddenWhenEmpty,
filter,
search,
limit,
maxDropdownHeight,
withScrollArea = true,
filterOptions = true,
withCheckIcon = false,
value,
checkIconPosition,
nothingFoundMessage,
unstyled,
labelId,
creatable,
}: OptionsDropdownProps<T>) => {
const shouldFilter = typeof search === 'string';
const filteredData = shouldFilter
? (filter || defaultOptionsFilter)({
options: data,
search: filterOptions ? search : '',
limit: limit ?? Infinity,
})
: data;
const hasExactMatch = getFlatOptions(data).some(
(item) => item.label === search
);
const isEmpty = isEmptyData(filteredData);
const options = filteredData.map((item) => (
<Option
key={isOptionGroup(item) ? item.group : item.value}
data={item}
withCheckIcon={withCheckIcon}
value={value}
checkIconPosition={checkIconPosition}
unstyled={unstyled}
optionRenderer={optionRenderer}
disableSelected={disableSelected}
/>
));
return (
<Combobox.Dropdown
hidden={hidden || (hiddenWhenEmpty && isEmpty && !creatable)}
>
<Combobox.Options labelledBy={labelId}>
{withScrollArea ? (
<ScrollArea.Autosize
mah={maxDropdownHeight ?? 220}
type='scroll'
scrollbarSize='var(--_combobox-padding)'
offsetScrollbars='y'
className={classes.optionsDropdownScrollArea}
>
{options}
</ScrollArea.Autosize>
) : (
options
)}
{creatable &&
!hasExactMatch &&
search &&
search.trim().length > 0 && (
<Combobox.Option value='$create'>
+ Create {search}
</Combobox.Option>
)}
{!hasExactMatch &&
search &&
search.trim().length > 0 &&
isEmpty &&
nothingFoundMessage && (
<Combobox.Empty>{nothingFoundMessage}</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
);
}
);
OptionsDropdown.displayName = 'OptionsDropdown';
import _ from '@lodash';
import type {
FilterOptionsInput,
ParsedOption,
ParsedOptionGroup,
SelectOptionGroupSrc,
SelectOptionSrc,
} from '../types';
export const getOptionsLockup = <T extends object>(
options: Array<ParsedOption<T> | ParsedOptionGroup<T>>
): Record<string, ParsedOption<T>> =>
options.reduce<Record<string, ParsedOption<T>>>((acc, item) => {
if ('group' in item) {
return {
...acc,
...getOptionsLockup(item.items),
};
}
acc[item.value] = item;
return acc;
}, {});
export const isOptionGroup = <T extends object>(
item:
| ParsedOption<T>
| ParsedOptionGroup<T>
| SelectOptionSrc<T>
| SelectOptionGroupSrc<T>
): item is ParsedOptionGroup<T> | SelectOptionGroupSrc<T> => 'group' in item;
export const parseOptions = <T extends object>(
options: Array<SelectOptionSrc<T> | SelectOptionGroupSrc<T>>,
valueField: string,
labelField?: string
): Array<ParsedOption<T> | ParsedOptionGroup<T>> =>
options.map((item) => {
if (isOptionGroup(item)) {
return {
group: item.group,
items: parseOptions(item.items, valueField, labelField) as Array<
ParsedOption<T>
>,
};
} else {
return {
label: `${_.get(item, labelField || valueField)}`,
value: `${_.get(item, valueField)}`,
disabled: _.get(item, 'disabled'),
src: { ...item },
} as ParsedOption<T>;
}
});
export const validateOptions = <T extends object>(
options: Array<ParsedOption<T> | ParsedOptionGroup<T>>,
valuesSet = new Set()
) => {
if (!Array.isArray(options)) {
return;
}
for (const option of options) {
if (isOptionGroup(option)) {
validateOptions(option.items, valuesSet);
} else {
if (typeof option.value === 'undefined') {
throw new Error('[MultiSelect] Each option must have value property');
}
if (typeof option.value !== 'string') {
throw new Error(
`[MultiSelect] Option value must be a string, other data formats are not supported, got ${typeof option.value}`
);
}
if (valuesSet.has(option.value)) {
throw new Error(
`[MultiSelect] Duplicate options are not supported. Option with value "${option.value}" was provided more than once`
);
}
valuesSet.add(option.value);
}
}
};
export const defaultOptionsFilter = <T extends object>({
options,
search,
limit,
}: FilterOptionsInput<T>): Array<ParsedOption<T> | ParsedOptionGroup<T>> => {
const result: Array<ParsedOption<T> | ParsedOptionGroup<T>> = [];
for (let i = 0; i < options.length; i += 1) {
const item = options[i];
if (result.length === limit) {
return result;
}
if (isOptionGroup(item)) {
result.push({
group: item.group,
items: defaultOptionsFilter({
options: item.items,
search,
limit: limit - result.length,
}) as Array<ParsedOption<T>>,
});
} else {
if (item.label.toLowerCase().includes(search.trim().toLowerCase())) {
result.push(item);
}
}
}
return result;
};
export const isEmptyData = <T extends object>(
data: Array<ParsedOption<T> | ParsedOptionGroup<T>>
) => {
if (data.length === 0) {
return true;
}
for (const item of data) {
if (!('group' in item)) {
return false;
}
if (item.items.length > 0) {
return false;
}
}
return true;
};
export const getFlatOptions = <T extends object>(
data: Array<ParsedOption<T> | ParsedOptionGroup<T>>
) =>
data.reduce<Array<ParsedOption<T>>>((acc, curr) => {
if (isOptionGroup(curr)) {
return [...acc, ...curr.items];
}
return [...acc, curr];
}, []);
export const findOptionByValue = <T extends object>(
data: Array<ParsedOption<T> | ParsedOptionGroup<T>>,
value: string
) => {
const flatData = getFlatOptions(data);
const result = flatData.find((option) => option.value === value);
if (!result) {
throw new Error(
`[MultiSelect] Unexpected error! No option matches given value: ${value}`
);
}
return result;
};
interface FilterPickedTagsInput<T extends object> {
data: Array<ParsedOption<T> | ParsedOptionGroup<T>>;
value: string[];
}
export const filterPickedValues = <T extends object>({
data,
value,
}: FilterPickedTagsInput<T>) => {
const normalizedValue = value.map((item) => item.trim().toLowerCase());
const filtered = data.reduce<Array<ParsedOption<T> | ParsedOptionGroup<T>>>(
(acc, item) => {
if (isOptionGroup(item)) {
acc.push({
group: item.group,
items: item.items.filter(
(option) =>
normalizedValue.indexOf(option.value.toLowerCase().trim()) === -1
),
});
} else if (
normalizedValue.indexOf(item.value.toLowerCase().trim()) === -1
) {
acc.push(item);
}
return acc;
},
[]
);
return filtered;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment