Last active
January 13, 2024 03:00
-
-
Save Toshinaki/b052fdc672d1803c8c9951b104efd04e to your computer and use it in GitHub Desktop.
Custom Mantine Select takes in any object as option and a custom renderer. Basically a copy of the built-in [Select](https://mantine.dev/core/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 Select } from './Select'; | |
export type { SelectProps } from './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
import classes from './Select.module.css'; | |
import { ReactNode, memo } from 'react'; | |
import clsx from 'clsx'; | |
import _ from '@lodash'; | |
import { isOptionGroup } from './utils'; | |
import { CheckIcon, Combobox, ScrollArea } from '@mantine/core'; | |
import type { 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 === 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 OptionsProps<T extends object> { | |
data?: Array<ParsedOption<T> | ParsedOptionGroup<T>>; | |
optionRenderer: (option: ParsedOption<T>, selected?: boolean) => ReactNode; | |
disableSelected?: boolean; | |
withScrollArea: boolean | undefined; | |
maxDropdownHeight: number | string | undefined; | |
withCheckIcon?: boolean; | |
value?: string | null; | |
checkIconPosition?: 'left' | 'right'; | |
unstyled: boolean | undefined; | |
} | |
export const Options = memo( | |
<T extends object>({ | |
data = [], | |
optionRenderer, | |
disableSelected, | |
maxDropdownHeight, | |
withScrollArea = true, | |
withCheckIcon = false, | |
value, | |
checkIconPosition, | |
unstyled, | |
}: OptionsProps<T>) => { | |
const options = data.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 withScrollArea ? ( | |
<ScrollArea.Autosize | |
mah={maxDropdownHeight ?? 220} | |
type='scroll' | |
scrollbarSize='var(--_combobox-padding)' | |
offsetScrollbars='y' | |
className={classes.optionsDropdownScrollArea} | |
> | |
{options} | |
</ScrollArea.Autosize> | |
) : ( | |
options | |
); | |
} | |
); | |
Options.displayName = 'Options'; |
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 _ from '@lodash'; | |
import { | |
defaultOptionsFilter, | |
getOptionsLockup, | |
isEmptyData, | |
parseOptions, | |
validateOptions, | |
} from './utils'; | |
import { | |
BoxProps, | |
Combobox, | |
Input, | |
InputBase, | |
useCombobox, | |
useProps, | |
useResolvedStylesApi, | |
type __BaseInputProps, | |
type __CloseButtonProps, | |
type __InputStylesNames, | |
type ComboboxLikeStylesNames, | |
type ComboboxLikeProps, | |
type ElementProps, | |
type Factory, | |
type InputVariant, | |
type StylesApiProps, | |
} from '@mantine/core'; | |
import { Options } from './Options'; | |
import type { | |
OptionsFilter, | |
ParsedOption, | |
SelectOptionGroupSrc, | |
SelectOptionSrc, | |
} from '../types'; | |
export type SelectStylesNames = __InputStylesNames | ComboboxLikeStylesNames; | |
export interface SelectProps<T extends object = object> | |
extends BoxProps, | |
__BaseInputProps, | |
Omit<ComboboxLikeProps, 'data' | 'filter'>, | |
StylesApiProps<SelectFactory<T>>, | |
ElementProps<'button', 'onChange' | 'size' | 'value' | 'defaultValue'> { | |
/** Controlled component value */ | |
value?: string | null; | |
/** Uncontrolled component default value */ | |
defaultValue?: string | null; | |
/** Called when value changes */ | |
onChange?: (value: string | null) => void; | |
/** Determines whether the select should be searchable, `false` by default */ | |
searchable?: boolean; | |
/** 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'; | |
/** Message displayed when no option matched current search query, only applicable when `searchable` prop is set */ | |
nothingFoundMessage?: React.ReactNode; | |
/** Controlled search value */ | |
searchValue?: string; | |
/** Default search value */ | |
defaultSearchValue?: string; | |
/** Called when search changes */ | |
onSearchChange?: (value: string) => void; | |
/** 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'>; | |
data?: Array<SelectOptionSrc<T> | SelectOptionGroupSrc<T>>; | |
filter?: OptionsFilter<T>; | |
valueField: string; | |
labelField?: string; | |
renderOption?: (option: ParsedOption<T>, selected?: boolean) => ReactNode; | |
OptionComponent?: React.ElementType; | |
placeholder?: string; | |
readOnly?: boolean; | |
disableSelected?: boolean; | |
} | |
export type SelectFactory<T extends object> = Factory<{ | |
props: SelectProps<T>; | |
ref: HTMLButtonElement; | |
stylesNames: SelectStylesNames; | |
variant: InputVariant; | |
}>; | |
const defaultProps: Partial<SelectProps<object>> = { | |
searchable: false, | |
withCheckIcon: true, | |
allowDeselect: false, | |
disableSelected: true, | |
checkIconPosition: 'left', | |
}; | |
const _Select = <T extends object>( | |
_props: SelectProps<T>, | |
ref: React.ForwardedRef<HTMLButtonElement> | |
) => { | |
const props = useProps('Select', defaultProps, _props); | |
const { | |
classNames, | |
styles, | |
unstyled, | |
vars: _vars, | |
dropdownOpened, | |
defaultDropdownOpened, | |
onDropdownClose, | |
onDropdownOpen, | |
onClick, | |
onChange, | |
data = [], | |
valueField, | |
labelField, | |
renderOption, | |
OptionComponent, | |
value, | |
defaultValue, | |
selectFirstOptionOnChange, | |
onOptionSubmit, | |
comboboxProps, | |
readOnly, | |
disabled, | |
filter, | |
limit, | |
withScrollArea, | |
maxDropdownHeight, | |
size, | |
searchable, | |
rightSection, | |
checkIconPosition, | |
withCheckIcon, | |
nothingFoundMessage, | |
name, | |
form, | |
searchValue, | |
defaultSearchValue, | |
onSearchChange, | |
allowDeselect, | |
disableSelected, | |
error, | |
rightSectionPointerEvents, | |
id, | |
clearable, | |
clearButtonProps, | |
hiddenInputProps, | |
placeholder, | |
...others | |
} = props; | |
const parsedOptions = useMemo( | |
() => parseOptions(data, valueField, labelField), | |
[data, labelField, valueField] | |
); | |
validateOptions(parsedOptions); | |
const optionsLockup = useMemo( | |
() => getOptionsLockup(parsedOptions), | |
[parsedOptions] | |
); | |
const _id = useId(id); | |
const [_value, setValue] = useUncontrolled({ | |
value, | |
defaultValue, | |
finalValue: null, | |
onChange, | |
}); | |
const selectedOption = | |
typeof _value === 'string' ? optionsLockup[_value] : undefined; | |
const [search, setSearch] = useUncontrolled({ | |
value: searchValue, | |
defaultValue: defaultSearchValue, | |
finalValue: selectedOption ? selectedOption.label : '', | |
onChange: onSearchChange, | |
}); | |
const combobox = useCombobox({ | |
opened: dropdownOpened, | |
defaultOpened: defaultDropdownOpened, | |
onDropdownOpen, | |
onDropdownClose: () => { | |
onDropdownClose?.(); | |
combobox.resetSelectedOption(); | |
}, | |
}); | |
const { resolvedClassNames, resolvedStyles } = useResolvedStylesApi< | |
SelectFactory<T> | |
>({ | |
props, | |
styles, | |
classNames, | |
}); | |
useEffect(() => { | |
if (selectFirstOptionOnChange) { | |
combobox.selectFirstOption(); | |
} | |
}, [selectFirstOptionOnChange, _value, combobox]); | |
useEffect(() => { | |
if (value === null) { | |
setSearch(''); | |
} | |
if (typeof value === 'string' && selectedOption) { | |
setSearch(selectedOption.label); | |
} | |
}, [selectedOption, setSearch, value]); | |
const clearButton = clearable && !!_value && !disabled && !readOnly && ( | |
<Combobox.ClearButton | |
size={size as string} | |
{...clearButtonProps} | |
onClear={() => { | |
setValue(null); | |
setSearch(''); | |
}} | |
/> | |
); | |
const optionRenderer = useCallback( | |
(option: ParsedOption<T>, selected?: boolean) => | |
OptionComponent ? ( | |
<OptionComponent option={option} selected={selected} /> | |
) : ( | |
renderOption?.(option, selected) || option.label | |
), | |
[OptionComponent, renderOption] | |
); | |
const shouldFilter = typeof search === 'string'; | |
const filterOptions = searchable && selectedOption?.label !== search; | |
const filteredData = shouldFilter | |
? (filter || defaultOptionsFilter)({ | |
options: parsedOptions, | |
search: filterOptions ? search : '', | |
limit: limit ?? Infinity, | |
}) | |
: parsedOptions; | |
const isEmpty = isEmptyData(filteredData); | |
return ( | |
<> | |
<Combobox | |
store={combobox} | |
__staticSelector='Select' | |
classNames={resolvedClassNames} | |
styles={resolvedStyles} | |
unstyled={unstyled} | |
readOnly={readOnly} | |
onOptionSubmit={(val) => { | |
onOptionSubmit?.(val); | |
const optionLockup = allowDeselect | |
? optionsLockup[val].value === _value | |
? null | |
: optionsLockup[val] | |
: optionsLockup[val]; | |
const nextValue = optionLockup?.value || null; | |
setValue(nextValue); | |
setSearch( | |
typeof nextValue === 'string' ? optionLockup?.label || '' : '' | |
); | |
combobox.closeDropdown(); | |
}} | |
size={size} | |
{...comboboxProps} | |
> | |
<Combobox.Target targetType='button'> | |
<InputBase | |
id={_id} | |
ref={ref} | |
component='button' | |
rightSection={ | |
rightSection || | |
clearButton || ( | |
<Combobox.Chevron | |
size={size} | |
error={error} | |
unstyled={unstyled} | |
/> | |
) | |
} | |
rightSectionPointerEvents={ | |
rightSectionPointerEvents || (clearButton ? 'all' : 'none') | |
} | |
{...others} | |
type='button' | |
pointer | |
size={size} | |
__staticSelector='Select' | |
disabled={disabled} | |
onClick={(event) => { | |
combobox.toggleDropdown(); | |
onClick?.(event); | |
}} | |
classNames={resolvedClassNames} | |
styles={resolvedStyles} | |
unstyled={unstyled} | |
error={error} | |
> | |
{selectedOption | |
? optionRenderer(selectedOption) | |
: placeholder && ( | |
<Input.Placeholder>{placeholder}</Input.Placeholder> | |
)} | |
</InputBase> | |
</Combobox.Target> | |
<Combobox.Dropdown | |
hidden={ | |
readOnly || | |
disabled || | |
((!searchable || !nothingFoundMessage) && isEmpty) | |
} | |
> | |
{searchable && ( | |
<Combobox.Search | |
value={search} | |
onChange={(event) => { | |
setSearch(event.currentTarget.value); | |
selectFirstOptionOnChange && combobox.selectFirstOption(); | |
}} | |
placeholder='Search groceries' | |
/> | |
)} | |
<Combobox.Options labelledBy={`${_id}-label`}> | |
<Options | |
data={filteredData} | |
optionRenderer={optionRenderer} | |
withScrollArea={withScrollArea} | |
maxDropdownHeight={maxDropdownHeight} | |
withCheckIcon={withCheckIcon} | |
value={_value} | |
checkIconPosition={checkIconPosition} | |
unstyled={unstyled} | |
disableSelected={disableSelected} | |
/> | |
{isEmpty && nothingFoundMessage && ( | |
<Combobox.Empty>{nothingFoundMessage}</Combobox.Empty> | |
)} | |
</Combobox.Options> | |
</Combobox.Dropdown> | |
</Combobox> | |
<input | |
type='hidden' | |
name={name} | |
value={_value || ''} | |
form={form} | |
disabled={disabled} | |
{...hiddenInputProps} | |
/> | |
</> | |
); | |
}; | |
_Select.classes = { ...InputBase.classes, ...Combobox.classes }; | |
_Select.displayName = 'Select'; | |
const Select = memo(forwardRef(_Select)) as <T extends object>( | |
_props: SelectProps<T> & { ref?: React.ForwardedRef<HTMLButtonElement> } | |
) => ReturnType<typeof _Select>; | |
export default 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 type SelectOptionSrc<T extends object> = { | |
[K in keyof T]?: T[K]; | |
}; | |
export interface SelectOptionGroupSrc<T extends object> { | |
group: string; | |
items: Array<SelectOptionSrc<T>>; | |
} | |
export interface ParsedOption<T extends object> { | |
label: string; | |
value: string; | |
disabled?: boolean; | |
src: SelectOptionSrc<T>; | |
} | |
export interface ParsedOptionGroup<T extends object> { | |
group: string; | |
items: Array<ParsedOption<T>>; | |
} | |
export interface FilterOptionsInput<T extends object> { | |
options: Array<ParsedOption<T> | ParsedOptionGroup<T>>; | |
search: string; | |
limit: number; | |
} | |
export type OptionsFilter<T extends object> = ( | |
input: FilterOptionsInput<T> | |
) => Array<ParsedOption<T> | ParsedOptionGroup<T>>; |
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('[Select] Each option must have value property'); | |
} | |
if (typeof option.value !== 'string') { | |
throw new Error( | |
`[Select] Option value must be a string, other data formats are not supported, got ${typeof option.value}` | |
); | |
} | |
if (valuesSet.has(option.value)) { | |
throw new Error( | |
`[Select] 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 parsedSearch = search.trim().toLowerCase(); | |
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(parsedSearch)) { | |
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; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment