1 import { Choice } from "@utils/types";
2 import classNames from "classnames";
10 import { I18NextService } from "../../services";
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 function focusSearch(i: SearchableSelect) {
42 if (i.toggleButtonRef.current?.ariaExpanded !== "true") {
43 i.searchInputRef.current?.focus();
45 if (i.props.onSearch) {
55 function handleChange({ option, i }: { option: Choice; i: SearchableSelect }) {
56 const { onChange, value } = i.props;
58 if (option.value !== value?.toString()) {
63 i.setState({ searchText: "" });
67 export class SearchableSelect extends Component<
68 SearchableSelectProps,
71 searchInputRef: RefObject<HTMLInputElement> = createRef();
72 toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
73 private loadingEllipsesInterval?: NodeJS.Timer = undefined;
75 state: SearchableSelectState = {
78 loadingEllipses: "...",
81 constructor(props: SearchableSelectProps, context: any) {
82 super(props, context);
85 let selectedIndex = props.options.findIndex(
86 ({ value }) => value === props.value?.toString()
89 if (selectedIndex < 0) {
101 const { id, options, onSearch, loading } = this.props;
102 const { searchText, selectedIndex, loadingEllipses } = this.state;
105 <div className="searchable-select dropdown">
110 className="form-select d-inline-block text-start"
111 aria-haspopup="listbox"
112 aria-controls="searchable-select-input"
113 aria-expanded={false}
114 data-bs-toggle="dropdown"
115 onClick={linkEvent(this, focusSearch)}
116 ref={this.toggleButtonRef}
119 ? `${I18NextService.i18n.t("loading")}${loadingEllipses}`
120 : options[selectedIndex].label}
122 <div className="modlog-choices-font-size dropdown-menu w-100 p-2">
123 <div className="input-group">
124 <span className="input-group-text">
125 {loading ? <Spinner /> : <Icon icon="search" />}
129 id="searchable-select-input"
130 className="form-control"
131 ref={this.searchInputRef}
132 onInput={linkEvent(this, handleSearch)}
134 placeholder={`${I18NextService.i18n.t("search")}...`}
138 // If onSearch is provided, it is assumed that the parent component is doing it's own sorting logic.
139 (onSearch || searchText.length === 0
141 : options.filter(({ label }) =>
142 label.toLowerCase().includes(searchText.toLowerCase())
144 ).map((option, index) => (
147 className={classNames("dropdown-item", {
148 active: selectedIndex === index,
151 aria-disabled={option.disabled}
152 disabled={option.disabled}
153 aria-selected={selectedIndex === index}
154 onClick={linkEvent({ i: this, option }, handleChange)}
165 static getDerivedStateFromProps({
168 }: SearchableSelectProps): Partial<SearchableSelectState> {
171 ? options.findIndex(option => option.value === value.toString())
174 if (selectedIndex < 0) {
183 componentDidUpdate() {
184 const { loading } = this.props;
185 if (loading && !this.loadingEllipsesInterval) {
186 this.loadingEllipsesInterval = setInterval(() => {
187 this.setState(({ loadingEllipses }) => ({
189 loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
192 } else if (!loading && this.loadingEllipsesInterval) {
193 clearInterval(this.loadingEllipsesInterval);
197 componentWillUnmount() {
198 if (this.loadingEllipsesInterval) {
199 clearInterval(this.loadingEllipsesInterval);