aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/ui/select/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ui/select/index.tsx')
-rw-r--r--src/components/ui/select/index.tsx461
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 @@
1import {
2 mdiArrowRightDropCircleOutline,
3 mdiCloseCircle,
4 mdiMagnify,
5} from '@mdi/js';
6import Icon from '@mdi/react';
7import classnames from 'classnames';
8import { ChangeEvent, Component, createRef } from 'react';
9import injectStyle from 'react-jss';
10
11import { Theme } from '@meetfranz/theme';
12
13import { IFormField, IWithStyle } from '../typings/generic';
14
15import { Error } from '../error';
16import { Label } from '../label';
17import { Wrapper } from '../wrapper';
18
19interface IOptions {
20 [index: string]: string;
21}
22
23interface IData {
24 [index: string]: string;
25}
26
27interface 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
42interface IState {
43 open: boolean;
44 value: string;
45 needle: string;
46 selected: number;
47 options: IOptions;
48}
49
50let popupTransition: string = 'none';
51let toggleTransition: string = 'none';
52
53if (window && window.matchMedia('(prefers-reduced-motion: no-preference)')) {
54 popupTransition = 'all 0.3s';
55 toggleTransition = 'transform 0.3s';
56}
57
58const 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
152class 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
461export const Select = injectStyle(styles)(SelectComponent);