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