Created
January 13, 2024 03:03
-
-
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/).
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export { default as MultiSelect } from './MultiSelect'; | |
export type { MultiSelectProps } from './MultiSelect'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* ------- 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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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