1 import classNames from "classnames";
9 import { i18n } from "../../i18next";
10 import { Choice } from "../../utils";
11 import { Icon, Spinner } from "./icon";
13 interface SearchableSelectProps {
15 value?: number | string;
17 onChange?: (option: Choice) => void;
18 onSearch?: (text: string) => void;
22 interface SearchableSelectState {
23 selectedIndex: number;
25 loadingEllipses: string;
28 function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
29 const { onSearch } = i.props;
30 const searchText = e.target.value;
41 export class SearchableSelect extends Component<
42 SearchableSelectProps,
45 private searchInputRef: RefObject<HTMLInputElement> = createRef();
46 private toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
47 private loadingEllipsesInterval?: NodeJS.Timer = undefined;
49 state: SearchableSelectState = {
52 loadingEllipses: "...",
55 constructor(props: SearchableSelectProps, context: any) {
56 super(props, context);
58 this.handleChange = this.handleChange.bind(this);
59 this.focusSearch = this.focusSearch.bind(this);
62 let selectedIndex = props.options.findIndex(
63 ({ value }) => value === props.value?.toString()
66 if (selectedIndex < 0) {
78 const { id, options, onSearch, loading } = this.props;
79 const { searchText, selectedIndex, loadingEllipses } = this.state;
82 <div className="dropdown">
86 className="custom-select text-start"
87 aria-haspopup="listbox"
88 data-bs-toggle="dropdown"
89 onClick={this.focusSearch}
92 ? `${i18n.t("loading")}${loadingEllipses}`
93 : options[selectedIndex].label}
97 aria-activedescendant={options[selectedIndex].label}
98 className="modlog-choices-font-size dropdown-menu w-100 p-2"
100 <div className="input-group">
101 <span className="input-group-text">
102 {loading ? <Spinner /> : <Icon icon="search" />}
106 className="form-control"
107 ref={this.searchInputRef}
108 onInput={linkEvent(this, handleSearch)}
110 placeholder={`${i18n.t("search")}...`}
114 // If onSearch is provided, it is assumed that the parent component is doing it's own sorting logic.
115 (onSearch || searchText.length === 0
117 : options.filter(({ label }) =>
118 label.toLowerCase().includes(searchText.toLowerCase())
120 ).map((option, index) => (
123 className={classNames("dropdown-item", {
124 active: selectedIndex === index,
127 aria-disabled={option.disabled}
128 disabled={option.disabled}
129 aria-selected={selectedIndex === index}
130 onClick={() => this.handleChange(option)}
142 if (this.toggleButtonRef.current?.ariaExpanded !== "true") {
143 this.searchInputRef.current?.focus();
145 if (this.props.onSearch) {
146 this.props.onSearch("");
155 static getDerivedStateFromProps({
158 }: SearchableSelectProps): Partial<SearchableSelectState> {
161 ? options.findIndex(option => option.value === value.toString())
164 if (selectedIndex < 0) {
173 componentDidUpdate() {
174 const { loading } = this.props;
175 if (loading && !this.loadingEllipsesInterval) {
176 this.loadingEllipsesInterval = setInterval(() => {
177 this.setState(({ loadingEllipses }) => ({
179 loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
182 } else if (!loading && this.loadingEllipsesInterval) {
183 clearInterval(this.loadingEllipsesInterval);
187 componentWillUnmount() {
188 if (this.loadingEllipsesInterval) {
189 clearInterval(this.loadingEllipsesInterval);
193 handleChange(option: Choice) {
194 const { onChange, value } = this.props;
196 if (option.value !== value?.toString()) {
201 this.setState({ searchText: "" });