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, Typography
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';
16 import { isGroup } from 'common/isGroup';
17 import { sortByKey } from 'common/objects';
19 export interface AutocompleteProps<Item, Suggestion> {
24 suggestions?: Suggestion[];
28 onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
29 onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
30 onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
31 onCreate?: () => void;
32 onDelete?: (item: Item, index: number) => void;
33 onSelect?: (suggestion: Suggestion) => void;
34 renderChipValue?: (item: Item) => string;
35 renderChipTooltip?: (item: Item) => string;
36 renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
37 category?: AutocompleteCat;
40 export enum AutocompleteCat {
44 export interface AutocompleteState {
45 suggestionsOpen: boolean;
46 selectedSuggestionIndex: number;
49 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
52 suggestionsOpen: false,
53 selectedSuggestionIndex: 0,
56 containerRef = React.createRef<HTMLDivElement>();
57 inputRef = React.createRef<HTMLInputElement>();
61 <RootRef rootRef={this.containerRef}>
62 <FormControl fullWidth error={this.props.error}>
65 {this.renderHelperText()}
66 {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()}
73 const { label } = this.props;
74 return label && <InputLabel>{label}</InputLabel>;
79 disabled={this.props.disabled}
80 autoFocus={this.props.autofocus}
81 inputRef={this.inputRef}
82 value={this.props.value}
83 startAdornment={this.renderChips()}
84 onFocus={this.handleFocus}
85 onBlur={this.handleBlur}
86 onChange={this.props.onChange}
87 onKeyPress={this.handleKeyPress}
88 onKeyDown={this.handleNavigationKeyPress}
93 return <FormHelperText>{this.props.helperText}</FormHelperText>;
97 const { suggestions = [] } = this.props;
100 open={this.isSuggestionBoxOpen()}
101 anchorEl={this.inputRef.current}
102 key={suggestions.length}>
103 <Paper onMouseDown={this.preventBlur}>
104 <List dense style={{ width: this.getSuggestionsWidth() }}>
106 (suggestion, index) =>
110 onClick={this.handleSelect(suggestion)}
111 selected={index === this.state.selectedSuggestionIndex}>
112 {this.renderSuggestion(suggestion)}
121 renderSharingSuggestions() {
122 const { suggestions = [] } = this.props;
123 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
124 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
128 open={this.isSuggestionBoxOpen()}
129 anchorEl={this.inputRef.current}
130 key={suggestions.length}>
131 <Paper onMouseDown={this.preventBlur}>
134 <List dense style={{ width: this.getSuggestionsWidth(), maxHeight: '8rem', overflowX: 'scroll' }}>
136 (suggestion, index) =>
139 id={`groups-${index}`}
140 key={`groups-${index}`}
141 onClick={this.handleSelect(suggestion)}>
142 {this.renderSuggestion(suggestion)}
145 </List> : <Typography variant="caption" style={{ padding: '0.5rem', fontStyle: 'italic' }}>no groups found</Typography>}
148 <List dense style={{ width: this.getSuggestionsWidth(), maxHeight: '8rem', overflowX: 'scroll' }}>
150 (suggestion, index) =>
153 id={`users-${index}`}
154 key={`users-${index}`}
155 onClick={this.handleSelect(suggestion)}>
156 {this.renderSuggestion(suggestion)}
159 </List> : <Typography variant="caption" style={{ padding: '0.5rem', fontStyle: 'italic' }}>no users found</Typography>}
165 isSuggestionBoxOpen() {
166 const { suggestions = [] } = this.props;
167 return this.state.suggestionsOpen && suggestions.length > 0;
170 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
171 const { onFocus = noop } = this.props;
172 this.setState({ suggestionsOpen: true });
176 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
178 const { onBlur = noop } = this.props;
179 this.setState({ suggestionsOpen: false });
184 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
185 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
186 const { selectedSuggestionIndex } = this.state;
187 if (event.key === 'Enter') {
188 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
189 // prevent form submissions when selecting a suggestion
190 event.preventDefault();
191 onSelect(suggestions[selectedSuggestionIndex]);
192 } else if (this.props.value.length > 0) {
198 handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
199 if (key === 'ArrowUp') {
200 this.updateSelectedSuggestionIndex(-1);
201 } else if (key === 'ArrowDown') {
202 this.updateSelectedSuggestionIndex(1);
206 updateSelectedSuggestionIndex(value: -1 | 1) {
207 const { suggestions = [] } = this.props;
208 this.setState(({ selectedSuggestionIndex }) => ({
209 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
214 const { items, onDelete } = this.props;
217 * If input startAdornment prop is not undefined, input's label will stay above the input.
218 * If there is not items, we want the label to go back to placeholder position.
219 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
221 if (items.length === 0) {
227 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
228 if (tooltip && tooltip.length) {
229 return <span key={index}>
230 <Tooltip title={tooltip}>
232 label={this.renderChipValue(item)}
234 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
237 return <span key={index}><Chip
238 label={this.renderChipValue(item)}
239 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
245 renderChipValue(value: Value) {
246 const { renderChipValue } = this.props;
247 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
250 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
251 event.preventDefault();
254 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
255 if (event.target !== this.inputRef.current) {
256 this.setState({ suggestionsOpen: false });
260 handleSelect(suggestion: Suggestion) {
262 const { onSelect = noop } = this.props;
263 const { current } = this.inputRef;
267 onSelect(suggestion);
271 renderSuggestion(suggestion: Suggestion) {
272 const { renderSuggestion } = this.props;
273 return renderSuggestion
274 ? renderSuggestion(suggestion)
275 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
278 getSuggestionsWidth() {
279 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
283 type ChipClasses = 'root';
285 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
287 marginRight: theme.spacing.unit / 4,
288 height: theme.spacing.unit * 3,
292 const Chip = withStyles(chipStyles)(MuiChip);
294 type PopperClasses = 'root';
296 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
298 zIndex: theme.zIndex.modal,
302 const Popper = withStyles(popperStyles)(
303 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
304 <MuiPopper {...props} className={classes.root} />
307 type InputClasses = 'root';
309 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
320 const Input = withStyles(inputStyles)(MuiInput);
322 const Paper = withStyles({