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