1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import * as React from 'react';
11 FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText
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> {
21 suggestions?: Suggestion[];
25 onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
26 onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
27 onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
28 onCreate?: () => void;
29 onDelete?: (item: Item, index: number) => void;
30 onSelect?: (suggestion: Suggestion) => void;
31 renderChipValue?: (item: Item) => string;
32 renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
35 export interface AutocompleteState {
36 suggestionsOpen: boolean;
37 selectedSuggestionIndex: number;
40 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
43 suggestionsOpen: false,
44 selectedSuggestionIndex: 0,
47 containerRef = React.createRef<HTMLDivElement>();
48 inputRef = React.createRef<HTMLInputElement>();
52 <RootRef rootRef={this.containerRef}>
53 <FormControl fullWidth error={this.props.error}>
56 {this.renderHelperText()}
57 {this.renderSuggestions()}
64 const { label } = this.props;
65 return label && <InputLabel>{label}</InputLabel>;
70 autoFocus={this.props.autofocus}
71 inputRef={this.inputRef}
72 value={this.props.value}
73 startAdornment={this.renderChips()}
74 onFocus={this.handleFocus}
75 onBlur={this.handleBlur}
76 onChange={this.props.onChange}
77 onKeyPress={this.handleKeyPress}
78 onKeyDown={this.handleNavigationKeyPress}
83 return <FormHelperText>{this.props.helperText}</FormHelperText>;
87 const { suggestions = [] } = this.props;
90 open={this.isSuggestionBoxOpen()}
91 anchorEl={this.inputRef.current}
92 key={suggestions.length}>
93 <Paper onMouseDown={this.preventBlur}>
94 <List dense style={{ width: this.getSuggestionsWidth() }}>
96 (suggestion, index) =>
100 onClick={this.handleSelect(suggestion)}
101 selected={index === this.state.selectedSuggestionIndex}>
102 {this.renderSuggestion(suggestion)}
111 isSuggestionBoxOpen() {
112 const { suggestions = [] } = this.props;
113 return this.state.suggestionsOpen && suggestions.length > 0;
116 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
117 const { onFocus = noop } = this.props;
118 this.setState({ suggestionsOpen: true });
122 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
124 const { onBlur = noop } = this.props;
125 this.setState({ suggestionsOpen: false });
130 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
131 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
132 const { selectedSuggestionIndex } = this.state;
133 if (event.key === 'Enter') {
134 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
135 // prevent form submissions when selecting a suggestion
136 event.preventDefault();
137 onSelect(suggestions[selectedSuggestionIndex]);
138 } else if (this.props.value.length > 0) {
144 handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
145 if (key === 'ArrowUp') {
146 this.updateSelectedSuggestionIndex(-1);
147 } else if (key === 'ArrowDown') {
148 this.updateSelectedSuggestionIndex(1);
152 updateSelectedSuggestionIndex(value: -1 | 1) {
153 const { suggestions = [] } = this.props;
154 this.setState(({ selectedSuggestionIndex }) => ({
155 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
160 const { items, onDelete } = this.props;
163 * If input startAdornment prop is not undefined, input's label will stay above the input.
164 * If there is not items, we want the label to go back to placeholder position.
165 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
167 if (items.length === 0) {
174 label={this.renderChipValue(item)}
176 onDelete={() => onDelete ? onDelete(item, index) : undefined} />
180 renderChipValue(value: Value) {
181 const { renderChipValue } = this.props;
182 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
185 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
186 event.preventDefault();
189 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
190 if (event.target !== this.inputRef.current) {
191 this.setState({ suggestionsOpen: false });
195 handleSelect(suggestion: Suggestion) {
197 const { onSelect = noop } = this.props;
198 const { current } = this.inputRef;
202 onSelect(suggestion);
206 renderSuggestion(suggestion: Suggestion) {
207 const { renderSuggestion } = this.props;
208 return renderSuggestion
209 ? renderSuggestion(suggestion)
210 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
213 getSuggestionsWidth() {
214 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
218 type ChipClasses = 'root';
220 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
222 marginRight: theme.spacing.unit / 4,
223 height: theme.spacing.unit * 3,
227 const Chip = withStyles(chipStyles)(MuiChip);
229 type PopperClasses = 'root';
231 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
233 zIndex: theme.zIndex.modal,
237 const Popper = withStyles(popperStyles)(
238 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
239 <MuiPopper {...props} className={classes.root} />
242 type InputClasses = 'root';
244 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
255 const Input = withStyles(inputStyles)(MuiInput);
257 const Paper = withStyles({