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