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