Merge branch 'master' into 14603-add-controlled-vocabulary-to-advanced-search
[arvados-workbench2.git] / src / components / autocomplete / autocomplete.tsx
index e2f354747c0059a3d7e028d83a04cea2f5cf4f09..52918c34ab47ee68c009cff6bd5bfdb42b80598b 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, Menu, MenuItem, ListItemText, ListItem, List } from '@material-ui/core';
+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';
 import { PopperProps } from '@material-ui/core/Popper';
 import { WithStyles } from '@material-ui/core/styles';
 import { noop } from 'lodash';
@@ -13,6 +13,9 @@ export interface AutocompleteProps<Item, Suggestion> {
     value: string;
     items: Item[];
     suggestions?: Suggestion[];
+    error?: boolean;
+    helperText?: string;
+    autofocus?: boolean;
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
@@ -25,21 +28,26 @@ export interface AutocompleteProps<Item, Suggestion> {
 
 export interface AutocompleteState {
     suggestionsOpen: boolean;
+    selectedSuggestionIndex: number;
 }
+
 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
 
     state = {
         suggestionsOpen: false,
+        selectedSuggestionIndex: 0,
     };
 
     containerRef = React.createRef<HTMLDivElement>();
     inputRef = React.createRef<HTMLInputElement>();
+
     render() {
         return (
             <RootRef rootRef={this.containerRef}>
-                <FormControl fullWidth>
+                <FormControl fullWidth error={this.props.error}>
                     {this.renderLabel()}
                     {this.renderInput()}
+                    {this.renderHelperText()}
                     {this.renderSuggestions()}
                 </FormControl>
             </RootRef>
@@ -53,6 +61,7 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
 
     renderInput() {
         return <Input
+            autoFocus={this.props.autofocus}
             inputRef={this.inputRef}
             value={this.props.value}
             startAdornment={this.renderChips()}
@@ -60,9 +69,44 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
             onBlur={this.handleBlur}
             onChange={this.props.onChange}
             onKeyPress={this.handleKeyPress}
+            onKeyDown={this.handleNavigationKeyPress}
         />;
     }
 
+    renderHelperText() {
+        return <FormHelperText>{this.props.helperText}</FormHelperText>;
+    }
+
+    renderSuggestions() {
+        const { suggestions = [] } = this.props;
+        return (
+            <Popper
+                open={this.isSuggestionBoxOpen()}
+                anchorEl={this.inputRef.current}
+                key={suggestions.length}>
+                <Paper onMouseDown={this.preventBlur}>
+                    <List dense style={{ width: this.getSuggestionsWidth() }}>
+                        {suggestions.map(
+                            (suggestion, index) =>
+                                <ListItem
+                                    button
+                                    key={index}
+                                    onClick={this.handleSelect(suggestion)}
+                                    selected={index === this.state.selectedSuggestionIndex}>
+                                    {this.renderSuggestion(suggestion)}
+                                </ListItem>
+                        )}
+                    </List>
+                </Paper>
+            </Popper>
+        );
+    }
+
+    isSuggestionBoxOpen() {
+        const { suggestions = [] } = this.props;
+        return this.state.suggestionsOpen && suggestions.length > 0;
+    }
+
     handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
         const { onFocus = noop } = this.props;
         this.setState({ suggestionsOpen: true });
@@ -70,20 +114,53 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
     }
 
     handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
-        const { onBlur = noop } = this.props;
-        this.setState({ suggestionsOpen: true });
-        onBlur(event);
+        setTimeout(() => {
+            const { onBlur = noop } = this.props;
+            this.setState({ suggestionsOpen: false });
+            onBlur(event);
+        });
+    }
+
+    handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
+        const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
+        const { selectedSuggestionIndex } = this.state;
+        if (event.key === 'Enter') {
+            if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
+                // prevent form submissions when selecting a suggestion
+                event.preventDefault();
+                onSelect(suggestions[selectedSuggestionIndex]);
+            } else if (this.props.value.length > 0) {
+                onCreate();
+            }
+        }
     }
 
-    handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
-        const { onCreate = noop } = this.props;
-        if (key === 'Enter') {
-            onCreate();
+    handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
+        if (key === 'ArrowUp') {
+            this.updateSelectedSuggestionIndex(-1);
+        } else if (key === 'ArrowDown') {
+            this.updateSelectedSuggestionIndex(1);
         }
     }
 
+    updateSelectedSuggestionIndex(value: -1 | 1) {
+        const { suggestions = [] } = this.props;
+        this.setState(({ selectedSuggestionIndex }) => ({
+            selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
+        }));
+    }
+
     renderChips() {
         const { items, onDelete } = this.props;
+
+        /**
+         * If input startAdornment prop is not undefined, input's label will stay above the input.
+         * If there is not items, we want the label to go back to placeholder position.
+         */
+        if (items.length === 0) {
+            return;
+        }
+
         return items.map(
             (item, index) =>
                 <Chip
@@ -98,24 +175,14 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
     }
 
-    renderSuggestions() {
-        const { suggestions } = this.props;
-        return suggestions && suggestions.length > 0
-            ? <Popper
-                open={this.state.suggestionsOpen}
-                anchorEl={this.containerRef.current}>
-                <Paper>
-                    <List dense style={{ width: this.getSuggestionsWidth() }}>
-                        {suggestions.map(
-                            (suggestion, index) =>
-                                <ListItem button key={index} onClick={this.handleSelect(suggestion)}>
-                                    {this.renderSuggestion(suggestion)}
-                                </ListItem>
-                        )}
-                    </List>
-                </Paper>
-            </Popper>
-            : null;
+    preventBlur = (event: React.MouseEvent<HTMLElement>) => {
+        event.preventDefault();
+    }
+
+    handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
+        if (event.target !== this.inputRef.current) {
+            this.setState({ suggestionsOpen: false });
+        }
     }
 
     handleSelect(suggestion: Suggestion) {
@@ -179,3 +246,10 @@ const inputStyles: StyleRulesCallback<InputClasses> = () => ({
 });
 
 const Input = withStyles(inputStyles)(MuiInput);
+
+const Paper = withStyles({
+    root: {
+        maxHeight: '80vh',
+        overflowY: 'auto',
+    }
+})(MuiPaper);