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