Merge branch '15256-removing-files-during-upload'
[arvados-workbench2.git] / src / components / autocomplete / autocomplete.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import * as React from 'react';
6 import {
7     Input as MuiInput,
8     Chip as MuiChip,
9     Popper as MuiPopper,
10     Paper as MuiPaper,
11     FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText
12 } from '@material-ui/core';
13 import { PopperProps } from '@material-ui/core/Popper';
14 import { WithStyles } from '@material-ui/core/styles';
15 import { noop } from 'lodash';
16
17 export interface AutocompleteProps<Item, Suggestion> {
18     label?: string;
19     value: string;
20     items: Item[];
21     suggestions?: Suggestion[];
22     error?: boolean;
23     helperText?: string;
24     autofocus?: boolean;
25     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
26     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
27     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
28     onCreate?: () => void;
29     onDelete?: (item: Item, index: number) => void;
30     onSelect?: (suggestion: Suggestion) => void;
31     renderChipValue?: (item: Item) => string;
32     renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
33 }
34
35 export interface AutocompleteState {
36     suggestionsOpen: boolean;
37     selectedSuggestionIndex: number;
38 }
39
40 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
41
42     state = {
43         suggestionsOpen: false,
44         selectedSuggestionIndex: 0,
45     };
46
47     containerRef = React.createRef<HTMLDivElement>();
48     inputRef = React.createRef<HTMLInputElement>();
49
50     render() {
51         return (
52             <RootRef rootRef={this.containerRef}>
53                 <FormControl fullWidth error={this.props.error}>
54                     {this.renderLabel()}
55                     {this.renderInput()}
56                     {this.renderHelperText()}
57                     {this.renderSuggestions()}
58                 </FormControl>
59             </RootRef>
60         );
61     }
62
63     renderLabel() {
64         const { label } = this.props;
65         return label && <InputLabel>{label}</InputLabel>;
66     }
67
68     renderInput() {
69         return <Input
70             autoFocus={this.props.autofocus}
71             inputRef={this.inputRef}
72             value={this.props.value}
73             startAdornment={this.renderChips()}
74             onFocus={this.handleFocus}
75             onBlur={this.handleBlur}
76             onChange={this.props.onChange}
77             onKeyPress={this.handleKeyPress}
78             onKeyDown={this.handleNavigationKeyPress}
79         />;
80     }
81
82     renderHelperText() {
83         return <FormHelperText>{this.props.helperText}</FormHelperText>;
84     }
85
86     renderSuggestions() {
87         const { suggestions = [] } = this.props;
88         return (
89             <Popper
90                 open={this.isSuggestionBoxOpen()}
91                 anchorEl={this.inputRef.current}
92                 key={suggestions.length}>
93                 <Paper onMouseDown={this.preventBlur}>
94                     <List dense style={{ width: this.getSuggestionsWidth() }}>
95                         {suggestions.map(
96                             (suggestion, index) =>
97                                 <ListItem
98                                     button
99                                     key={index}
100                                     onClick={this.handleSelect(suggestion)}
101                                     selected={index === this.state.selectedSuggestionIndex}>
102                                     {this.renderSuggestion(suggestion)}
103                                 </ListItem>
104                         )}
105                     </List>
106                 </Paper>
107             </Popper>
108         );
109     }
110
111     isSuggestionBoxOpen() {
112         const { suggestions = [] } = this.props;
113         return this.state.suggestionsOpen && suggestions.length > 0;
114     }
115
116     handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
117         const { onFocus = noop } = this.props;
118         this.setState({ suggestionsOpen: true });
119         onFocus(event);
120     }
121
122     handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
123         setTimeout(() => {
124             const { onBlur = noop } = this.props;
125             this.setState({ suggestionsOpen: false });
126             onBlur(event);
127         });
128     }
129
130     handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
131         const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
132         const { selectedSuggestionIndex } = this.state;
133         if (event.key === 'Enter') {
134             if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
135                 // prevent form submissions when selecting a suggestion
136                 event.preventDefault();
137                 onSelect(suggestions[selectedSuggestionIndex]);
138             } else if (this.props.value.length > 0) {
139                 onCreate();
140             }
141         }
142     }
143
144     handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
145         if (key === 'ArrowUp') {
146             this.updateSelectedSuggestionIndex(-1);
147         } else if (key === 'ArrowDown') {
148             this.updateSelectedSuggestionIndex(1);
149         }
150     }
151
152     updateSelectedSuggestionIndex(value: -1 | 1) {
153         const { suggestions = [] } = this.props;
154         this.setState(({ selectedSuggestionIndex }) => ({
155             selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
156         }));
157     }
158
159     renderChips() {
160         const { items, onDelete } = this.props;
161
162         /**
163          * If input startAdornment prop is not undefined, input's label will stay above the input.
164          * If there is not items, we want the label to go back to placeholder position.
165          * That why we return without a value instead of returning a result of a _map_ which is an empty array.
166          */
167         if (items.length === 0) {
168             return;
169         }
170
171         return items.map(
172             (item, index) =>
173                 <Chip
174                     label={this.renderChipValue(item)}
175                     key={index}
176                     onDelete={() => onDelete ? onDelete(item, index) : undefined} />
177         );
178     }
179
180     renderChipValue(value: Value) {
181         const { renderChipValue } = this.props;
182         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
183     }
184
185     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
186         event.preventDefault();
187     }
188
189     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
190         if (event.target !== this.inputRef.current) {
191             this.setState({ suggestionsOpen: false });
192         }
193     }
194
195     handleSelect(suggestion: Suggestion) {
196         return () => {
197             const { onSelect = noop } = this.props;
198             const { current } = this.inputRef;
199             if (current) {
200                 current.focus();
201             }
202             onSelect(suggestion);
203         };
204     }
205
206     renderSuggestion(suggestion: Suggestion) {
207         const { renderSuggestion } = this.props;
208         return renderSuggestion
209             ? renderSuggestion(suggestion)
210             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
211     }
212
213     getSuggestionsWidth() {
214         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
215     }
216 }
217
218 type ChipClasses = 'root';
219
220 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
221     root: {
222         marginRight: theme.spacing.unit / 4,
223         height: theme.spacing.unit * 3,
224     }
225 });
226
227 const Chip = withStyles(chipStyles)(MuiChip);
228
229 type PopperClasses = 'root';
230
231 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
232     root: {
233         zIndex: theme.zIndex.modal,
234     }
235 });
236
237 const Popper = withStyles(popperStyles)(
238     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
239         <MuiPopper {...props} className={classes.root} />
240 );
241
242 type InputClasses = 'root';
243
244 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
245     root: {
246         display: 'flex',
247         flexWrap: 'wrap',
248     },
249     input: {
250         minWidth: '20%',
251         flex: 1,
252     },
253 });
254
255 const Input = withStyles(inputStyles)(MuiInput);
256
257 const Paper = withStyles({
258     root: {
259         maxHeight: '80vh',
260         overflowY: 'auto',
261     }
262 })(MuiPaper);