Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / components / chips-input / chips-input.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import { Chips } from 'components/chips/chips';
7 import { Input as MuiInput, withStyles, WithStyles } from '@material-ui/core';
8 import { StyleRulesCallback } from '@material-ui/core/styles';
9 import { InputProps } from '@material-ui/core/Input';
10
11 interface ChipsInputProps<Value> {
12     values: Value[];
13     getLabel?: (value: Value) => string;
14     onChange: (value: Value[]) => void;
15     onPartialInput?: (value: boolean) => void;
16     handleFocus?: (e: any) => void;
17     handleBlur?: (e: any) => void;
18     chipsClassName?: string;
19     createNewValue: (value: string) => Value;
20     inputComponent?: React.ComponentType<InputProps>;
21     inputProps?: InputProps;
22     deletable?: boolean;
23     orderable?: boolean;
24     disabled?: boolean;
25     pattern?: RegExp;
26 }
27
28 type CssRules = 'chips' | 'input' | 'inputContainer';
29
30 const styles: StyleRulesCallback = ({ spacing }) => ({
31     chips: {
32         minHeight: spacing.unit * 5,
33         zIndex: 1,
34         position: 'relative',
35     },
36     input: {
37         zIndex: 1,
38         marginBottom: 8,
39         position: 'relative',
40     },
41     inputContainer: {
42         marginTop: -34
43     },
44 });
45
46 export const ChipsInput = withStyles(styles)(
47     class ChipsInput<Value> extends React.Component<ChipsInputProps<Value> & WithStyles<CssRules>> {
48
49         state = {
50             text: '',
51         };
52
53         filler = React.createRef<HTMLDivElement>();
54         timeout = -1;
55
56         setText = (event: React.ChangeEvent<HTMLInputElement>) => {
57             this.setState({ text: event.target.value }, () => {
58                 // Update partial input status
59                 this.props.onPartialInput && this.props.onPartialInput(this.state.text !== '');
60
61                 // If pattern is provided, check for delimiter
62                 if (this.props.pattern) {
63                     const matches = this.state.text.match(this.props.pattern);
64                     // Only create values if 1 match and the last character is a delimiter
65                     //   (user pressed an invalid character at the end of a token)
66                     //   or if multiple matches (user pasted text)
67                     if (matches &&
68                             (
69                                 matches.length > 1 ||
70                                 (matches.length === 1 && !this.state.text.endsWith(matches[0]))
71                             )) {
72                         this.createNewValue(matches.map((i) => i));
73                     }
74                 }
75             });
76         }
77
78         handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
79             // Handle special keypresses
80             if (e.key === 'Enter') {
81                 this.createNewValue();
82                 e.preventDefault();
83             } else if (e.key === 'Backspace') {
84                 this.deleteLastValue();
85             }
86         }
87
88         createNewValue = (matches?: string[]) => {
89             if (this.state.text) {
90                 if (matches && matches.length > 0) {
91                     const newValues = matches.map((v) => this.props.createNewValue(v));
92                     this.setState({ text: '' });
93                     this.props.onChange([...this.props.values, ...newValues]);
94                 } else {
95                     const newValue = this.props.createNewValue(this.state.text);
96                     this.setState({ text: '' });
97                     this.props.onChange([...this.props.values, newValue]);
98                 }
99                 this.props.onPartialInput && this.props.onPartialInput(false);
100             }
101         }
102
103         deleteLastValue = () => {
104             if (this.state.text.length === 0 && this.props.values.length > 0) {
105                 this.props.onChange(this.props.values.slice(0, -1));
106             }
107         }
108
109         updateCursorPosition = () => {
110             if (this.timeout) {
111                 clearTimeout(this.timeout);
112             }
113             this.timeout = window.setTimeout(() => this.setState({ ...this.state }));
114         }
115
116         getInputStyles = (): React.CSSProperties => ({
117             width: this.filler.current
118                 ? this.filler.current.offsetWidth
119                 : '100%',
120             right: this.filler.current
121                 ? `calc(${this.filler.current.offsetWidth}px - 100%)`
122                 : 0,
123
124         })
125
126         componentDidMount() {
127             this.updateCursorPosition();
128         }
129
130         render() {
131             return <>
132                 {this.renderChips()}
133                 {this.renderInput()}
134             </>;
135         }
136
137         renderChips() {
138             const { classes, ...props } = this.props;
139             return <div className={[classes.chips, this.props.chipsClassName].join(' ')}>
140                 <Chips
141                     {...props}
142                     clickable={!props.disabled}
143                     filler={<div ref={this.filler} />}
144                 />
145             </div>;
146         }
147
148         renderInput() {
149             const { inputProps: InputProps, inputComponent: Input = MuiInput, classes } = this.props;
150             return <Input
151                 {...InputProps}
152                 value={this.state.text}
153                 onChange={this.setText}
154                 disabled={this.props.disabled}
155                 onKeyDown={this.handleKeyPress}
156                 onFocus={this.props.handleFocus}
157                 onBlur={this.props.handleBlur}
158                 inputProps={{
159                     ...(InputProps && InputProps.inputProps),
160                     className: classes.input,
161                     style: this.getInputStyles(),
162                 }}
163                 fullWidth
164                 className={classes.inputContainer} />;
165         }
166
167         componentDidUpdate(prevProps: ChipsInputProps<Value>) {
168             if (prevProps.values !== this.props.values) {
169                 this.updateCursorPosition();
170             }
171         }
172         componentWillUnmount() {
173             clearTimeout(this.timeout);
174         }
175     });