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-activedescendant={options[selectedIndex].label}
114 aria-expanded={false}
115 data-bs-toggle="dropdown"
116 onClick={linkEvent(this, focusSearch)}
117 ref={this.toggleButtonRef}
120 ? `${I18NextService.i18n.t("loading")}${loadingEllipses}`
121 : options[selectedIndex].label}
123 <div className="modlog-choices-font-size dropdown-menu w-100 p-2">
124 <div className="input-group">
125 <span className="input-group-text">
126 {loading ? <Spinner /> : <Icon icon="search" />}
130 id="searchable-select-input"
131 className="form-control"
132 ref={this.searchInputRef}
133 onInput={linkEvent(this, handleSearch)}
135 placeholder={`${I18NextService.i18n.t("search")}...`}
139 // If onSearch is provided, it is assumed that the parent component is doing it's own sorting logic.
140 (onSearch || searchText.length === 0
142 : options.filter(({ label }) =>
143 label.toLowerCase().includes(searchText.toLowerCase())
145 ).map((option, index) => (
148 className={classNames("dropdown-item", {
149 active: selectedIndex === index,
152 aria-disabled={option.disabled}
153 disabled={option.disabled}
154 aria-selected={selectedIndex === index}
155 onClick={linkEvent({ i: this, option }, handleChange)}
166 static getDerivedStateFromProps({
169 }: SearchableSelectProps): Partial<SearchableSelectState> {
172 ? options.findIndex(option => option.value === value.toString())
175 if (selectedIndex < 0) {
184 componentDidUpdate() {
185 const { loading } = this.props;
186 if (loading && !this.loadingEllipsesInterval) {
187 this.loadingEllipsesInterval = setInterval(() => {
188 this.setState(({ loadingEllipses }) => ({
190 loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
193 } else if (!loading && this.loadingEllipsesInterval) {
194 clearInterval(this.loadingEllipsesInterval);
198 componentWillUnmount() {
199 if (this.loadingEllipsesInterval) {
200 clearInterval(this.loadingEllipsesInterval);