|
1 | | -// @ts-ignore |
2 | | -import React, { useState, useRef, useEffect } from 'react'; |
| 1 | +import React, { useState, forwardRef, useEffect } from 'react'; |
3 | 2 | import ReactSelect, { |
4 | | - components as ReactSelectComponents, |
5 | | - Props as ReactSelectProps, |
| 3 | + OptionProps, |
| 4 | + MultiValueProps, |
| 5 | + IndicatorProps, |
| 6 | + StylesConfig, |
| 7 | + Props as SelectProps, |
6 | 8 | ActionMeta, |
7 | | - SingleValue, |
8 | | - MultiValue, |
9 | | - ValueContainerProps, |
10 | 9 | } from 'react-select'; |
11 | 10 | import AsyncSelect from 'react-select/async'; |
12 | | -import AsyncCreatableSelect from 'react-select/async-creatable'; |
13 | 11 | import CreatableSelect from 'react-select/creatable'; |
| 12 | +import AsyncCreatableSelect from 'react-select/async-creatable'; |
| 13 | +import Icon from '../Icon/Icon'; |
| 14 | +import Badge from '../Badge/Badge'; |
14 | 15 |
|
15 | | -export interface Option { |
16 | | - label: string; |
17 | | - value: any; |
18 | | -} |
| 16 | +// Type definitions |
| 17 | +type OptionType = { label: string; value: any; disabled?: boolean }; |
19 | 18 |
|
20 | | -interface SelectProps extends Omit<ReactSelectProps<Option, boolean>, 'onChange'> { |
21 | | - options?: Option[]; |
22 | | - defaultValue?: Option | Option[] | null; |
23 | | - value?: Option | Option[] | null; |
24 | | - onChange?: (value: Option | Option[] | null, action: ActionMeta<Option>) => void; |
25 | | - loadOptions?: (inputValue: string, callback: (options: Option[]) => void) => void; |
| 19 | +interface CustomSelectProps extends Omit<SelectProps<OptionType, boolean>, 'onChange' | 'isMulti' | 'isDisabled'> { |
| 20 | + onChange?: (value: any, action?: ActionMeta<OptionType>) => void; |
| 21 | + arrowRenderer?: (props: { isOpen: boolean }) => React.ReactNode; |
| 22 | + valueComponent?: React.ComponentType<MultiValueProps<OptionType>>; |
| 23 | + optionComponent?: React.ComponentType<OptionProps<OptionType>>; |
| 24 | + loadOptions?: (input: string, callback: (options: OptionType[]) => void) => void; |
26 | 25 | creatable?: boolean; |
27 | | - multi?: boolean; |
28 | | - name?: string; |
29 | 26 | inputProps?: React.InputHTMLAttributes<HTMLInputElement>; |
| 27 | + multi?: boolean; |
| 28 | + disabled?: boolean; |
| 29 | + isValidNewOption?: (inputValue: any) => boolean; |
30 | 30 | } |
31 | 31 |
|
32 | | -const Select: React.FC<SelectProps> = ({ |
33 | | - options, |
34 | | - defaultValue, |
35 | | - value, |
36 | | - onChange, |
37 | | - loadOptions, |
38 | | - creatable, |
39 | | - multi, |
40 | | - name, |
41 | | - inputProps, |
42 | | - className, |
43 | | - ...props |
44 | | - }) => { |
45 | | - const [internalValue, setInternalValue] = useState<Option | Option[] | null>( |
46 | | - value || defaultValue || null |
| 32 | +// Utility functions |
| 33 | +const getSelectArrow = (isOpen: boolean, arrowRenderer?: (props: { isOpen: boolean }) => React.ReactNode) => |
| 34 | + arrowRenderer ? arrowRenderer({ isOpen }) : <Icon name={`caret-${isOpen ? 'up' : 'down'}`} />; |
| 35 | + |
| 36 | +const getCloseButton = () => ( |
| 37 | + <Icon name="xmark" className="ms-1" style={{ opacity: 0.5, fontSize: '.5rem' }} /> |
| 38 | +); |
| 39 | + |
| 40 | +// Custom components |
| 41 | +const CustomMultiValue: React.FC<MultiValueProps<OptionType>> = (props) => { |
| 42 | + const { children, removeProps, ...badgeProps } = props; |
| 43 | + |
| 44 | + return ( |
| 45 | + <Badge |
| 46 | + color="light" |
| 47 | + className="ms-1 fw-normal border d-inline-flex align-items-center text-start" |
| 48 | + style={{ textTransform: 'none', whiteSpace: 'normal' }} |
| 49 | + {...badgeProps} |
| 50 | + > |
| 51 | + {children} |
| 52 | + <span {...removeProps}> |
| 53 | + {getCloseButton()} |
| 54 | + </span> |
| 55 | + </Badge> |
| 56 | + ); |
| 57 | +}; |
| 58 | + |
| 59 | +const CustomOption: React.FC<OptionProps<OptionType>> = (props) => { |
| 60 | + const { children, isDisabled, isFocused, isSelected, innerProps, data } = props; |
| 61 | + |
| 62 | + return ( |
| 63 | + <div |
| 64 | + className={` |
| 65 | + dropdown-item |
| 66 | + ${isSelected && !isFocused ? 'bg-light' : ''} |
| 67 | + ${isFocused ? 'bg-primary text-white' : ''} |
| 68 | + ${isDisabled || data.disabled ? 'disabled' : ''} |
| 69 | + `.trim()} |
| 70 | + {...innerProps} |
| 71 | + aria-disabled={isDisabled || data.disabled} |
| 72 | + > |
| 73 | + {children} |
| 74 | + </div> |
47 | 75 | ); |
48 | | - const selectRef = useRef<any>(null); |
49 | | - const isControlled = value !== undefined; |
| 76 | +}; |
| 77 | + |
| 78 | +const CustomArrow: React.FC<IndicatorProps<OptionType>> = ({ selectProps }) => { |
| 79 | + const { menuIsOpen, arrowRenderer } = selectProps as CustomSelectProps; |
| 80 | + return <>{getSelectArrow(!!menuIsOpen, arrowRenderer)}</>; |
| 81 | +}; |
| 82 | + |
| 83 | +// Main Select component |
| 84 | +const Select = forwardRef<any, CustomSelectProps>((props, ref) => { |
| 85 | + const { |
| 86 | + arrowRenderer, |
| 87 | + className, |
| 88 | + defaultValue, |
| 89 | + inputProps, |
| 90 | + valueComponent, |
| 91 | + optionComponent, |
| 92 | + loadOptions, |
| 93 | + creatable, |
| 94 | + onChange, |
| 95 | + multi, |
| 96 | + isValidNewOption, |
| 97 | + value: propsValue, |
| 98 | + options: propsOptions, |
| 99 | + disabled, |
| 100 | + ...restProps |
| 101 | + } = props; |
| 102 | + |
| 103 | + const [value, setValue] = useState(propsValue || defaultValue); |
| 104 | + const [options, setOptions] = useState(propsOptions || []); |
| 105 | + |
| 106 | + useEffect(() => { |
| 107 | + if (propsValue !== undefined) { |
| 108 | + setValue(propsValue); |
| 109 | + } |
| 110 | + }, [propsValue]); |
50 | 111 |
|
51 | 112 | useEffect(() => { |
52 | | - if (isControlled) { |
53 | | - setInternalValue(value); |
| 113 | + if (propsOptions) { |
| 114 | + setOptions(propsOptions); |
54 | 115 | } |
55 | | - }, [value, isControlled]); |
56 | | - |
57 | | - const handleChange = ( |
58 | | - newValue: SingleValue<Option> | MultiValue<Option>, |
59 | | - action: ActionMeta<Option> |
60 | | - ) => { |
61 | | - if (!isControlled) { |
62 | | - setInternalValue(newValue); |
| 116 | + }, [propsOptions]); |
| 117 | + |
| 118 | + const handleChange = (newValue: any, action: ActionMeta<OptionType>) => { |
| 119 | + setValue(newValue); |
| 120 | + if (onChange) { |
| 121 | + // For multi-select, always pass an array |
| 122 | + if (multi) { |
| 123 | + onChange(newValue ? newValue : [], action); |
| 124 | + } else { |
| 125 | + onChange(newValue, action); |
| 126 | + } |
63 | 127 | } |
64 | | - onChange?.(newValue, action); |
65 | 128 | }; |
66 | 129 |
|
67 | | - let SelectComponent: any = ReactSelect; |
68 | | - if (loadOptions) { |
69 | | - SelectComponent = creatable ? AsyncCreatableSelect : AsyncSelect; |
| 130 | + // Handle async options loading |
| 131 | + const loadOptionsWrapper = loadOptions |
| 132 | + ? (inputValue: string) => |
| 133 | + new Promise<OptionType[]>((resolve) => { |
| 134 | + loadOptions(inputValue, (result: any) => { |
| 135 | + resolve(result.options || []); |
| 136 | + }); |
| 137 | + }) |
| 138 | + : undefined; |
| 139 | + |
| 140 | + // Determine which Select component to use |
| 141 | + let SelectElement: typeof ReactSelect | typeof AsyncSelect | typeof CreatableSelect | typeof AsyncCreatableSelect = ReactSelect; |
| 142 | + if (loadOptionsWrapper && creatable) { |
| 143 | + SelectElement = AsyncCreatableSelect; |
| 144 | + } else if (loadOptionsWrapper) { |
| 145 | + SelectElement = AsyncSelect; |
70 | 146 | } else if (creatable) { |
71 | | - SelectComponent = CreatableSelect; |
| 147 | + SelectElement = CreatableSelect; |
72 | 148 | } |
73 | 149 |
|
74 | | - const selectClassName = `Select ${multi ? 'Select--multi' : 'Select--single'} ${ |
75 | | - loadOptions ? 'select-async' : '' |
76 | | - } ${className || ''}`.trim(); |
77 | | - |
78 | | - const CustomValueContainer = ({ children, ...props }: ValueContainerProps<Option, boolean>) => { |
79 | | - return ( |
80 | | - <ReactSelectComponents.ValueContainer {...props}> |
81 | | - {children} |
82 | | - {name && <input type="hidden" name={name} value={props.getValue()[0]?.value || ''} />} |
83 | | - </ReactSelectComponents.ValueContainer> |
84 | | - ); |
| 150 | + // Custom styles |
| 151 | + const customStyles: StylesConfig<OptionType, boolean> = { |
| 152 | + control: (base) => ({ |
| 153 | + ...base, |
| 154 | + minHeight: '2.35rem', |
| 155 | + }), |
| 156 | + option: (base, state) => ({ |
| 157 | + ...base, |
| 158 | + backgroundColor: state.isDisabled ? '#f8f9fa' : base.backgroundColor, |
| 159 | + color: state.isDisabled ? '#6c757d' : base.color, |
| 160 | + cursor: state.isDisabled ? 'not-allowed' : 'default', |
| 161 | + }), |
85 | 162 | }; |
86 | 163 |
|
| 164 | + const isValidNewOptionWrapper = isValidNewOption |
| 165 | + ? ({ label, value, options }: CreateOptionProps<OptionType>) => isValidNewOption({ label, value }) |
| 166 | + : undefined; |
| 167 | + |
87 | 168 | return ( |
88 | | - <SelectComponent |
89 | | - ref={selectRef} |
90 | | - options={options} |
91 | | - value={internalValue} |
92 | | - onChange={handleChange} |
93 | | - loadOptions={loadOptions} |
94 | | - isMulti={multi} |
95 | | - className={selectClassName} |
96 | | - classNamePrefix="Select" |
97 | | - {...props} |
| 169 | + <SelectElement |
| 170 | + ref={ref} |
| 171 | + className={`${className || ''} ${loadOptionsWrapper ? 'select-async' : ''}`.trim()} |
98 | 172 | components={{ |
99 | | - ...ReactSelectComponents, |
100 | | - Input: (inputComponentProps: any) => ( |
101 | | - <ReactSelectComponents.Input |
102 | | - {...inputComponentProps} |
103 | | - {...inputProps} |
104 | | - name={inputProps?.name || name} |
105 | | - /> |
106 | | - ), |
107 | | - ValueContainer: CustomValueContainer, |
| 173 | + MultiValue: valueComponent || CustomMultiValue, |
| 174 | + Option: optionComponent || CustomOption, |
| 175 | + DropdownIndicator: CustomArrow, |
108 | 176 | }} |
| 177 | + styles={customStyles} |
| 178 | + inputProps={{ name: props.name, ...inputProps }} |
| 179 | + isMulti={multi} |
| 180 | + isDisabled={disabled} |
| 181 | + loadOptions={loadOptionsWrapper} |
| 182 | + onChange={handleChange} |
| 183 | + value={value} |
| 184 | + options={options} |
| 185 | + isValidNewOption={isValidNewOptionWrapper} |
| 186 | + isOptionDisabled={(option: OptionType) => !!option.disabled} |
| 187 | + {...restProps} |
109 | 188 | /> |
110 | 189 | ); |
111 | | -}; |
| 190 | +}); |
112 | 191 |
|
113 | | -// For testing purposes |
114 | | -Select.Async = AsyncSelect; |
115 | | -Select.AsyncCreatable = AsyncCreatableSelect; |
116 | | -Select.Creatable = CreatableSelect; |
| 192 | +Select.displayName = 'Select'; |
117 | 193 |
|
118 | 194 | export default Select; |
0 commit comments