1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
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';
11 export interface AutocompleteProps<Item, Suggestion> {
15 suggestions?: Suggestion[];
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;
29 export interface AutocompleteState {
30 suggestionsOpen: boolean;
31 selectedSuggestionIndex: number;
34 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
37 suggestionsOpen: false,
38 selectedSuggestionIndex: 0,
41 containerRef = React.createRef<HTMLDivElement>();
42 inputRef = React.createRef<HTMLInputElement>();
46 <RootRef rootRef={this.containerRef}>
47 <FormControl fullWidth error={this.props.error}>
50 {this.renderHelperText()}
51 {this.renderSuggestions()}
58 const { label } = this.props;
59 return label && <InputLabel>{label}</InputLabel>;
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}
77 return <FormHelperText>{this.props.helperText}</FormHelperText>;
81 const { suggestions = [] } = this.props;
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() }}>
90 (suggestion, index) =>
94 onClick={this.handleSelect(suggestion)}
95 selected={index === this.state.selectedSuggestionIndex}>
96 {this.renderSuggestion(suggestion)}
105 isSuggestionBoxOpen() {
106 const { suggestions = [] } = this.props;
107 return this.state.suggestionsOpen && suggestions.length > 0;
110 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
111 const { onFocus = noop } = this.props;
112 this.setState({ suggestionsOpen: true });
116 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
118 const { onBlur = noop } = this.props;
119 this.setState({ suggestionsOpen: false });
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) {
138 handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
139 if (key === 'ArrowUp') {
140 this.updateSelectedSuggestionIndex(-1);
141 } else if (key === 'ArrowDown') {
142 this.updateSelectedSuggestionIndex(1);
146 updateSelectedSuggestionIndex(value: -1 | 1) {
147 const { suggestions = [] } = this.props;
148 this.setState(({ selectedSuggestionIndex }) => ({
149 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
154 const { items, onDelete } = this.props;
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 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
161 if (items.length === 0) {
168 label={this.renderChipValue(item)}
170 onDelete={() => onDelete ? onDelete(item, index) : undefined} />
174 renderChipValue(value: Value) {
175 const { renderChipValue } = this.props;
176 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
179 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
180 event.preventDefault();
183 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
184 if (event.target !== this.inputRef.current) {
185 this.setState({ suggestionsOpen: false });
189 handleSelect(suggestion: Suggestion) {
191 const { onSelect = noop } = this.props;
192 const { current } = this.inputRef;
196 onSelect(suggestion);
200 renderSuggestion(suggestion: Suggestion) {
201 const { renderSuggestion } = this.props;
202 return renderSuggestion
203 ? renderSuggestion(suggestion)
204 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
207 getSuggestionsWidth() {
208 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
212 type ChipClasses = 'root';
214 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
216 marginRight: theme.spacing.unit / 4,
217 height: theme.spacing.unit * 3,
221 const Chip = withStyles(chipStyles)(MuiChip);
223 type PopperClasses = 'root';
225 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
227 zIndex: theme.zIndex.modal,
231 const Popper = withStyles(popperStyles)(
232 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
233 <MuiPopper {...props} className={classes.root} />
236 type InputClasses = 'root';
238 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
249 const Input = withStyles(inputStyles)(MuiInput);
251 const Paper = withStyles({