Fix autocomplete's label positioning when there is no items
[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
153         /**
154          * If input startAdornment prop is not undefined, input's label will stay above the input.
155          * If there is not items, we want the label to go back to placeholder position.
156          */
157         if (items.length === 0) {
158             return;
159         }
160
161         return items.map(
162             (item, index) =>
163                 <Chip
164                     label={this.renderChipValue(item)}
165                     key={index}
166                     onDelete={() => onDelete ? onDelete(item, index) : undefined} />
167         );
168     }
169
170     renderChipValue(value: Value) {
171         const { renderChipValue } = this.props;
172         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
173     }
174
175     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
176         event.preventDefault();
177     }
178
179     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
180         if (event.target !== this.inputRef.current) {
181             this.setState({ suggestionsOpen: false });
182         }
183     }
184
185     handleSelect(suggestion: Suggestion) {
186         return () => {
187             const { onSelect = noop } = this.props;
188             const { current } = this.inputRef;
189             if (current) {
190                 current.focus();
191             }
192             onSelect(suggestion);
193         };
194     }
195
196     renderSuggestion(suggestion: Suggestion) {
197         const { renderSuggestion } = this.props;
198         return renderSuggestion
199             ? renderSuggestion(suggestion)
200             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
201     }
202
203     getSuggestionsWidth() {
204         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
205     }
206 }
207
208 type ChipClasses = 'root';
209
210 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
211     root: {
212         marginRight: theme.spacing.unit / 4,
213         height: theme.spacing.unit * 3,
214     }
215 });
216
217 const Chip = withStyles(chipStyles)(MuiChip);
218
219 type PopperClasses = 'root';
220
221 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
222     root: {
223         zIndex: theme.zIndex.modal,
224     }
225 });
226
227 const Popper = withStyles(popperStyles)(
228     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
229         <MuiPopper {...props} className={classes.root} />
230 );
231
232 type InputClasses = 'root';
233
234 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
235     root: {
236         display: 'flex',
237         flexWrap: 'wrap',
238     },
239     input: {
240         minWidth: '20%',
241         flex: 1,
242     },
243 });
244
245 const Input = withStyles(inputStyles)(MuiInput);
246
247 const Paper = withStyles({
248     root: {
249         maxHeight: '80vh',
250         overflowY: 'auto',
251     }
252 })(MuiPaper);