]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/searchable-select.tsx
Merge branch 'main' into fix/fix-badges-spacing-componentize
[lemmy-ui.git] / src / shared / components / common / searchable-select.tsx
1 import { Choice } from "@utils/types";
2 import classNames from "classnames";
3 import {
4   ChangeEvent,
5   Component,
6   createRef,
7   linkEvent,
8   RefObject,
9 } from "inferno";
10 import { I18NextService } from "../../services";
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="searchable-select dropdown col-12 col-sm-auto flex-grow-1">
106         <button
107           id={id}
108           type="button"
109           role="combobox"
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}
118         >
119           {loading
120             ? `${I18NextService.i18n.t("loading")}${loadingEllipses}`
121             : options[selectedIndex].label}
122         </button>
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" />}
127             </span>
128             <input
129               type="text"
130               id="searchable-select-input"
131               className="form-control"
132               ref={this.searchInputRef}
133               onInput={linkEvent(this, handleSearch)}
134               value={searchText}
135               placeholder={`${I18NextService.i18n.t("search")}...`}
136             />
137           </div>
138           {!loading &&
139             // If onSearch is provided, it is assumed that the parent component is doing it's own sorting logic.
140             (onSearch || searchText.length === 0
141               ? options
142               : options.filter(({ label }) =>
143                   label.toLowerCase().includes(searchText.toLowerCase())
144                 )
145             ).map((option, index) => (
146               <button
147                 key={option.value}
148                 className={classNames("dropdown-item", {
149                   active: selectedIndex === index,
150                 })}
151                 role="option"
152                 aria-disabled={option.disabled}
153                 disabled={option.disabled}
154                 aria-selected={selectedIndex === index}
155                 onClick={linkEvent({ i: this, option }, handleChange)}
156                 type="button"
157               >
158                 {option.label}
159               </button>
160             ))}
161         </div>
162       </div>
163     );
164   }
165
166   static getDerivedStateFromProps({
167     value,
168     options,
169   }: SearchableSelectProps): Partial<SearchableSelectState> {
170     let selectedIndex =
171       value || value === 0
172         ? options.findIndex(option => option.value === value.toString())
173         : 0;
174
175     if (selectedIndex < 0) {
176       selectedIndex = 0;
177     }
178
179     return {
180       selectedIndex,
181     };
182   }
183
184   componentDidUpdate() {
185     const { loading } = this.props;
186     if (loading && !this.loadingEllipsesInterval) {
187       this.loadingEllipsesInterval = setInterval(() => {
188         this.setState(({ loadingEllipses }) => ({
189           loadingEllipses:
190             loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
191         }));
192       }, 750);
193     } else if (!loading && this.loadingEllipsesInterval) {
194       clearInterval(this.loadingEllipsesInterval);
195     }
196   }
197
198   componentWillUnmount() {
199     if (this.loadingEllipsesInterval) {
200       clearInterval(this.loadingEllipsesInterval);
201     }
202   }
203 }