Skip to content

Instantly share code, notes, and snippets.

@neostom432
Created July 30, 2022 07:07
Show Gist options
  • Save neostom432/8c349257b1f7497f71273b9ce19817f9 to your computer and use it in GitHub Desktop.
Save neostom432/8c349257b1f7497f71273b9ce19817f9 to your computer and use it in GitHub Desktop.
Creatable Select component
import { ComponentType, FocusEvent, useMemo, useRef } from 'react';
import Creatable from 'react-select/creatable';
import {
ActionMeta,
components,
MenuPlacement,
mergeStyles,
MultiValue,
Options,
OptionsOrGroups,
PropsValue,
SingleValue,
StylesConfig,
} from 'react-select';
import SVG from 'util/SVG';
import { useIntl } from 'react-intl';
import {
LoadOptions,
useAsyncPaginate,
useComponents,
reduceGroupedOptions,
} from 'react-select-async-paginate';
import { FilterOptionOption } from 'react-select/dist/declarations/src/filters';
export type SelectCustomComponetProps<T> = {
option: NewOption<T>;
isDisabled?: boolean;
};
export type SelectAdditional =
| {
page: number;
optionIds?: string[];
}
| undefined;
const variantEnum = {
border: 'border',
default: 'default',
} as const;
export type SelectVariant = typeof variantEnum[keyof typeof variantEnum];
export type SelectProps<T> = {
className?: string;
creatable?: boolean;
defaultValue?: NewOption<T>;
debounceTimeout?: number;
disabled?: boolean;
isClearable?: boolean;
isMulti?: boolean;
isSearchable?: boolean;
menuIsOpen?: boolean;
menuPlacement?: MenuPlacement;
maxMenuHeight?: number;
name?: string;
options?: OptionsOrGroups<NewOption<T>, NewGroupOption<T>>;
placeholder?: string;
styles?: StylesConfig<NewOption<T>, boolean, NewGroupOption<T>>;
value?: PropsValue<NewOption<T>>;
variant?: SelectVariant;
usePortalForMenu?: boolean;
filterOption?:
| ((
option: FilterOptionOption<NewOption<T>>,
inputValue: string
) => boolean)
| null;
formatCreateLabel?: ((inputValue: string) => React.ReactNode) | undefined;
isOptionDisabled?: (
option: NewOption<T>,
selectValue: Options<NewOption<T>>
) => boolean;
loadOptions?: LoadOptions<NewOption<T>, NewGroupOption<T>, SelectAdditional>;
noOptionsMessage?:
| ((obj: { inputValue: string }) => React.ReactNode)
| undefined;
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
onChange?: (
newValue: MultiValue<NewOption<T>> | SingleValue<NewOption<T>>,
actionMeta: ActionMeta<NewOption<T>>
) => void;
onCancel?: (
value: MultiValue<NewOption<T>> | SingleValue<NewOption<T>>
) => void;
onSubmit?: (
value: MultiValue<NewOption<T>> | SingleValue<NewOption<T>>
) => void;
MultiValueLabelComponent?: ComponentType<SelectCustomComponetProps<T>>;
OptionComponent?: ComponentType<SelectCustomComponetProps<T>>;
SingleValueComponent?: ComponentType<SelectCustomComponetProps<T>>;
};
const DropdownIndicatorComponent = (props: any) => {
return (
<components.DropdownIndicator {...props}>
<SVG name="select-dropdown-indicator" width={18} height={18} />
</components.DropdownIndicator>
);
};
const ClearIndicatorComponent = (props: any) => {
return (
<components.ClearIndicator {...props}>
<SVG name="select-clear" width={18} height={18} />
</components.ClearIndicator>
);
};
const MultiValueRemoveComponent = (props: any) => {
return (
<components.MultiValueRemove {...props}>
<SVG name="label-delete" width={16} height={16} />
</components.MultiValueRemove>
);
};
const NoOptionMessageComponent = (props: any) => {
const { formatMessage } = useIntl();
const NO_OPTION_MESSAGE = formatMessage({ id: 'undefined' });
return (
<components.NoOptionsMessage {...props}>
{NO_OPTION_MESSAGE}
</components.NoOptionsMessage>
);
};
export default function NewSelect<T>({
className,
creatable,
debounceTimeout = 300,
defaultValue,
disabled,
isClearable,
isMulti,
isSearchable = false,
menuIsOpen,
menuPlacement,
name,
options,
placeholder,
styles,
value,
maxMenuHeight,
variant = 'default',
filterOption,
formatCreateLabel,
isOptionDisabled,
loadOptions,
noOptionsMessage,
onBlur,
onChange,
onCancel,
onSubmit,
usePortalForMenu,
MultiValueLabelComponent,
OptionComponent,
SingleValueComponent,
}: SelectProps<T>) {
const ref = useRef(null);
const isDirty = useRef<boolean>(false);
const isCanceled = useRef<boolean>(false);
const cachePreviousValue = useRef<PropsValue<NewOption<T>>>();
const submitCandidateValue = useRef<PropsValue<NewOption<T>>>();
const isAsync = !!loadOptions;
const asyncPaginateProps = useAsyncPaginate<
NewOption<T>,
NewGroupOption<T>,
SelectAdditional
>({
loadOptionsOnMenuOpen: true,
debounceTimeout,
loadOptions: async (inputValue, newOptions, additional) => {
return loadOptions
? loadOptions(inputValue, newOptions, additional)
: { options: options || [], hasMore: false };
},
reduceOptions: (
prevOptions: OptionsOrGroups<NewOption<T>, NewGroupOption<T>>,
loadedOptions: OptionsOrGroups<NewOption<T>, NewGroupOption<T>>,
additional: SelectAdditional
) => {
if (
loadedOptions.length > 0 &&
loadedOptions[0].hasOwnProperty('options')
) {
return reduceGroupedOptions(prevOptions, loadedOptions, additional);
} else {
return [...prevOptions, ...loadedOptions];
}
},
});
function creatableValidate<K>(
currentOptions: OptionsOrGroups<NewOption<K>, NewGroupOption<K>>
): boolean {
if (currentOptions.length > 0) {
const isGroup = !currentOptions[0].hasOwnProperty('data');
let optionLength = 0;
if (isGroup) {
const gropuOptions = currentOptions as NewGroupOption<K>[];
gropuOptions.forEach((opt) => (optionLength += opt.options.length));
} else {
const singleOptions = currentOptions as NewOption<K>[];
optionLength = singleOptions.length;
}
return optionLength === 0;
} else {
return true;
}
}
const defaultComponents = useMemo(
() => ({
IndicatorSeparator: undefined,
DropdownIndicator: DropdownIndicatorComponent,
ClearIndicator: ClearIndicatorComponent,
Placeholder: (props: any) => {
return (
<components.Placeholder {...props}>
{isSearchable && <SVG name={'app-header-search'} width={16} />}
{props.children}
</components.Placeholder>
);
},
SingleValue: SingleValueComponent
? (props: any) => {
return (
<components.SingleValue {...props}>
<SingleValueComponent option={props.data} />
</components.SingleValue>
);
}
: components.SingleValue,
MultiValueLabel: MultiValueLabelComponent
? (props: any) => {
return (
<components.MultiValueLabel {...props}>
<MultiValueLabelComponent option={props.data} />
</components.MultiValueLabel>
);
}
: components.MultiValueLabel,
MultiValueRemove: MultiValueRemoveComponent,
Option: OptionComponent
? (props: any) => {
return (
<>
{props.data.data ? (
<components.Option {...props}>
<OptionComponent
option={props.data}
isDisabled={props.isDisabled}
/>
</components.Option>
) : (
<components.Option {...props} />
)}
</>
);
}
: components.Option,
}),
[isSearchable]
);
const defaultStyles: StylesConfig<
NewOption<T>,
boolean,
NewGroupOption<T>
> = {
container: (provided, state) => {
return {
...provided,
padding: 0,
margin: 0,
maxWidth: '100%',
minWidth: '180px',
};
},
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (provided, state) => {
return {
...provided,
transition: 'none',
padding: isMulti ? '4px 10px' : '5px 10px',
boxSizing: 'border-box',
height: isMulti ? 'auto' : '30px',
minHeight: '30px',
borderColor: variant === 'border' ? '#E9EBEE' : 'transparent',
boxShadow: 'none',
borderRadius: '3px',
...(!state.isFocused && {
':hover': {
cursor: 'pointer',
borderColor: '#374553',
},
}),
...(state.isFocused && {
borderColor: '#3d8af2',
}),
...(state.isDisabled && {
borderColor: '#E9EBEE',
backgroundColor: '#F7F8F9',
}),
};
},
valueContainer: (provided, state) => {
return {
...provided,
padding: 0,
margin: 0,
gap: '4px',
input: {
lineHeight: '20px',
},
};
},
multiValue: (provided, state) => {
return {
...provided,
padding: '0px 4px',
margin: 0,
borderRadius: '3px',
backgroundColor: '#F7F8F9',
maxWidth: '100%',
path: {
fill: '#C6C8CE',
},
':hover': {
path: {
fill: '#646F7C',
},
cursor: 'pointer',
},
...(state.isDisabled && {
backgroundColor: '#E9EBEE',
}),
};
},
multiValueLabel: (provided, state) => {
return {
...provided,
padding: 0,
paddingLeft: 0,
margin: 0,
fontSize: '14px',
lineHeight: '20px',
color: '#374553',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...(state.isDisabled && {
color: '#C6C8CE',
}),
};
},
multiValueRemove: (provided, state) => {
return {
...provided,
padding: 0,
marginLeft: '6px',
':hover': {
background: 'none',
backgroundColor: 'none',
},
display: state.isDisabled || state.data.isDisabled ? 'none' : 'flex',
};
},
menu: (provided, state) => {
return {
...provided,
marginTop: 0,
borderColor: '#E9EBEE',
borderRadius: '2px',
boxShadow: '1px 4px 10px 0px #161D2433',
zIndex: 200,
};
},
menuList: (provided, state) => {
return {
...provided,
paddingTop: '8px',
paddingBottom: '8px',
};
},
option: (provided, state) => {
return {
...provided,
minHeight: '30px',
boxSizing: 'border-box',
padding: '5px 10px',
fontSize: '14px',
lineHeight: '20px',
color: '#374553',
cursor: 'pointer',
':active': {
backgroundColor: '#F7F9FA',
},
...(state.isFocused && {
backgroundColor: '#F7F9FA',
}),
...(state.isSelected && {
backgroundColor: '#E7F2FF',
}),
};
},
placeholder: (provided, state) => {
return {
...provided,
padding: 0,
margin: 0,
display: 'flex',
gap: '8px',
alignItems: 'center',
fontSize: '14px',
lineHeight: '20px',
color: '#C6C8CE',
};
},
input: (provided, state) => {
return {
...provided,
padding: 0,
margin: 0,
fontSize: '14px',
lineHeight: '20px',
color: '#374553',
};
},
singleValue: (provided, state) => {
return {
...provided,
padding: 0,
margin: 0,
fontSize: '14px',
lineHeight: '20px',
color: '#374553',
...(state.isDisabled && {
color: '#C6C8CE',
}),
};
},
noOptionsMessage: (provided, state) => {
return {
...provided,
margin: 0,
padding: '5px 10px',
fontSize: '14px',
lineHeight: '20px',
color: '#646F7C',
};
},
loadingMessage: (provided, state) => {
return {
...provided,
margin: 0,
padding: '5px 10px',
fontSize: '14px',
lineHeight: '20px',
color: '#646F7C',
};
},
indicatorsContainer: (provided, state) => {
return {
...provided,
padding: 0,
marginLeft: '4px',
...(state.isDisabled && {
path: {
fill: '#c6c8ce',
},
}),
};
},
clearIndicator: (provided, state) => {
return {
...provided,
padding: 0,
marginRight: '4px',
display: 'none',
path: {
fill: '#C6C8CE',
},
':hover': {
path: {
fill: '#646F7C',
},
},
cursor: 'pointer',
...(state.isFocused && {
display: 'flex',
}),
};
},
loadingIndicator: (provided, state) => {
return {
...provided,
padding: 0,
marginRight: '4px',
};
},
dropdownIndicator: (provided, state) => {
return {
...provided,
padding: 0,
margin: 0,
display:
variant === 'default' && state.isFocused === false ? 'none' : 'flex',
path: {
fill: '#646F7C',
},
};
},
};
const asyncComponents: any = useComponents(defaultComponents);
return (
<Creatable
{...(isAsync
? { ...asyncPaginateProps, components: asyncComponents }
: { options, components: defaultComponents })}
menuIsOpen={menuIsOpen}
isValidNewOption={
creatable && isAsync
? (
inputValue: string,
currentValue: Options<NewOption<T>>,
currentOptions: OptionsOrGroups<NewOption<T>, NewGroupOption<T>>
) => creatableValidate<T>(currentOptions)
: () => false
}
formatCreateLabel={formatCreateLabel}
id={name}
name={name}
className={className}
maxMenuHeight={maxMenuHeight}
defaultValue={defaultValue}
isDisabled={disabled}
isMulti={isMulti}
menuPortalTarget={usePortalForMenu ? document.body : undefined}
isSearchable={isSearchable}
isClearable={isClearable}
menuShouldBlockScroll={usePortalForMenu}
isOptionDisabled={isOptionDisabled}
onChange={(newValue, actionMeta) => {
submitCandidateValue.current = newValue;
isDirty.current = true;
onChange?.(newValue, actionMeta);
}}
placeholder={placeholder}
value={value}
noOptionsMessage={noOptionsMessage}
onFocus={() => {
cachePreviousValue.current = value;
submitCandidateValue.current = isDirty.current
? submitCandidateValue.current
: value;
isDirty.current = true;
isCanceled.current = false;
}}
onBlur={(e) => {
isDirty.current = false;
onBlur?.(e);
if (isCanceled.current) {
onCancel?.(cachePreviousValue.current || null);
} else {
onSubmit?.(submitCandidateValue.current || null);
}
}}
menuPlacement={menuPlacement}
openMenuOnFocus
closeMenuOnSelect={!isMulti}
ref={ref}
blurInputOnSelect={!isMulti}
filterOption={filterOption}
tabSelectsValue={false}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation();
isCanceled.current = true;
const select = ref.current as any;
select.blur();
}
if (e.key === 'Tab' && !isMulti) {
isCanceled.current = true;
}
}}
styles={styles ? mergeStyles(defaultStyles, styles) : defaultStyles}
/>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment