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