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