1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React from 'react';
11 FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip
12 } from '@material-ui/core';
13 import { PopperProps } from '@material-ui/core/Popper';
14 import { WithStyles } from '@material-ui/core/styles';
15 import { noop } from 'lodash';
17 export interface AutocompleteProps<Item, Suggestion> {
22 suggestions?: Suggestion[];
26 onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
27 onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
28 onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
29 onCreate?: () => void;
30 onDelete?: (item: Item, index: number) => void;
31 onSelect?: (suggestion: Suggestion) => void;
32 renderChipValue?: (item: Item) => string;
33 renderChipTooltip?: (item: Item) => string;
34 renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
37 export interface AutocompleteState {
38 suggestionsOpen: boolean;
39 selectedSuggestionIndex: number;
42 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
45 suggestionsOpen: false,
46 selectedSuggestionIndex: 0,
49 containerRef = React.createRef<HTMLDivElement>();
50 inputRef = React.createRef<HTMLInputElement>();
54 <RootRef rootRef={this.containerRef}>
55 <FormControl fullWidth error={this.props.error}>
58 {this.renderHelperText()}
59 {this.renderSuggestions()}
66 const { label } = this.props;
67 return label && <InputLabel>{label}</InputLabel>;
72 disabled={this.props.disabled}
73 autoFocus={this.props.autofocus}
74 inputRef={this.inputRef}
75 value={this.props.value}
76 startAdornment={this.renderChips()}
77 onFocus={this.handleFocus}
78 onBlur={this.handleBlur}
79 onChange={this.props.onChange}
80 onKeyPress={this.handleKeyPress}
81 onKeyDown={this.handleNavigationKeyPress}
86 return <FormHelperText>{this.props.helperText}</FormHelperText>;
90 const { suggestions = [] } = this.props;
93 open={this.isSuggestionBoxOpen()}
94 anchorEl={this.inputRef.current}
95 key={suggestions.length}>
96 <Paper onMouseDown={this.preventBlur}>
97 <List dense style={{ width: this.getSuggestionsWidth() }}>
99 (suggestion, index) =>
103 onClick={this.handleSelect(suggestion)}
104 selected={index === this.state.selectedSuggestionIndex}>
105 {this.renderSuggestion(suggestion)}
114 isSuggestionBoxOpen() {
115 const { suggestions = [] } = this.props;
116 return this.state.suggestionsOpen && suggestions.length > 0;
119 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
120 const { onFocus = noop } = this.props;
121 this.setState({ suggestionsOpen: true });
125 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
127 const { onBlur = noop } = this.props;
128 this.setState({ suggestionsOpen: false });
133 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
134 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
135 const { selectedSuggestionIndex } = this.state;
136 if (event.key === 'Enter') {
137 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
138 // prevent form submissions when selecting a suggestion
139 event.preventDefault();
140 onSelect(suggestions[selectedSuggestionIndex]);
141 } else if (this.props.value.length > 0) {
147 handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
148 if (key === 'ArrowUp') {
149 this.updateSelectedSuggestionIndex(-1);
150 } else if (key === 'ArrowDown') {
151 this.updateSelectedSuggestionIndex(1);
155 updateSelectedSuggestionIndex(value: -1 | 1) {
156 const { suggestions = [] } = this.props;
157 this.setState(({ selectedSuggestionIndex }) => ({
158 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
163 const { items, onDelete } = this.props;
166 * If input startAdornment prop is not undefined, input's label will stay above the input.
167 * If there is not items, we want the label to go back to placeholder position.
168 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
170 if (items.length === 0) {
176 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
177 if (tooltip && tooltip.length) {
178 return <span key={index}>
179 <Tooltip title={tooltip}>
181 label={this.renderChipValue(item)}
183 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
186 return <span key={index}><Chip
187 label={this.renderChipValue(item)}
188 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
194 renderChipValue(value: Value) {
195 const { renderChipValue } = this.props;
196 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
199 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
200 event.preventDefault();
203 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
204 if (event.target !== this.inputRef.current) {
205 this.setState({ suggestionsOpen: false });
209 handleSelect(suggestion: Suggestion) {
211 const { onSelect = noop } = this.props;
212 const { current } = this.inputRef;
216 onSelect(suggestion);
220 renderSuggestion(suggestion: Suggestion) {
221 const { renderSuggestion } = this.props;
222 return renderSuggestion
223 ? renderSuggestion(suggestion)
224 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
227 getSuggestionsWidth() {
228 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
232 type ChipClasses = 'root';
234 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
236 marginRight: theme.spacing.unit / 4,
237 height: theme.spacing.unit * 3,
241 const Chip = withStyles(chipStyles)(MuiChip);
243 type PopperClasses = 'root';
245 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
247 zIndex: theme.zIndex.modal,
251 const Popper = withStyles(popperStyles)(
252 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
253 <MuiPopper {...props} className={classes.root} />
256 type InputClasses = 'root';
258 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
269 const Input = withStyles(inputStyles)(MuiInput);
271 const Paper = withStyles({