]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/searchable-select.tsx
Use http client (#1081)
[lemmy-ui.git] / src / shared / components / common / searchable-select.tsx
1 import classNames from "classnames";
2 import {
3   ChangeEvent,
4   Component,
5   createRef,
6   linkEvent,
7   RefObject,
8 } from "inferno";
9 import { i18n } from "../../i18next";
10 import { Choice } from "../../utils";
11 import { Icon, Spinner } from "./icon";
12
13 interface SearchableSelectProps {
14   id: string;
15   value?: number | string;
16   options: Choice[];
17   onChange?: (option: Choice) => void;
18   onSearch?: (text: string) => void;
19   loading?: boolean;
20 }
21
22 interface SearchableSelectState {
23   selectedIndex: number;
24   searchText: string;
25   loadingEllipses: string;
26 }
27
28 function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
29   const { onSearch } = i.props;
30   const searchText = e.target.value;
31
32   if (onSearch) {
33     onSearch(searchText);
34   }
35
36   i.setState({
37     searchText,
38   });
39 }
40
41 function focusSearch(i: SearchableSelect) {
42   if (i.toggleButtonRef.current?.ariaExpanded !== "true") {
43     i.searchInputRef.current?.focus();
44
45     if (i.props.onSearch) {
46       i.props.onSearch("");
47     }
48
49     i.setState({
50       searchText: "",
51     });
52   }
53 }
54
55 function handleChange({ option, i }: { option: Choice; i: SearchableSelect }) {
56   const { onChange, value } = i.props;
57
58   if (option.value !== value?.toString()) {
59     if (onChange) {
60       onChange(option);
61     }
62
63     i.setState({ searchText: "" });
64   }
65 }
66
67 export class SearchableSelect extends Component<
68   SearchableSelectProps,
69   SearchableSelectState
70 > {
71   searchInputRef: RefObject<HTMLInputElement> = createRef();
72   toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
73   private loadingEllipsesInterval?: NodeJS.Timer = undefined;
74
75   state: SearchableSelectState = {
76     selectedIndex: 0,
77     searchText: "",
78     loadingEllipses: "...",
79   };
80
81   constructor(props: SearchableSelectProps, context: any) {
82     super(props, context);
83
84     if (props.value) {
85       let selectedIndex = props.options.findIndex(
86         ({ value }) => value === props.value?.toString()
87       );
88
89       if (selectedIndex < 0) {
90         selectedIndex = 0;
91       }
92
93       this.state = {
94         ...this.state,
95         selectedIndex,
96       };
97     }
98   }
99
100   render() {
101     const { id, options, onSearch, loading } = this.props;
102     const { searchText, selectedIndex, loadingEllipses } = this.state;
103
104     return (
105       <div className="dropdown">
106         <button
107           id={id}
108           type="button"
109           className="custom-select text-start"
110           aria-haspopup="listbox"
111           data-bs-toggle="dropdown"
112           onClick={linkEvent(this, focusSearch)}
113           ref={this.toggleButtonRef}
114         >
115           {loading
116             ? `${i18n.t("loading")}${loadingEllipses}`
117             : options[selectedIndex].label}
118         </button>
119         <div
120           role="combobox"
121           aria-activedescendant={options[selectedIndex].label}
122           className="modlog-choices-font-size dropdown-menu w-100 p-2"
123         >
124           <div className="input-group">
125             <span className="input-group-text">
126               {loading ? <Spinner /> : <Icon icon="search" />}
127             </span>
128             <input
129               type="text"
130               className="form-control"
131               ref={this.searchInputRef}
132               onInput={linkEvent(this, handleSearch)}
133               value={searchText}
134               placeholder={`${i18n.t("search")}...`}
135             />
136           </div>
137           {!loading &&
138             // If onSearch is provided, it is assumed that the parent component is doing it's own sorting logic.
139             (onSearch || searchText.length === 0
140               ? options
141               : options.filter(({ label }) =>
142                   label.toLowerCase().includes(searchText.toLowerCase())
143                 )
144             ).map((option, index) => (
145               <button
146                 key={option.value}
147                 className={classNames("dropdown-item", {
148                   active: selectedIndex === index,
149                 })}
150                 role="option"
151                 aria-disabled={option.disabled}
152                 disabled={option.disabled}
153                 aria-selected={selectedIndex === index}
154                 onClick={linkEvent({ i: this, option }, handleChange)}
155                 type="button"
156               >
157                 {option.label}
158               </button>
159             ))}
160         </div>
161       </div>
162     );
163   }
164
165   static getDerivedStateFromProps({
166     value,
167     options,
168   }: SearchableSelectProps): Partial<SearchableSelectState> {
169     let selectedIndex =
170       value || value === 0
171         ? options.findIndex(option => option.value === value.toString())
172         : 0;
173
174     if (selectedIndex < 0) {
175       selectedIndex = 0;
176     }
177
178     return {
179       selectedIndex,
180     };
181   }
182
183   componentDidUpdate() {
184     const { loading } = this.props;
185     if (loading && !this.loadingEllipsesInterval) {
186       this.loadingEllipsesInterval = setInterval(() => {
187         this.setState(({ loadingEllipses }) => ({
188           loadingEllipses:
189             loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
190         }));
191       }, 750);
192     } else if (!loading && this.loadingEllipsesInterval) {
193       clearInterval(this.loadingEllipsesInterval);
194     }
195   }
196
197   componentWillUnmount() {
198     if (this.loadingEllipsesInterval) {
199       clearInterval(this.loadingEllipsesInterval);
200     }
201   }
202 }