Skip to content
Snippets Groups Projects
Autocomplete.js 18.4 KiB
Newer Older
  • Learn to ignore specific revisions
  • James Long's avatar
    James Long committed
    import React, { useState, useRef, useEffect } from 'react';
    
    James Long's avatar
    James Long committed
    import lively from '@jlongster/lively';
    import Downshift from 'downshift';
    import { css } from 'glamor';
    
    James Long's avatar
    James Long committed
    import { colors } from '../style';
    import Remove from '../svg/v2/Remove';
    
    James Long's avatar
    James Long committed
    import { View, Input, Tooltip, Button } from './common';
    
    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;
    }
    
    function getInitialState({
      props: {
        value,
        suggestions,
        embedded,
        isOpen = false,
        strict,
    
        initialFilterSuggestions,
      },
    
    James Long's avatar
    James Long committed
    }) {
      let selectedItem = findItem(strict, suggestions, value);
      let filteredSuggestions = initialFilterSuggestions
        ? initialFilterSuggestions(suggestions, value)
        : null;
    
      return {
        selectedItem,
        value: selectedItem ? getItemName(selectedItem) : '',
        originalItem: selectedItem,
        filteredSuggestions,
        highlightedIndex: null,
    
        isOpen: embedded || isOpen,
    
    James Long's avatar
    James Long committed
      };
    }
    
    function componentWillReceiveProps(bag, nextProps) {
      let {
        strict,
        suggestions,
        filterSuggestions = defaultFilterSuggestions,
        initialFilterSuggestions,
        value,
    
        itemToString = defaultItemToString,
    
    James Long's avatar
    James Long committed
      } = nextProps;
      let { value: currValue } = bag.state;
      let updates = null;
    
      function updateValue() {
        let selectedItem = findItem(strict, suggestions, value);
        if (selectedItem) {
          updates = updates || {};
          updates.value = itemToString(selectedItem);
          updates.selectedItem = selectedItem;
        }
      }
    
      if (bag.props.value !== value) {
        updateValue();
      }
    
      // TODO: Something is causing a rerender immediately after first
      // render, and this condition is true, causing items to be filtered
      // twice. This shouldn't effect functionality (I think), but look
      // into this later
      if (bag.props.suggestions !== suggestions) {
        let filteredSuggestions = null;
    
        if (bag.state.highlightedIndex != null) {
          filteredSuggestions = filterSuggestions(suggestions, currValue);
        } else {
          filteredSuggestions = initialFilterSuggestions
            ? initialFilterSuggestions(suggestions, currValue)
            : null;
        }
    
        updates = updates || {};
        updateValue();
        updates.filteredSuggestions = filteredSuggestions;
      }
    
      return updates;
    }
    
    export function defaultFilterSuggestion(suggestion, value) {
    
      return getItemName(suggestion).toLowerCase().includes(value.toLowerCase());
    
    James Long's avatar
    James Long committed
    }
    
    export function defaultFilterSuggestions(suggestions, value) {
      return suggestions.filter(suggestion =>
    
        defaultFilterSuggestion(suggestion, value),
    
    James Long's avatar
    James Long committed
      );
    }
    
    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);
    }
    
    function onInputValueChange(
      {
        props: {
          suggestions,
          onUpdate,
          multi,
          highlightFirst,
          strict,
          filterSuggestions = defaultFilterSuggestions,
    
    James Long's avatar
    James Long committed
        },
    
    James Long's avatar
    James Long committed
      },
      value,
    
    James Long's avatar
    James Long committed
    ) {
      // 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);
        }
    
        return {
          value,
          filteredSuggestions,
    
    James Long's avatar
    James Long committed
        };
      } else {
        let defaultGetHighlightedIndex = filteredSuggestions => {
          return highlightFirst && filteredSuggestions.length ? 0 : null;
        };
        let highlightedIndex = (getHighlightedIndex || defaultGetHighlightedIndex)(
    
    James Long's avatar
    James Long committed
        );
    
        if (changes.type !== Downshift.stateChangeTypes.clickItem) {
          fireUpdate(
            onUpdate,
            strict,
            filteredSuggestions,
            highlightedIndex,
    
    James Long's avatar
    James Long committed
          );
        }
    
        return {
          value,
          filteredSuggestions,
    
    James Long's avatar
    James Long committed
        };
      }
    }
    
    function onStateChange({ props, state, inst }, changes, stateAndHelpers) {
      if (
        props.tableBehavior &&
        changes.type === Downshift.stateChangeTypes.mouseUp
      ) {
        return;
      }
    
      const newState = {};
      if ('highlightedIndex' in changes) {
        newState.highlightedIndex = changes.highlightedIndex;
      }
      if ('isOpen' in changes) {
        newState.isOpen = props.embedded ? true : changes.isOpen;
      }
      if ('selectedItem' in changes) {
        newState.selectedItem = 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 (
        state.isOpen &&
        (changes.type === Downshift.stateChangeTypes.keyDownArrowUp ||
          changes.type === Downshift.stateChangeTypes.keyDownArrowDown)
      ) {
        fireUpdate(
          props.onUpdate,
          props.strict,
          state.filteredSuggestions || props.suggestions,
          newState.highlightedIndex != null
            ? newState.highlightedIndex
            : state.highlightedIndex,
    
    James Long's avatar
    James Long committed
        );
      }
    
      inst.lastChangeType = changes.type;
      return newState;
    }
    
    function onSelect(
      { props: { onSelect, clearAfterSelect, suggestions }, inst },
    
    James Long's avatar
    James Long committed
    ) {
      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));
        }, 0);
      }
      return onSelectAfter(suggestions, clearAfterSelect, inst);
    }
    
    function onSelectAfter(suggestions, clearAfterSelect, inst) {
      if (clearAfterSelect) {
        return {
          value: '',
          selectedItem: null,
          highlightedIndex: null,
    
          filteredSuggestions: suggestions,
    
    James Long's avatar
    James Long committed
        };
      } else if (inst.input) {
        inst.input.setSelectionRange(0, 10000);
      }
    }
    
    function onChange({ props: { inputProps } }, e) {
      const { onChange } = inputProps || {};
      onChange && onChange(e.target.value);
    }
    
    function onKeyDown(
      {
        props: {
          suggestions,
          clearAfterSelect,
          initialFilterSuggestions,
          embedded,
          onUpdate,
          onSelect,
          inputProps,
          shouldSaveFromKey = defaultShouldSaveFromKey,
    
    James Long's avatar
    James Long committed
        },
        state: {
          selectedItem,
          filteredSuggestions,
          highlightedIndex,
          originalItem,
          isNulled,
          isOpen,
    
    James Long's avatar
    James Long committed
        },
    
    James Long's avatar
    James Long committed
      },
    
    James Long's avatar
    James Long committed
    ) {
      let ENTER = 13;
      let ESC = 27;
      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.keyCode === 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);
            return onSelectAfter(suggestions, clearAfterSelect, inst);
          } 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.keyCode === ESC) {
        e.preventDefault();
    
        if (!embedded) {
          e.stopPropagation();
        }
    
        let filteredSuggestions = initialFilterSuggestions
          ? initialFilterSuggestions(suggestions, getItemName(originalItem))
          : null;
        fireUpdate(onUpdate, strict, suggestions, null, getItemId(originalItem));
        return {
          value: getItemName(originalItem),
          selectedItem: findItem(strict, suggestions, originalItem),
          filteredSuggestions,
          highlightedIndex: null,
    
          isOpen: embedded ? true : false,
    
    James Long's avatar
    James Long committed
        };
      }
    }
    
    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,
    
    James Long's avatar
    James Long committed
                })}
              >
                {name}
              </div>
            );
          })}
        </div>
      );
    }
    
    function defaultShouldSaveFromKey(e) {
      // Enter
      return e.keyCode === 13;
    }
    
    function onFocus({ inst, props: { inputProps = {}, openOnFocus = true } }, e) {
      inputProps.onFocus && inputProps.onFocus(e);
    
      if (openOnFocus) {
        return { isOpen: true };
      }
    }
    
    function onBlur({ inst, props, state: { selectedItem } }, e) {
      let { inputProps = {}, onSelect } = props;
    
      e.preventDownshiftDefault = true;
      inputProps.onBlur && inputProps.onBlur(e);
    
      if (!props.tableBehavior) {
        if (e.target.value === '') {
          onSelect && onSelect(null);
          return { selectedItem: null, originalValue: null, isOpen: false };
        }
    
        // If not using table behavior, reset the input on blur. Tables
        // handle saving the value on blur.
        let value = selectedItem ? getItemId(selectedItem) : null;
    
        return getInitialState({
          props: {
            ...props,
            value,
    
    James Long's avatar
    James Long committed
        });
      } else {
        return { isOpen: false };
      }
    }
    
    function defaultItemToString(item) {
      return item ? getItemName(item) : '';
    }
    
    function _SingleAutocomplete({
      props: {
        focused,
        embedded,
        containerProps,
        inputProps,
        children,
        suggestions,
        tooltipStyle,
        onItemClick,
        strict,
        tooltipProps,
        renderInput = defaultRenderInput,
        renderItems = defaultRenderItems,
    
        itemToString = defaultItemToString,
    
    James Long's avatar
    James Long committed
      },
      state: { value, selectedItem, filteredSuggestions, highlightedIndex, isOpen },
      updater,
    
    James Long's avatar
    James Long committed
    }) {
      const filtered = filteredSuggestions || suggestions;
    
      return (
        <Downshift
          onSelect={updater(onSelect)}
          highlightedIndex={highlightedIndex}
          selectedItem={selectedItem || null}
          itemToString={itemToString}
          inputValue={value}
          isOpen={isOpen}
          onInputValueChange={updater(onInputValueChange)}
          onStateChange={updater(onStateChange)}
        >
          {({
            getInputProps,
            getItemProps,
            getRootProps,
            isOpen,
            inputValue,
            selectedItem,
    
    James Long's avatar
    James Long committed
          }) => (
            // 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: updater(onFocus),
                  onBlur: updater(onBlur),
                  onKeyDown: updater(onKeyDown),
    
                  onChange: updater(onChange),
                }),
    
    James Long's avatar
    James Long committed
              )}
              {isOpen &&
                filtered.length > 0 &&
                (embedded ? (
                  <View style={{ marginTop: 5 }} data-testid="autocomplete">
                    {renderItems(
                      filtered,
                      getItemProps,
                      highlightedIndex,
    
    James Long's avatar
    James Long committed
                    )}
                  </View>
                ) : (
                  <Tooltip
                    position="bottom-stretch"
                    offset={2}
                    style={{
                      padding: 0,
                      backgroundColor: colors.n1,
                      color: 'white',
    
    James Long's avatar
    James Long committed
                    }}
                    {...tooltipProps}
                    data-testid="autocomplete"
                  >
                    {renderItems(
                      filtered,
                      getItemProps,
                      highlightedIndex,
    
    James Long's avatar
    James Long committed
                    )}
                  </Tooltip>
                ))}
            </div>
          )}
        </Downshift>
      );
    }
    
    const SingleAutocomplete = lively(_SingleAutocomplete, {
      getInitialState,
    
      componentWillReceiveProps,
    
    James Long's avatar
    James Long committed
    });
    
    function MultiItem({ name, onRemove }) {
      return (
        <View
          style={{
            alignItems: 'center',
            flexDirection: 'row',
            backgroundColor: colors.b9,
            padding: '2px 4px',
            margin: '2px',
    
    James Long's avatar
    James Long committed
          }}
        >
          {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)),
    
    James Long's avatar
    James Long committed
          )}
          onSelect={onAddItem}
          clearAfterSelect
          highlightFirst
          strict={strict}
          tooltipProps={{
    
            forceLayout: lastSelectedItems.current !== selectedItems,
    
    James Long's avatar
    James Long committed
          }}
          renderInput={props => (
            <View
              style={[
                {
                  display: 'flex',
                  flexWrap: 'wrap',
                  flexDirection: 'row',
                  alignItems: 'center',
                  backgroundColor: 'white',
                  borderRadius: 4,
    
                  border: '1px solid #d0d0d0',
    
    James Long's avatar
    James Long committed
                },
                focused && {
                  border: '1px solid ' + colors.b5,
    
                  boxShadow: '0 1px 1px ' + colors.b7,
                },
    
    James Long's avatar
    James Long committed
              ]}
            >
              {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' },
    
    James Long's avatar
    James Long committed
                  },
    
    James Long's avatar
    James Long committed
                ]}
              />
            </View>
          )}
        />
      );
    }
    
    export function AutocompleteFooterButton({
      title,
      style,
      hoveredStyle,
    
    James Long's avatar
    James Long committed
    }) {
      return (
        <Button
          style={[
            {
              fontSize: 12,
              color: colors.n10,
              backgroundColor: 'transparent',
    
    James Long's avatar
    James Long committed
            },
    
    James Long's avatar
    James Long committed
          ]}
          hoveredStyle={[
            { backgroundColor: 'rgba(200, 200, 200, .25)' },
    
    James Long's avatar
    James Long committed
          ]}
          onClick={onClick}
        >
          {title}
        </Button>
      );
    }
    
    export function AutocompleteFooter({ show = true, embedded, children }) {
      return (
        show && (
          <View
            style={[
              { flexShrink: 0 },
    
              embedded ? { paddingTop: 5 } : { padding: 5 },
    
    James Long's avatar
    James Long committed
            ]}
            onMouseDown={e => e.preventDefault()}
          >
            {children}
          </View>
        )
      );
    }
    
    export default function Autocomplete({ multi, ...props }) {
      if (multi) {
        return <MultiAutocomplete {...props} />;
      } else {
        return <SingleAutocomplete {...props} />;
      }
    }