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