diff options
author | 2021-10-15 16:22:25 +0530 | |
---|---|---|
committer | 2021-10-15 16:22:25 +0530 | |
commit | ec15f83b947fb2daf4ca1a72e3af527dc89512a3 (patch) | |
tree | ea049cee5184a58b5bc09505e723cd19a736c4bd /src/components/ui/select | |
parent | chore: move 'packages/ui' into 'src' (no longer an injected package) (#2077) (diff) | |
download | ferdium-app-ec15f83b947fb2daf4ca1a72e3af527dc89512a3.tar.gz ferdium-app-ec15f83b947fb2daf4ca1a72e3af527dc89512a3.tar.zst ferdium-app-ec15f83b947fb2daf4ca1a72e3af527dc89512a3.zip |
chore: move 'packages/forms' into 'src' (no longer an injected package) (#2079)
Diffstat (limited to 'src/components/ui/select')
-rw-r--r-- | src/components/ui/select/index.tsx | 461 |
1 files changed, 461 insertions, 0 deletions
diff --git a/src/components/ui/select/index.tsx b/src/components/ui/select/index.tsx new file mode 100644 index 000000000..41cab7818 --- /dev/null +++ b/src/components/ui/select/index.tsx | |||
@@ -0,0 +1,461 @@ | |||
1 | import { | ||
2 | mdiArrowRightDropCircleOutline, | ||
3 | mdiCloseCircle, | ||
4 | mdiMagnify, | ||
5 | } from '@mdi/js'; | ||
6 | import Icon from '@mdi/react'; | ||
7 | import classnames from 'classnames'; | ||
8 | import { ChangeEvent, Component, createRef } from 'react'; | ||
9 | import injectStyle from 'react-jss'; | ||
10 | |||
11 | import { Theme } from '@meetfranz/theme'; | ||
12 | |||
13 | import { IFormField, IWithStyle } from '../typings/generic'; | ||
14 | |||
15 | import { Error } from '../error'; | ||
16 | import { Label } from '../label'; | ||
17 | import { Wrapper } from '../wrapper'; | ||
18 | |||
19 | interface IOptions { | ||
20 | [index: string]: string; | ||
21 | } | ||
22 | |||
23 | interface IData { | ||
24 | [index: string]: string; | ||
25 | } | ||
26 | |||
27 | interface IProps extends IFormField, IWithStyle { | ||
28 | actionText: string; | ||
29 | className?: string; | ||
30 | inputClassName?: string; | ||
31 | defaultValue?: string; | ||
32 | disabled?: boolean; | ||
33 | id?: string; | ||
34 | name: string; | ||
35 | options: IOptions; | ||
36 | value: string; | ||
37 | onChange: (event: ChangeEvent<HTMLInputElement>) => void; | ||
38 | showSearch: boolean; | ||
39 | data: IData; | ||
40 | } | ||
41 | |||
42 | interface IState { | ||
43 | open: boolean; | ||
44 | value: string; | ||
45 | needle: string; | ||
46 | selected: number; | ||
47 | options: IOptions; | ||
48 | } | ||
49 | |||
50 | let popupTransition: string = 'none'; | ||
51 | let toggleTransition: string = 'none'; | ||
52 | |||
53 | if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) { | ||
54 | popupTransition = 'all 0.3s'; | ||
55 | toggleTransition = 'transform 0.3s'; | ||
56 | } | ||
57 | |||
58 | const styles = (theme: Theme) => ({ | ||
59 | select: { | ||
60 | background: theme.selectBackground, | ||
61 | border: theme.selectBorder, | ||
62 | borderRadius: theme.borderRadiusSmall, | ||
63 | height: theme.selectHeight, | ||
64 | fontSize: theme.uiFontSize, | ||
65 | width: '100%', | ||
66 | display: 'flex', | ||
67 | alignItems: 'center', | ||
68 | textAlign: 'left', | ||
69 | color: theme.selectColor, | ||
70 | }, | ||
71 | label: { | ||
72 | '& > div': { | ||
73 | marginTop: 5, | ||
74 | }, | ||
75 | }, | ||
76 | popup: { | ||
77 | opacity: 0, | ||
78 | height: 0, | ||
79 | overflowX: 'scroll', | ||
80 | border: theme.selectBorder, | ||
81 | borderTop: 0, | ||
82 | transition: popupTransition, | ||
83 | }, | ||
84 | open: { | ||
85 | opacity: 1, | ||
86 | height: 350, | ||
87 | background: theme.selectPopupBackground, | ||
88 | }, | ||
89 | option: { | ||
90 | padding: 10, | ||
91 | borderBottom: theme.selectOptionBorder, | ||
92 | color: theme.selectOptionColor, | ||
93 | |||
94 | '&:hover': { | ||
95 | background: theme.selectOptionItemHover, | ||
96 | color: theme.selectOptionItemHoverColor, | ||
97 | }, | ||
98 | '&:active': { | ||
99 | background: theme.selectOptionItemActive, | ||
100 | color: theme.selectOptionItemActiveColor, | ||
101 | }, | ||
102 | }, | ||
103 | selected: { | ||
104 | background: theme.selectOptionItemActive, | ||
105 | color: theme.selectOptionItemActiveColor, | ||
106 | }, | ||
107 | toggle: { | ||
108 | marginLeft: 'auto', | ||
109 | fill: theme.selectToggleColor, | ||
110 | transition: toggleTransition, | ||
111 | }, | ||
112 | toggleOpened: { | ||
113 | transform: 'rotateZ(90deg)', | ||
114 | }, | ||
115 | searchContainer: { | ||
116 | display: 'flex', | ||
117 | background: theme.selectSearchBackground, | ||
118 | alignItems: 'center', | ||
119 | paddingLeft: 10, | ||
120 | color: theme.selectColor, | ||
121 | |||
122 | '& svg': { | ||
123 | fill: theme.selectSearchColor, | ||
124 | }, | ||
125 | }, | ||
126 | search: { | ||
127 | border: 0, | ||
128 | width: '100%', | ||
129 | fontSize: theme.uiFontSize, | ||
130 | background: 'none', | ||
131 | marginLeft: 10, | ||
132 | padding: [10, 0], | ||
133 | color: theme.selectSearchColor, | ||
134 | }, | ||
135 | clearNeedle: { | ||
136 | background: 'none', | ||
137 | border: 0, | ||
138 | }, | ||
139 | focused: { | ||
140 | fontWeight: 'bold', | ||
141 | background: theme.selectOptionItemHover, | ||
142 | color: theme.selectOptionItemHoverColor, | ||
143 | }, | ||
144 | hasError: { | ||
145 | borderColor: theme.brandDanger, | ||
146 | }, | ||
147 | disabled: { | ||
148 | opacity: theme.selectDisabledOpacity, | ||
149 | }, | ||
150 | }); | ||
151 | |||
152 | class SelectComponent extends Component<IProps> { | ||
153 | public static defaultProps = { | ||
154 | onChange: () => {}, | ||
155 | showLabel: true, | ||
156 | disabled: false, | ||
157 | error: '', | ||
158 | }; | ||
159 | |||
160 | state = { | ||
161 | open: false, | ||
162 | value: '', | ||
163 | needle: '', | ||
164 | selected: 0, | ||
165 | options: null, | ||
166 | }; | ||
167 | |||
168 | private componentRef = createRef<HTMLDivElement>(); | ||
169 | |||
170 | private inputRef = createRef<HTMLInputElement>(); | ||
171 | |||
172 | private searchInputRef = createRef<HTMLInputElement>(); | ||
173 | |||
174 | private scrollContainerRef = createRef<HTMLDivElement>(); | ||
175 | |||
176 | private activeOptionRef = createRef<HTMLDivElement>(); | ||
177 | |||
178 | private keyListener: any; | ||
179 | |||
180 | componentWillReceiveProps(nextProps: IProps) { | ||
181 | if (nextProps.value && nextProps.value !== this.props.value) { | ||
182 | this.setState({ | ||
183 | value: nextProps.value, | ||
184 | }); | ||
185 | } | ||
186 | } | ||
187 | |||
188 | componentDidUpdate() { | ||
189 | const { open } = this.state; | ||
190 | |||
191 | if (this.searchInputRef && this.searchInputRef.current && open) { | ||
192 | this.searchInputRef.current.focus(); | ||
193 | } | ||
194 | } | ||
195 | |||
196 | componentDidMount() { | ||
197 | if (this.inputRef && this.inputRef.current) { | ||
198 | const { data } = this.props; | ||
199 | |||
200 | if (data) { | ||
201 | Object.keys(data).map( | ||
202 | key => (this.inputRef.current!.dataset[key] = data[key]), | ||
203 | ); | ||
204 | } | ||
205 | } | ||
206 | |||
207 | window.addEventListener('keydown', this.arrowKeysHandler.bind(this), false); | ||
208 | } | ||
209 | |||
210 | componentWillMount() { | ||
211 | const { value } = this.props; | ||
212 | |||
213 | if (this.componentRef && this.componentRef.current) { | ||
214 | this.componentRef.current.removeEventListener( | ||
215 | 'keydown', | ||
216 | this.keyListener, | ||
217 | ); | ||
218 | } | ||
219 | |||
220 | if (value) { | ||
221 | this.setState({ | ||
222 | value, | ||
223 | }); | ||
224 | } | ||
225 | |||
226 | this.setFilter(); | ||
227 | } | ||
228 | |||
229 | componentWillUnmount() { | ||
230 | // eslint-disable-next-line unicorn/no-invalid-remove-event-listener | ||
231 | window.removeEventListener('keydown', this.arrowKeysHandler.bind(this)); | ||
232 | } | ||
233 | |||
234 | setFilter(needle = '') { | ||
235 | const { options } = this.props; | ||
236 | |||
237 | let filteredOptions = {}; | ||
238 | if (needle) { | ||
239 | Object.keys(options).map(key => { | ||
240 | if ( | ||
241 | key.toLocaleLowerCase().startsWith(needle.toLocaleLowerCase()) || | ||
242 | options[key] | ||
243 | .toLocaleLowerCase() | ||
244 | .startsWith(needle.toLocaleLowerCase()) | ||
245 | ) { | ||
246 | Object.assign(filteredOptions, { | ||
247 | [`${key}`]: options[key], | ||
248 | }); | ||
249 | } | ||
250 | }); | ||
251 | } else { | ||
252 | filteredOptions = options; | ||
253 | } | ||
254 | |||
255 | this.setState({ | ||
256 | needle, | ||
257 | options: filteredOptions, | ||
258 | selected: 0, | ||
259 | }); | ||
260 | } | ||
261 | |||
262 | select(key: string) { | ||
263 | this.setState(() => ({ | ||
264 | value: key, | ||
265 | open: false, | ||
266 | })); | ||
267 | |||
268 | this.setFilter(); | ||
269 | |||
270 | if (this.props.onChange) { | ||
271 | this.props.onChange(key as any); | ||
272 | } | ||
273 | } | ||
274 | |||
275 | arrowKeysHandler(e: KeyboardEvent) { | ||
276 | const { selected, open, options } = this.state; | ||
277 | |||
278 | if (!open) return; | ||
279 | |||
280 | if (e.keyCode === 38 || e.keyCode === 40) { | ||
281 | e.preventDefault(); | ||
282 | } | ||
283 | |||
284 | if (this.componentRef && this.componentRef.current) { | ||
285 | if (e.keyCode === 38 && selected > 0) { | ||
286 | this.setState((state: IState) => ({ | ||
287 | selected: state.selected - 1, | ||
288 | })); | ||
289 | } else if ( | ||
290 | e.keyCode === 40 && | ||
291 | selected < Object.keys(options!).length - 1 | ||
292 | ) { | ||
293 | this.setState((state: IState) => ({ | ||
294 | selected: state.selected + 1, | ||
295 | })); | ||
296 | } else if (e.keyCode === 13) { | ||
297 | this.select(Object.keys(options!)[selected]); | ||
298 | } | ||
299 | |||
300 | if ( | ||
301 | this.activeOptionRef && | ||
302 | this.activeOptionRef.current && | ||
303 | this.scrollContainerRef && | ||
304 | this.scrollContainerRef.current | ||
305 | ) { | ||
306 | const containerTopOffset = this.scrollContainerRef.current.offsetTop; | ||
307 | const optionTopOffset = this.activeOptionRef.current.offsetTop; | ||
308 | |||
309 | const topOffset = optionTopOffset - containerTopOffset; | ||
310 | |||
311 | this.scrollContainerRef.current.scrollTop = topOffset - 35; | ||
312 | } | ||
313 | } | ||
314 | |||
315 | switch (e.keyCode) { | ||
316 | case 37: | ||
317 | case 39: | ||
318 | case 38: | ||
319 | case 40: // Arrow keys | ||
320 | case 32: | ||
321 | break; // Space | ||
322 | default: | ||
323 | break; // do not block other keys | ||
324 | } | ||
325 | } | ||
326 | |||
327 | render() { | ||
328 | const { | ||
329 | actionText, | ||
330 | classes, | ||
331 | className, | ||
332 | defaultValue, | ||
333 | disabled, | ||
334 | error, | ||
335 | id, | ||
336 | inputClassName, | ||
337 | name, | ||
338 | label, | ||
339 | showLabel, | ||
340 | showSearch, | ||
341 | onChange, | ||
342 | required, | ||
343 | } = this.props; | ||
344 | |||
345 | const { open, needle, value, selected, options } = this.state; | ||
346 | |||
347 | let selection = ''; | ||
348 | if (!value && defaultValue && options![defaultValue]) { | ||
349 | selection = options![defaultValue]; | ||
350 | } else if (value && options![value]) { | ||
351 | selection = options![value]; | ||
352 | } else { | ||
353 | selection = actionText; | ||
354 | } | ||
355 | |||
356 | return ( | ||
357 | <Wrapper className={className} identifier="franz-select"> | ||
358 | <Label | ||
359 | title={label} | ||
360 | showLabel={showLabel} | ||
361 | htmlFor={id} | ||
362 | className={classes.label} | ||
363 | isRequired={required} | ||
364 | > | ||
365 | <div | ||
366 | className={classnames({ | ||
367 | [`${classes.hasError}`]: error, | ||
368 | [`${classes.disabled}`]: disabled, | ||
369 | })} | ||
370 | ref={this.componentRef} | ||
371 | > | ||
372 | <button | ||
373 | type="button" | ||
374 | className={classnames({ | ||
375 | [`${inputClassName}`]: inputClassName, | ||
376 | [`${classes.select}`]: true, | ||
377 | [`${classes.hasError}`]: error, | ||
378 | })} | ||
379 | onClick={ | ||
380 | !disabled | ||
381 | ? () => | ||
382 | this.setState((state: IState) => ({ | ||
383 | open: !state.open, | ||
384 | })) | ||
385 | : () => {} | ||
386 | } | ||
387 | > | ||
388 | {selection} | ||
389 | <Icon | ||
390 | path={mdiArrowRightDropCircleOutline} | ||
391 | size={0.8} | ||
392 | className={classnames({ | ||
393 | [`${classes.toggle}`]: true, | ||
394 | [`${classes.toggleOpened}`]: open, | ||
395 | })} | ||
396 | /> | ||
397 | </button> | ||
398 | {showSearch && open && ( | ||
399 | <div className={classes.searchContainer}> | ||
400 | <Icon path={mdiMagnify} size={0.8} /> | ||
401 | <input | ||
402 | type="text" | ||
403 | value={needle} | ||
404 | onChange={e => this.setFilter(e.currentTarget.value)} | ||
405 | placeholder="Search" | ||
406 | className={classes.search} | ||
407 | ref={this.searchInputRef} | ||
408 | /> | ||
409 | {needle && ( | ||
410 | <button | ||
411 | type="button" | ||
412 | className={classes.clearNeedle} | ||
413 | onClick={() => this.setFilter()} | ||
414 | > | ||
415 | <Icon path={mdiCloseCircle} size={0.7} /> | ||
416 | </button> | ||
417 | )} | ||
418 | </div> | ||
419 | )} | ||
420 | <div | ||
421 | className={classnames({ | ||
422 | [`${classes.popup}`]: true, | ||
423 | [`${classes.open}`]: open, | ||
424 | })} | ||
425 | ref={this.scrollContainerRef} | ||
426 | > | ||
427 | {Object.keys(options!).map((key, i) => ( | ||
428 | <div | ||
429 | key={key} | ||
430 | onClick={() => this.select(key)} | ||
431 | className={classnames({ | ||
432 | [`${classes.option}`]: true, | ||
433 | [`${classes.selected}`]: options![key] === selection, | ||
434 | [`${classes.focused}`]: selected === i, | ||
435 | })} | ||
436 | onMouseOver={() => this.setState({ selected: i })} | ||
437 | ref={selected === i ? this.activeOptionRef : null} | ||
438 | > | ||
439 | {options![key]} | ||
440 | </div> | ||
441 | ))} | ||
442 | </div> | ||
443 | </div> | ||
444 | <input | ||
445 | className={classes.input} | ||
446 | id={id} | ||
447 | name={name} | ||
448 | type="hidden" | ||
449 | defaultValue={value} | ||
450 | onChange={onChange} | ||
451 | disabled={disabled} | ||
452 | ref={this.inputRef} | ||
453 | /> | ||
454 | </Label> | ||
455 | {error && <Error message={error} />} | ||
456 | </Wrapper> | ||
457 | ); | ||
458 | } | ||
459 | } | ||
460 | |||
461 | export const Select = injectStyle(styles)(SelectComponent); | ||