-
Matiss Janis Aboltins authoredMatiss Janis Aboltins authored
Autocomplete.js 17.58 KiB
import React, { useState, useRef, useEffect, useMemo } from 'react';
import Downshift from 'downshift';
import { css } from 'glamor';
import Remove from '../../icons/v2/Remove';
import { colors } from '../../style';
import { View, Input, Tooltip, Button } from '../common';
const inst = {};
function findItem(strict, suggestions, value) {
if (strict) {
let idx = suggestions.findIndex(item => item.id === value);
return idx === -1 ? null : suggestions[idx];
}
return value;
}
function getItemName(item) {
if (item == null) {
return '';
} else if (typeof item === 'string') {
return item;
}
return item.name || '';
}
function getItemId(item) {
if (typeof item === 'string') {
return item;
}
return item ? item.id : null;
}
export function defaultFilterSuggestion(suggestion, value) {
return getItemName(suggestion).toLowerCase().includes(value.toLowerCase());
}
export function defaultFilterSuggestions(suggestions, value) {
return suggestions.filter(suggestion =>
defaultFilterSuggestion(suggestion, value),
);
}
function fireUpdate(onUpdate, strict, suggestions, index, value) {
// If the index is null, look up the id in the suggestions. If the
// value is empty it will select nothing (as expected). If it's not
// empty but nothing is selected, it still resolves to an id. It
// would very confusing otherwise: the menu could be in a state
// where nothing is highlighted but there is a valid value.
let selected = null;
if (!strict) {
selected = value;
} else {
if (index == null) {
// If passing in a value directly, validate the id
let sug = suggestions.find(sug => sug.id === value);
if (sug) {
selected = sug.id;
}
} else if (index < suggestions.length) {
selected = suggestions[index].id;
}
}
onUpdate && onUpdate(selected, value);
}
function defaultRenderInput(props) {
return <Input {...props} />;
}
function defaultRenderItems(items, getItemProps, highlightedIndex) {
return (
<div>
{items.map((item, index) => {
let name = getItemName(item);
return (
<div
{...getItemProps({ item })}
key={name}
{...css({
padding: 5,
cursor: 'default',
backgroundColor: highlightedIndex === index ? colors.n4 : null,
})}
>
{name}
</div>
);
})}
</div>
);
}
function defaultShouldSaveFromKey(e) {
return e.code === 'Enter';
}
function defaultItemToString(item) {
return item ? getItemName(item) : '';
}
function SingleAutocomplete({
focused,
embedded = false,
containerProps,
inputProps = {},
suggestions,
tooltipStyle,
tooltipProps,
renderInput = defaultRenderInput,
renderItems = defaultRenderItems,
itemToString = defaultItemToString,
shouldSaveFromKey = defaultShouldSaveFromKey,
filterSuggestions = defaultFilterSuggestions,
openOnFocus = true,
getHighlightedIndex,
highlightFirst,
onUpdate,
strict,
onSelect,
tableBehavior,
value: initialValue,
}) {
const [selectedItem, setSelectedItem] = useState(() =>
findItem(strict, suggestions, initialValue),
);
const [value, setValue] = useState(
selectedItem ? getItemName(selectedItem) : '',
);
const [isChanged, setIsChanged] = useState(false);
const [originalItem, setOriginalItem] = useState(selectedItem);
const filteredSuggestions = useMemo(
() => filterSuggestions(suggestions, value),
[filterSuggestions, suggestions, value],
);
const [highlightedIndex, setHighlightedIndex] = useState(null);
const [isOpen, setIsOpen] = useState(embedded);
// Update the selected item if the suggestion list or initial
// input value has changed
useEffect(() => {
setSelectedItem(findItem(strict, suggestions, initialValue));
}, [initialValue, suggestions, strict]);
function resetState(newValue) {
const val = newValue === undefined ? initialValue : newValue;
let selectedItem = findItem(strict, suggestions, val);
setSelectedItem(selectedItem);
setValue(selectedItem ? getItemName(selectedItem) : '');
setOriginalItem(selectedItem);
setHighlightedIndex(null);
setIsOpen(embedded);
setIsChanged(false);
}
function onSelectAfter() {
setValue('');
setSelectedItem(null);
setHighlightedIndex(null);
setIsChanged(false);
}
const filtered = isChanged ? filteredSuggestions || suggestions : suggestions;
return (
<Downshift
onSelect={(item, { inputValue }) => {
setSelectedItem(item);
setHighlightedIndex(null);
setIsOpen(false);
if (onSelect) {
// I AM NOT PROUD OF THIS OK??
// This WHOLE FILE is a mess anyway
// OK SIT DOWN AND I WILL EXPLAIN
// This component uses `componentWillReceiveProps` and in there
// it will re-filter suggestions if the suggestions change and
// a `highlightedIndex` exists. When we select something,
// we clear `highlightedIndex` so it should show all suggestions
// again. HOWEVER, in the case of a multi-autocomplete, it's
// changing the suggestions every time something is selected.
// In that case, cWRP is running *before* our state setting that
// cleared `highlightedIndex`. Forcing this to run later assures
// us that we will clear out local state before cWRP runs.
// YEAH THAT'S ALL OK I JUST WANT TO SHIP THIS
setTimeout(() => {
onSelect(getItemId(item), inputValue);
}, 0);
}
}}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem || null}
itemToString={itemToString}
inputValue={value}
isOpen={isOpen}
onInputValueChange={(value, changes) => {
// OMG this is the dumbest thing ever. I need to remove Downshift
// and build my own component. For some reason this is fired on blur
// with an empty value which clears out the input when the app blurs
if (!document.hasFocus()) {
return;
}
// Do nothing if it's simply updating the selected item
if (
changes.type ===
Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem
) {
return;
}
// Otherwise, filter the items and always the first item if
// desired
const filteredSuggestions = filterSuggestions(suggestions, value);
if (value === '') {
// A blank value shouldn't highlight any item so that the field
// can be left blank if desired
if (changes.type !== Downshift.stateChangeTypes.clickItem) {
fireUpdate(onUpdate, strict, filteredSuggestions, null, null);
}
setHighlightedIndex(null);
} else {
let defaultGetHighlightedIndex = filteredSuggestions => {
return highlightFirst && filteredSuggestions.length ? 0 : null;
};
let highlightedIndex = (
getHighlightedIndex || defaultGetHighlightedIndex
)(filteredSuggestions);
if (changes.type !== Downshift.stateChangeTypes.clickItem) {
fireUpdate(
onUpdate,
strict,
filteredSuggestions,
highlightedIndex,
value,
);
}
setHighlightedIndex(highlightedIndex);
}
setValue(value);
setIsChanged(true);
}}
onStateChange={changes => {
if (
tableBehavior &&
changes.type === Downshift.stateChangeTypes.mouseUp
) {
return;
}
if ('highlightedIndex' in changes) {
setHighlightedIndex(changes.highlightedIndex);
}
if ('isOpen' in changes) {
setIsOpen(embedded ? true : changes.isOpen);
}
if ('selectedItem' in changes) {
setSelectedItem(changes.selectedItem);
}
// We only ever want to update the value if the user explicitly
// highlighted an item via the keyboard. It shouldn't change with
// mouseover; otherwise the user could accidentally hover over an
// item without realizing it and change the value.
if (
isOpen &&
(changes.type === Downshift.stateChangeTypes.keyDownArrowUp ||
changes.type === Downshift.stateChangeTypes.keyDownArrowDown)
) {
fireUpdate(
onUpdate,
strict,
filteredSuggestions || suggestions,
changes.highlightedIndex != null
? changes.highlightedIndex
: highlightedIndex,
value,
);
}
inst.lastChangeType = changes.type;
}}
>
{({
getInputProps,
getItemProps,
isOpen,
inputValue,
highlightedIndex,
}) => (
// Super annoying but it works best to return a div so we
// can't use a View here, but we can fake it be using the
// className
<div
className={'view ' + css({ display: 'flex' }).toString()}
{...containerProps}
>
{renderInput(
getInputProps({
focused,
...inputProps,
onFocus: e => {
inputProps.onFocus && inputProps.onFocus(e);
if (openOnFocus) {
setIsOpen(true);
}
},
onBlur: e => {
e.preventDownshiftDefault = true;
inputProps.onBlur && inputProps.onBlur(e);
if (!tableBehavior) {
if (e.target.value === '') {
onSelect && onSelect(null, e.target.value);
setSelectedItem(null);
setIsOpen(false);
return;
}
// If not using table behavior, reset the input on blur. Tables
// handle saving the value on blur.
let value = selectedItem ? getItemId(selectedItem) : null;
resetState(value);
} else {
setIsOpen(false);
}
},
onKeyDown: e => {
let { onKeyDown } = inputProps || {};
// If the dropdown is open, an item is highlighted, and the user
// pressed enter, always capture that and handle it ourselves
if (isOpen) {
if (e.key === 'Enter') {
if (highlightedIndex != null) {
if (
inst.lastChangeType ===
Downshift.stateChangeTypes.itemMouseEnter
) {
// If the last thing the user did was hover an item, intentionally
// ignore the default behavior of selecting the item. It's too
// common to accidentally hover an item and then save it
e.preventDefault();
} else {
// Otherwise, stop propagation so that the table navigator
// doesn't handle it
e.stopPropagation();
}
} else if (!strict) {
// Handle it ourselves
e.stopPropagation();
onSelect(value, e.target.value);
return onSelectAfter();
} else {
// No highlighted item, still allow the table to save the item
// as `null`, even though we're allowing the table to move
e.preventDefault();
onKeyDown && onKeyDown(e);
}
} else if (shouldSaveFromKey(e)) {
e.preventDefault();
onKeyDown && onKeyDown(e);
}
}
// Handle escape ourselves
if (e.key === 'Escape') {
e.preventDefault();
if (!embedded) {
e.stopPropagation();
}
fireUpdate(
onUpdate,
strict,
suggestions,
null,
getItemId(originalItem),
);
setValue(getItemName(originalItem));
setSelectedItem(findItem(strict, suggestions, originalItem));
setHighlightedIndex(null);
setIsOpen(embedded ? true : false);
}
},
onChange: e => {
const { onChange } = inputProps || {};
onChange && onChange(e.target.value);
},
}),
)}
{isOpen &&
filtered.length > 0 &&
(embedded ? (
<View style={{ marginTop: 5 }} data-testid="autocomplete">
{renderItems(
filtered,
getItemProps,
highlightedIndex,
inputValue,
)}
</View>
) : (
<Tooltip
position="bottom-stretch"
offset={2}
style={{
padding: 0,
backgroundColor: colors.n1,
color: 'white',
...tooltipStyle,
}}
{...tooltipProps}
data-testid="autocomplete"
>
{renderItems(
filtered,
getItemProps,
highlightedIndex,
inputValue,
)}
</Tooltip>
))}
</div>
)}
</Downshift>
);
}
function MultiItem({ name, onRemove }) {
return (
<View
style={{
alignItems: 'center',
flexDirection: 'row',
backgroundColor: colors.b9,
padding: '2px 4px',
margin: '2px',
borderRadius: 4,
}}
>
{name}
<Button type="button" bare style={{ marginLeft: 1 }} onClick={onRemove}>
<Remove style={{ width: 8, height: 8 }} />
</Button>
</View>
);
}
export function MultiAutocomplete({
value: selectedItems,
onSelect,
suggestions,
strict,
...props
}) {
let [focused, setFocused] = useState(false);
let lastSelectedItems = useRef();
useEffect(() => {
lastSelectedItems.current = selectedItems;
});
function onRemoveItem(id) {
let items = selectedItems.filter(i => i !== id);
onSelect(items);
}
function onAddItem(id) {
if (id) {
id = id.trim();
onSelect([...selectedItems, id], id);
}
}
function onKeyDown(e, prevOnKeyDown) {
if (e.key === 'Backspace' && e.target.value === '') {
onRemoveItem(selectedItems[selectedItems.length - 1]);
}
prevOnKeyDown && prevOnKeyDown(e);
}
return (
<Autocomplete
{...props}
value={null}
suggestions={suggestions.filter(
item => !selectedItems.includes(getItemId(item)),
)}
onSelect={onAddItem}
highlightFirst
strict={strict}
tooltipProps={{
forceLayout: lastSelectedItems.current !== selectedItems,
}}
renderInput={props => (
<View
style={[
{
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
borderRadius: 4,
border: '1px solid #d0d0d0',
},
focused && {
border: '1px solid ' + colors.b5,
boxShadow: '0 1px 1px ' + colors.b7,
},
]}
>
{selectedItems.map((item, idx) => {
item = findItem(strict, suggestions, item);
return (
item && (
<MultiItem
key={getItemId(item) || idx}
name={getItemName(item)}
onRemove={() => onRemoveItem(getItemId(item))}
/>
)
);
})}
<Input
{...props}
onKeyDown={e => onKeyDown(e, props.onKeyDown)}
onFocus={e => {
setFocused(true);
props.onFocus(e);
}}
onBlur={e => {
setFocused(false);
props.onBlur(e);
}}
style={[
{
flex: 1,
minWidth: 30,
border: 0,
':focus': { border: 0, boxShadow: 'none' },
},
props.style,
]}
/>
</View>
)}
/>
);
}
export function AutocompleteFooterButton({
title,
style,
hoveredStyle,
onClick,
}) {
return (
<Button
style={[
{
fontSize: 12,
color: colors.n10,
backgroundColor: 'transparent',
borderColor: colors.n5,
},
style,
]}
hoveredStyle={[
{ backgroundColor: 'rgba(200, 200, 200, .25)' },
hoveredStyle,
]}
onClick={onClick}
>
{title}
</Button>
);
}
export function AutocompleteFooter({ show = true, embedded, children }) {
return (
show && (
<View
style={[
{ flexShrink: 0 },
embedded ? { paddingTop: 5 } : { padding: 5 },
]}
onMouseDown={e => e.preventDefault()}
>
{children}
</View>
)
);
}
export default function Autocomplete({ multi, ...props }) {
if (multi) {
return <MultiAutocomplete {...props} />;
} else {
return <SingleAutocomplete {...props} />;
}
}