Merge branch 'master' of git.curoverse.com:arvados-workbench2 into 14503_keep_services
[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, 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                 <Paper onMouseDown={this.preventBlur}>
81                     <List dense style={{ width: this.getSuggestionsWidth() }}>
82                         {suggestions.map(
83                             (suggestion, index) =>
84                                 <ListItem button key={index} onClick={this.handleSelect(suggestion)}>
85                                     {this.renderSuggestion(suggestion)}
86                                 </ListItem>
87                         )}
88                     </List>
89                 </Paper>
90             </Popper>
91         );
92     }
93
94     handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
95         const { onFocus = noop } = this.props;
96         this.setState({ suggestionsOpen: true });
97         onFocus(event);
98     }
99
100     handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
101         setTimeout(() => {
102             const { onBlur = noop } = this.props;
103             this.setState({ suggestionsOpen: false });
104             onBlur(event);
105         });
106     }
107
108     handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
109         const { onCreate = noop } = this.props;
110         if (key === 'Enter' && this.props.value.length > 0) {
111             onCreate();
112         }
113     }
114
115     renderChips() {
116         const { items, onDelete } = this.props;
117         return items.map(
118             (item, index) =>
119                 <Chip
120                     label={this.renderChipValue(item)}
121                     key={index}
122                     onDelete={() => onDelete ? onDelete(item, index) : undefined} />
123         );
124     }
125
126     renderChipValue(value: Value) {
127         const { renderChipValue } = this.props;
128         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
129     }
130
131     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
132         event.preventDefault();
133     }
134
135     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
136         if (event.target !== this.inputRef.current) {
137             this.setState({ suggestionsOpen: false });
138         }
139     }
140
141     handleSelect(suggestion: Suggestion) {
142         return () => {
143             const { onSelect = noop } = this.props;
144             const { current } = this.inputRef;
145             if (current) {
146                 current.focus();
147             }
148             onSelect(suggestion);
149         };
150     }
151
152     renderSuggestion(suggestion: Suggestion) {
153         const { renderSuggestion } = this.props;
154         return renderSuggestion
155             ? renderSuggestion(suggestion)
156             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
157     }
158
159     getSuggestionsWidth() {
160         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
161     }
162 }
163
164 type ChipClasses = 'root';
165
166 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
167     root: {
168         marginRight: theme.spacing.unit / 4,
169         height: theme.spacing.unit * 3,
170     }
171 });
172
173 const Chip = withStyles(chipStyles)(MuiChip);
174
175 type PopperClasses = 'root';
176
177 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
178     root: {
179         zIndex: theme.zIndex.modal,
180     }
181 });
182
183 const Popper = withStyles(popperStyles)(
184     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
185         <MuiPopper {...props} className={classes.root} />
186 );
187
188 type InputClasses = 'root';
189
190 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
191     root: {
192         display: 'flex',
193         flexWrap: 'wrap',
194     },
195     input: {
196         minWidth: '20%',
197         flex: 1,
198     },
199 });
200
201 const Input = withStyles(inputStyles)(MuiInput);