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