// 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, 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';
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;
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>();
render() {
return (
<RootRef rootRef={this.containerRef}>
- <FormControl fullWidth>
+ <FormControl fullWidth error={this.props.error}>
{this.renderLabel()}
{this.renderInput()}
+ {this.renderHelperText()}
{this.renderSuggestions()}
</FormControl>
</RootRef>
renderInput() {
return <Input
+ autoFocus={this.props.autofocus}
inputRef={this.inputRef}
value={this.props.value}
startAdornment={this.renderChips()}
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.state.suggestionsOpen && suggestions.length > 0}
- anchorEl={this.containerRef.current}>
+ 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)}>
+ <ListItem
+ button
+ key={index}
+ onClick={this.handleSelect(suggestion)}
+ selected={index === this.state.selectedSuggestionIndex}>
{this.renderSuggestion(suggestion)}
</ListItem>
)}
);
}
+ 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 });
});
}
- handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
- const { onCreate = noop } = this.props;
- if (key === 'Enter' && this.props.value.length > 0) {
- onCreate();
+ 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();
+ }
+ }
+ }
+
+ 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.
+ * That why we return without a value instead of returning a result of a _map_ which is an empty array.
+ */
+ if (items.length === 0) {
+ return;
+ }
+
return items.map(
(item, index) =>
<Chip
});
const Input = withStyles(inputStyles)(MuiInput);
+
+const Paper = withStyles({
+ root: {
+ maxHeight: '80vh',
+ overflowY: 'auto',
+ }
+})(MuiPaper);