Skip to content

Instantly share code, notes, and snippets.

@Toshinaki
Last active January 13, 2024 03:00
Show Gist options
  • Save Toshinaki/b052fdc672d1803c8c9951b104efd04e to your computer and use it in GitHub Desktop.
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/).
export { default as Select } from './Select';
export type { SelectProps } from './Select';
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';
/* ------- 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 _ 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;
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>>;
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