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';
18 export interface AutocompleteProps<Item, Suggestion> {
23 suggestions?: Suggestion[];
27 onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
28 onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
29 onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
30 onCreate?: () => void;
31 onDelete?: (item: Item, index: number) => void;
32 onSelect?: (suggestion: Suggestion) => void;
33 renderChipValue?: (item: Item) => string;
34 renderChipTooltip?: (item: Item) => string;
35 renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
36 category?: AutocompleteCat;
39 export enum AutocompleteCat {
43 export interface AutocompleteState {
44 suggestionsOpen: boolean;
45 selectedSuggestionIndex: number;
48 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
51 suggestionsOpen: false,
52 selectedSuggestionIndex: 0,
55 containerRef = React.createRef<HTMLDivElement>();
56 inputRef = React.createRef<HTMLInputElement>();
58 componentDidUpdate( prevProps: Readonly<AutocompleteProps<Value, Suggestion>>, prevState: Readonly<AutocompleteState>, snapshot?: any ): void {
59 if (prevState.selectedSuggestionIndex !== this.state.selectedSuggestionIndex) {
60 document.getElementById(`users-${this.state.selectedSuggestionIndex}`)?.scrollIntoView({ block: 'nearest' });
66 <RootRef rootRef={this.containerRef}>
67 <FormControl fullWidth error={this.props.error}>
70 {this.renderHelperText()}
71 {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()}
78 const { label } = this.props;
79 return label && <InputLabel>{label}</InputLabel>;
84 disabled={this.props.disabled}
85 autoFocus={this.props.autofocus}
86 inputRef={this.inputRef}
87 value={this.props.value}
88 startAdornment={this.renderChips()}
89 onFocus={this.handleFocus}
90 onBlur={this.handleBlur}
91 onChange={this.props.onChange}
92 onKeyPress={this.handleKeyPress}
93 onKeyDown={this.handleNavigationKeyPress}
98 return <FormHelperText>{this.props.helperText}</FormHelperText>;
101 renderSuggestions() {
102 const { suggestions = [] } = this.props;
105 open={this.isSuggestionBoxOpen()}
106 anchorEl={this.inputRef.current}
107 key={suggestions.length}>
108 <Paper onMouseDown={this.preventBlur}>
109 <List dense style={{ width: this.getSuggestionsWidth() }}>
111 (suggestion, index) =>
115 onClick={this.handleSelect(suggestion)}
116 selected={index === this.state.selectedSuggestionIndex}>
117 {this.renderSuggestion(suggestion)}
126 renderSharingSuggestions() {
127 const { suggestions = [] } = this.props;
128 const groups = suggestions.filter(item => isGroup(item));
129 const users = suggestions.filter(item => !isGroup(item));
133 open={this.isSuggestionBoxOpen()}
134 anchorEl={this.inputRef.current}
135 key={suggestions.length}>
136 <Paper onMouseDown={this.preventBlur}>
139 <List dense style={{ width: this.getSuggestionsWidth(), maxHeight: '8rem', overflowX: 'scroll' }}>
141 (suggestion, index) =>
144 id={`users-${index}`}
145 key={`users-${index}`}
146 onClick={this.handleSelect(suggestion)}
147 selected={index === this.state.selectedSuggestionIndex}>
148 {this.renderSuggestion(suggestion)}
151 </List> : <Typography variant="caption" style={{ padding: '0.5rem', fontStyle: 'italic' }}>no users found</Typography>}
154 <List dense style={{ width: this.getSuggestionsWidth(), maxHeight: '8rem', overflowX: 'scroll' }}>
156 (suggestion, index) =>
159 id={`groups-${index}`}
160 key={`groups-${index}`}
161 onClick={this.handleSelect(suggestion)}>
162 {this.renderSuggestion(suggestion)}
165 </List> : <Typography variant="caption" style={{ padding: '0.5rem', fontStyle: 'italic' }}>no groups found</Typography>}
171 isSuggestionBoxOpen() {
172 const { suggestions = [] } = this.props;
173 return this.state.suggestionsOpen && suggestions.length > 0;
176 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
177 const { onFocus = noop } = this.props;
178 this.setState({ suggestionsOpen: true });
182 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
184 const { onBlur = noop } = this.props;
185 this.setState({ suggestionsOpen: false });
190 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
191 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
192 const { selectedSuggestionIndex } = this.state;
193 if (event.key === 'Enter') {
194 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
195 // prevent form submissions when selecting a suggestion
196 event.preventDefault();
197 onSelect(suggestions[selectedSuggestionIndex]);
198 } else if (this.props.value.length > 0) {
204 handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
205 if (key === 'ArrowUp') {
206 this.updateSelectedSuggestionIndex(-1);
207 } else if (key === 'ArrowDown') {
208 this.updateSelectedSuggestionIndex(1);
212 updateSelectedSuggestionIndex(value: -1 | 1) {
213 const { suggestions = [] } = this.props;
214 this.setState(({ selectedSuggestionIndex }) => ({
215 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
220 const { items, onDelete } = this.props;
223 * If input startAdornment prop is not undefined, input's label will stay above the input.
224 * If there is not items, we want the label to go back to placeholder position.
225 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
227 if (items.length === 0) {
233 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
234 if (tooltip && tooltip.length) {
235 return <span key={index}>
236 <Tooltip title={tooltip}>
238 label={this.renderChipValue(item)}
240 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
243 return <span key={index}><Chip
244 label={this.renderChipValue(item)}
245 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
251 renderChipValue(value: Value) {
252 const { renderChipValue } = this.props;
253 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
256 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
257 event.preventDefault();
260 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
261 if (event.target !== this.inputRef.current) {
262 this.setState({ suggestionsOpen: false });
266 handleSelect(suggestion: Suggestion) {
268 const { onSelect = noop } = this.props;
269 const { current } = this.inputRef;
273 onSelect(suggestion);
277 renderSuggestion(suggestion: Suggestion) {
278 const { renderSuggestion } = this.props;
279 return renderSuggestion
280 ? renderSuggestion(suggestion)
281 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
284 getSuggestionsWidth() {
285 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
289 type ChipClasses = 'root';
291 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
293 marginRight: theme.spacing.unit / 4,
294 height: theme.spacing.unit * 3,
298 const Chip = withStyles(chipStyles)(MuiChip);
300 type PopperClasses = 'root';
302 const popperStyles: StyleRulesCallback<ChipClasses> = theme => ({
304 zIndex: theme.zIndex.modal,
308 const Popper = withStyles(popperStyles)(
309 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
310 <MuiPopper {...props} className={classes.root} />
313 type InputClasses = 'root';
315 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
326 const Input = withStyles(inputStyles)(MuiInput);
328 const Paper = withStyles({