Force suggestions box to update position when items amount changes
[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 }
31 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
32
33     state = {
34         suggestionsOpen: false,
35     };
36
37     containerRef = React.createRef<HTMLDivElement>();
38     inputRef = React.createRef<HTMLInputElement>();
39
40     render() {
41         return (
42             <RootRef rootRef={this.containerRef}>
43                 <FormControl fullWidth error={this.props.error}>
44                     {this.renderLabel()}
45                     {this.renderInput()}
46                     {this.renderHelperText()}
47                     {this.renderSuggestions()}
48                 </FormControl>
49             </RootRef>
50         );
51     }
52
53     renderLabel() {
54         const { label } = this.props;
55         return label && <InputLabel>{label}</InputLabel>;
56     }
57
58     renderInput() {
59         return <Input
60             inputRef={this.inputRef}
61             value={this.props.value}
62             startAdornment={this.renderChips()}
63             onFocus={this.handleFocus}
64             onBlur={this.handleBlur}
65             onChange={this.props.onChange}
66             onKeyPress={this.handleKeyPress}
67         />;
68     }
69
70     renderHelperText(){
71         return <FormHelperText>{this.props.helperText}</FormHelperText>;
72     }
73
74     renderSuggestions() {
75         const { suggestions = [] } = this.props;
76         return (
77             <Popper
78                 open={this.state.suggestionsOpen && suggestions.length > 0}
79                 anchorEl={this.inputRef.current}
80                 key={suggestions.length}>
81                 <Paper onMouseDown={this.preventBlur}>
82                     <List dense style={{ width: this.getSuggestionsWidth() }}>
83                         {suggestions.map(
84                             (suggestion, index) =>
85                                 <ListItem button key={index} onClick={this.handleSelect(suggestion)}>
86                                     {this.renderSuggestion(suggestion)}
87                                 </ListItem>
88                         )}
89                     </List>
90                 </Paper>
91             </Popper>
92         );
93     }
94
95     handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
96         const { onFocus = noop } = this.props;
97         this.setState({ suggestionsOpen: true });
98         onFocus(event);
99     }
100
101     handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
102         setTimeout(() => {
103             const { onBlur = noop } = this.props;
104             this.setState({ suggestionsOpen: false });
105             onBlur(event);
106         });
107     }
108
109     handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
110         const { onCreate = noop } = this.props;
111         if (key === 'Enter' && this.props.value.length > 0) {
112             onCreate();
113         }
114     }
115
116     renderChips() {
117         const { items, onDelete } = this.props;
118         return items.map(
119             (item, index) =>
120                 <Chip
121                     label={this.renderChipValue(item)}
122                     key={index}
123                     onDelete={() => onDelete ? onDelete(item, index) : undefined} />
124         );
125     }
126
127     renderChipValue(value: Value) {
128         const { renderChipValue } = this.props;
129         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
130     }
131
132     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
133         event.preventDefault();
134     }
135
136     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
137         if (event.target !== this.inputRef.current) {
138             this.setState({ suggestionsOpen: false });
139         }
140     }
141
142     handleSelect(suggestion: Suggestion) {
143         return () => {
144             const { onSelect = noop } = this.props;
145             const { current } = this.inputRef;
146             if (current) {
147                 current.focus();
148             }
149             onSelect(suggestion);
150         };
151     }
152
153     renderSuggestion(suggestion: Suggestion) {
154         const { renderSuggestion } = this.props;
155         return renderSuggestion
156             ? renderSuggestion(suggestion)
157             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
158     }
159
160     getSuggestionsWidth() {
161         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
162     }
163 }
164
165 type ChipClasses = 'root';
166
167 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
168     root: {
169         marginRight: theme.spacing.unit / 4,
170         height: theme.spacing.unit * 3,
171     }
172 });
173
174 const Chip = withStyles(chipStyles)(MuiChip);
175
176 type PopperClasses = 'root';
177
178 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
179     root: {
180         zIndex: theme.zIndex.modal,
181     }
182 });
183
184 const Popper = withStyles(popperStyles)(
185     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
186         <MuiPopper {...props} className={classes.root} />
187 );
188
189 type InputClasses = 'root';
190
191 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
192     root: {
193         display: 'flex',
194         flexWrap: 'wrap',
195     },
196     input: {
197         minWidth: '20%',
198         flex: 1,
199     },
200 });
201
202 const Input = withStyles(inputStyles)(MuiInput);
203
204 const Paper = withStyles({
205     root: {
206         maxHeight: '80vh',
207         overflowY: 'auto',
208     }
209 })(MuiPaper);