import {
createContext,
useContext,
useState,
useEffect,
useRef,
forwardRef,
ForwardRefExoticComponent,
RefAttributes,
useImperativeHandle,
ReactNode,
Ref,
useMemo,
useCallback,
} from 'react';
import { ScrollbarWrapper, Tooltip } from '../../index';
import {
components,
GroupTypeBase,
OptionTypeBase,
ValueContainerProps,
} from 'react-select';
import { Icon } from '../icon/Icon.component';
import { SelectStyle } from './SelectStyle';
import { FixedSizeList, FixedSizeList as List } from 'react-window';
import { convertRemToPixels } from '../../utils';
import { spacing } from '../../spacing';
import { convertSizeToRem } from '../inputv2/inputv2';
import { ConstrainedText } from '../constrainedtext/Constrainedtext.component';
import ReactSelect from 'react-select/src/Select';
const ITEMS_PER_SCROLL_WINDOW = 4;
// more/equal than NOPT_SEARCH options enable search
const NOPT_SEARCH = 8;
export type OptionProps = {
title?: string;
disabled?: boolean;
icon?: ReactNode;
children?: ReactNode;
value: string;
disabledReason?: ReactNode;
};
const usePreviousValue = (value) => {
const ref = useRef(null);
useEffect(() => {
ref.current = value;
});
return ref.current;
};
function useOptions() {
const optionContext = useContext(OptionContext);
if (!optionContext)
throw new Error(
'useOptions cannot be rendered outside the Select component',
);
return Object.values(optionContext.options);
}
export function Option({
value,
children,
disabled,
icon,
disabledReason,
...rest
}: OptionProps): JSX.Element {
const optionContext = useContext(OptionContext);
if (!optionContext)
throw new Error('Option cannot be rendered outside the Select component');
const prevValue = usePreviousValue(value);
useEffect(() => {
if (prevValue && prevValue !== value) {
optionContext.unregister(prevValue);
}
optionContext.register({
value: value,
label: children || '',
isDisabled: disabled || false,
icon: icon,
disabledReason: disabledReason,
optionProps: { ...rest },
});
return () => {
optionContext.unregister(value);
};
//eslint-disable-next-line react-hooks/exhaustive-deps -- optionContext is mutable
}, [children, disabled, icon, value, prevValue]);
return <>>;
}
const Input = (props) => {
return ;
};
const selectDropdownIndicator = (
caretType: 'chevron' | 'caret',
indicatorDirection: 'up' | 'down',
) => {
if (caretType === 'chevron') {
if (indicatorDirection === 'up') return 'Chevron-up';
else return 'Chevron-down';
} else {
if (indicatorDirection === 'up') return 'Dropdown-up';
else return 'Dropdown-down';
}
};
const DropdownIndicator = (props) => {
const indicatorDirection = props.selectProps.menuIsOpen ? 'up' : 'down';
const caretType = props.selectProps.isDefault ? 'chevron' : 'caret';
return (
);
};
const InternalOption = (width, isDefaultVariant) => (props) => {
const formatOptionLabel = () => {
const label: string = props.data.label;
const inputValue = props.selectProps.inputValue;
const parts = label
.split(inputValue)
.flatMap((item, index) => [inputValue, item])
.slice(1);
const reducedWidth = `${parseFloat(width.replace('rem')) - 2}rem`;
if (inputValue) {
return (
{
const highlightStyle =
part.toLowerCase() === inputValue.toLowerCase()
? 'sc-highlighted-matching-text'
: '';
return (
{part}
);
})}
/>
);
} else {
return (
);
}
};
const innerProps = {
...props.innerProps,
...props.data.optionProps,
// remove onMouseMove & onMouseOver so that options are not focused on hover
onMouseMove: undefined,
onMouseOver: undefined,
role: 'option',
'aria-disabled': props.isDisabled,
'aria-selected': props.isSelected,
};
return (
{props.data.icon}
{formatOptionLabel()}
{props.isDisabled && }
);
};
const Menu = (props) => {
useEffect(() => {
props.selectProps.setIsMenuBottom(props.placement === 'bottom');
}, [props]);
return ;
};
const getScrollOffset = (
list,
index: number,
itemCount: number,
offset: number,
): number => {
const { itemSize, height } = list.props;
const scrollOffset = list.state ? list.state.scrollOffset : 0;
const lastItemOffset = Math.max(0, itemCount * itemSize - height);
const maxOffset = Math.min(lastItemOffset, index * itemSize);
const minOffset = Math.max(0, index * itemSize - height + itemSize);
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
return scrollOffset;
} else if (scrollOffset < minOffset) {
return minOffset === 0 ? minOffset : minOffset + offset;
} else {
return maxOffset === 0 ? maxOffset : maxOffset - offset;
}
};
const MenuList = (props) => {
const listRef = useRef | null>(null);
const { children, getValue } = props;
const [selectedOption] = getValue();
const optionHeight =
convertRemToPixels(
parseFloat(props.selectProps.isDefault ? spacing.r40 : spacing.r24),
) || 32;
let selectedIndex = 0;
let focusedIndex = 0;
if (children && children.length > 0) {
selectedIndex = children.findIndex(
(child) => child.props.data === selectedOption,
);
focusedIndex = props.focusedOption
? children.findIndex((child) => child.props.data === props.focusedOption)
: selectedIndex;
}
const initialOffset =
selectedIndex * optionHeight - (ITEMS_PER_SCROLL_WINDOW - 1) * optionHeight;
useEffect(() => {
if (listRef && listRef.current) {
listRef.current.scrollTo(
getScrollOffset(
listRef.current,
focusedIndex,
children.length,
optionHeight / 2,
),
);
}
}, [children.length, focusedIndex, optionHeight, listRef]);
if (children.length > ITEMS_PER_SCROLL_WINDOW) {
return (
// @ts-ignore
{({ index, style }) => {
return (
{children[index]}
);
}}
);
}
return {children};
};
const ValueContainer = <
OptionType extends OptionTypeBase,
IsMulti extends boolean,
GroupType extends GroupTypeBase,
>({
children,
...props
}: ValueContainerProps) => {
const selectedOption = props.selectProps.selectedOption;
const icon = selectedOption ? selectedOption.icon : null;
const ariaProps = {
innerProps: {
disabled: true,
role: props.selectProps.isSearchable ? 'combobox' : 'listbox',
'aria-expanded': props.selectProps.menuIsOpen,
'aria-autocomplete': 'list',
'aria-label': props.selectProps.placeholder,
},
};
return (
{icon ? {icon}
: null}
{children}
);
};
export interface SelectRef<
OptionType extends OptionTypeBase,
IsMulti extends boolean,
GroupType extends GroupTypeBase,
> {
select: ReactSelect | null;
focus: () => void;
blur: () => void;
openMenu: () => void;
closeMenu: () => void;
setValue: (value: string) => void;
clearValue: () => void;
}
export type SelectProps = {
id: string;
placeholder?: string;
disabled?: boolean;
children?: ReactNode;
value?: string;
onFocus?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onChange: (newValue: string) => void;
variant?: 'default' | 'rounded';
size?: '1' | '2/3' | '1/2' | '1/3';
className?: string;
/** use menuPositon='fixed' inside modal to avoid display issue */
menuPosition?: 'fixed' | 'absolute';
};
type SelectOptionProps = {
value: string;
label: ReactNode;
isDisabled: boolean;
icon?: ReactNode;
optionProps: any;
disabledReason?: ReactNode;
};
type SelectComponentType<
OptionType extends OptionTypeBase,
IsMulti extends boolean,
GroupType extends GroupTypeBase,
> = ForwardRefExoticComponent<
SelectProps & RefAttributes>
> & {
Option: typeof Option;
};
const OptionContext = createContext<{
options: Record;
register: (option: SelectOptionProps) => void;
unregister: (value: string) => void;
} | null>(null);
function SelectBox<
OptionType extends OptionTypeBase,
IsMulti extends boolean,
GroupType extends GroupTypeBase,
>({
placeholder = 'Select...',
disabled = false,
value,
onChange,
variant = 'default',
className,
size = '1',
id,
selectRef,
...rest
}: SelectProps & {
selectRef?: Ref>;
}) {
const [keyboardFocusEnabled, setKeyboardFocusEnabled] = useState(false);
const [searchSelection, setSearchSelection] = useState('');
const [searchValue, setSearchValue] = useState('');
const [customPlaceholder, setPlaceholder] = useState(placeholder);
const isDefaultVariant = variant === 'default';
const [isMenuBottom, setIsMenuBottom] = useState(true);
const internalSelectRef = useRef<
ReactSelect & {
setState: (state: { menuIsOpen: boolean }) => void;
state: { isOpen: boolean };
select: {
setValue: (option: SelectOptionProps) => void;
clearValue: () => void;
};
}
>(null);
useImperativeHandle(
selectRef,
() => ({
focus: () => {
if (internalSelectRef.current) {
internalSelectRef.current.focus();
}
},
blur: () => {
if (internalSelectRef.current) {
internalSelectRef.current.blur();
}
},
select: internalSelectRef.current,
openMenu: () => {
if (internalSelectRef.current) {
internalSelectRef.current.setState({ menuIsOpen: true });
}
},
closeMenu: () => {
if (internalSelectRef.current) {
internalSelectRef.current.setState({ menuIsOpen: false });
}
},
setValue: (newValue: string) => {
if (internalSelectRef.current) {
const option = options.find((opt) => opt.value === newValue);
if (option) {
internalSelectRef.current.select.setValue(option);
}
}
},
clearValue: () => {
if (internalSelectRef.current && internalSelectRef.current.select) {
internalSelectRef.current.select.clearValue();
}
},
}),
[internalSelectRef],
);
const options = useOptions();
const handleChange = (option: SelectOptionProps) => {
const newValue = option ? option.value : '';
if (onChange && typeof onChange === 'function' && newValue !== value) {
onChange(newValue);
}
if (options && options.length > NOPT_SEARCH && internalSelectRef.current) {
internalSelectRef.current.blur();
}
};
const handleSearchInput = (inputValue, { action }) => {
if (options && options.length > NOPT_SEARCH) {
if (action === 'menu-close') {
setSearchSelection('');
}
if (action === 'input-blur' || action === 'set-value') {
if (searchValue) setPlaceholder(searchValue);
else setPlaceholder(placeholder);
setSearchValue(inputValue);
} else {
setSearchValue(inputValue);
if (inputValue.length === 0) setPlaceholder(placeholder);
}
}
};
const isEmptyStringInOptions = options.find((option) => option.value === '');
// Force to reset the value
useEffect(() => {
if (
!isEmptyStringInOptions &&
value === '' &&
internalSelectRef.current &&
internalSelectRef.current.select
) {
internalSelectRef.current.select.clearValue();
}
}, [value, isEmptyStringInOptions]);
return (
<>
{options && (
opt.value === value)
}
inputValue={options.length > NOPT_SEARCH ? searchValue : undefined}
selectedOption={options.find((opt) => opt.value === value)}
keyboardFocusEnabled={keyboardFocusEnabled}
options={options}
isDisabled={disabled}
placeholder={customPlaceholder}
menuPlacement="auto"
isSearchable={options.length > NOPT_SEARCH}
components={{
Input: Input,
Option: InternalOption(convertSizeToRem(size), isDefaultVariant),
Menu: Menu,
MenuList: MenuList,
ValueContainer: ValueContainer,
DropdownIndicator: DropdownIndicator,
IndicatorSeparator: null,
}}
isDefault={isDefaultVariant}
ITEMS_PER_SCROLL_WINDOW={ITEMS_PER_SCROLL_WINDOW}
onChange={handleChange}
onInputChange={handleSearchInput}
ref={internalSelectRef}
isMenuBottom={isMenuBottom}
setIsMenuBottom={setIsMenuBottom}
onBlur={rest.onBlur}
onFocus={rest.onFocus}
onMenuClose={() => setKeyboardFocusEnabled(false)}
onKeyDown={(event: KeyboardEvent) => {
if (
event &&
event.key === 'Enter' &&
internalSelectRef.current &&
!internalSelectRef.current.state.isOpen
) {
internalSelectRef.current.setState({
menuIsOpen: true,
});
} else {
setKeyboardFocusEnabled(true);
}
}}
width={convertSizeToRem(size)}
{...rest}
/>
)}
>
);
}
const SelectWithOptionContext = forwardRef<
SelectRef>,
SelectProps
>((props, ref) => {
const [options, setOptions] = useState>({});
const register = useCallback((option: SelectOptionProps) => {
setOptions((prevOptions) => ({
...prevOptions,
[option.value]: option,
}));
}, []);
const unregister = useCallback((value: string) => {
setOptions((prevOptions) => {
const { [value]: _, ...rest } = prevOptions;
return rest;
});
}, []);
const contextValue = useMemo(() => ({
options,
register,
unregister
}), [options, register, unregister]);
return (
<>
{props.children}
>
);
}) as SelectComponentType<
OptionTypeBase,
boolean,
GroupTypeBase
>;
SelectWithOptionContext.displayName = 'Select';
SelectWithOptionContext.Option = Option;
export const Select = SelectWithOptionContext;