yury-dymov / react-autocomplete-input

Autocomplete input field for React
https://yury-dymov.github.io/react-autocomplete-input/
MIT License
199 stars 65 forks source link

I Rewrote this in TS and in Modern React and Fixed Some Things #135

Open martinmckenna opened 1 month ago

martinmckenna commented 1 month ago

I'm not going to PR this because the project doesn't support typescript, but I added this component to my project, rewrote in in TS and converted it to a React functional component with hooks

Also fixed some issues:

This isn't perfect. It needs some more polish, but it works great for me so far:

import type {
  ChangeEvent,
  ComponentProps,
  ForwardRefExoticComponent,
  ReactNode,
  RefObject,
} from "react";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "throttle-debounce";
import getCaretCoordinates from "textarea-caret";
import getInputSelection, { setCaretPosition } from "./get-input-selection";
import scrollIntoView from "scroll-into-view-if-needed";
import styles from "./textareaautocomplete.module.css";
import Typography from "../Typography";

const KEY_UP = 38;
const KEY_DOWN = 40;
const KEY_RETURN = 13;
const KEY_ENTER = 14;
const KEY_ESCAPE = 27;
const KEY_TAB = 9;

const OPTION_LIST_MIN_WIDTH = 100;

export type Props<C extends string | ForwardRefExoticComponent<any>> = {
  Component?: C;
  defaultValue?: string;
  disabled?: boolean;
  maxOptions?: number;
  onBlur?: (...args: any[]) => void;
  onChange?: (value: string) => void;
  onKeyDown?: (...args: any[]) => void;
  onRequestOptions?: (value: string) => void;
  onSelect?: (...args: any[]) => void;
  changeOnSelect?: (trigger: string | string[], slug: string) => string;
  options?: Record<string, string[]> | string[];
  regex?: string;
  matchAny?: boolean;
  minChars?: number;
  spaceRemovers?: string[];
  spacer?: string;
  trigger?: string | string[];
  value?: string;
  offsetX?: number;
  offsetY?: number;
  passThroughEnter?: boolean;
  passThroughTab?: boolean;
  triggerMatchWholeWord?: boolean;
  triggerCaseInsensitive?: boolean;
} & Omit<ComponentProps<any>, "onChange">;

export const TextAreaAutocomplete = forwardRef<HTMLInputElement, Props<any>>(
  (
    {
      Component,
      defaultValue,
      disabled,
      maxOptions = 4,
      onBlur,
      onChange,
      onKeyDown,
      onRequestOptions,
      onSelect,
      changeOnSelect = (trigger, slug) => trigger + slug,
      options = [],
      regex = "^[A-Za-z0-9\\-_.!]+$",
      matchAny,
      minChars = 0,
      spaceRemovers = [",", "?"],
      spacer = " ",
      trigger = "@",
      offsetX = 0,
      offsetY = 0,
      value,
      passThroughEnter,
      passThroughTab = true,
      triggerMatchWholeWord,
      triggerCaseInsensitive,
      ...rest
    },
    ref
  ) => {
    const [helperVisible, setHelperVisible] = useState(false);
    const [left, setLeft] = useState(0);
    const [stateTrigger, setStateTrigger] = useState<string | null>(null);
    const [matchLength, setMatchLength] = useState(0);
    const [matchStart, setMatchStart] = useState(0);
    const [stateOptions, setStateOptions] = useState<string[]>([]);
    const [selection, setSelection] = useState(0);
    const [top, setTop] = useState(0);
    const [stateValue, setStateValue] = useState<string | null>(null);
    const [caret, setCaret] = useState<number | null>(null);

    const recentValue = useRef(defaultValue);
    const enableSpaceRemovers = useRef(false);
    const internalRefInput = useRef<HTMLInputElement>(null);
    const refInput = (ref as RefObject<HTMLInputElement>) || internalRefInput;
    const refCurrent = useRef<HTMLLIElement>(null);
    const refParent = useRef<HTMLUListElement>(null);

    const handleResize = () => {
      setHelperVisible(false);
    };

    const handleOnRequestOptionsDebounce = useCallback(
      debounce(
        100,
        (...args: Parameters<NonNullable<typeof onRequestOptions>>) => {
          onRequestOptions?.(...args);
        }
      ),
      []
    );

    const arrayTriggerMatch = (triggers: string[], re: RegExp) => {
      const triggersMatch = triggers.map((trigger) => ({
        triggerStr: trigger,
        triggerMatch: trigger.match(re),
        triggerLength: trigger.length,
      }));

      return triggersMatch;
    };

    const isTrigger = (passedTrigger: string, str: string, i: number) => {
      if (!passedTrigger || !passedTrigger.length) {
        return true;
      }

      if (triggerMatchWholeWord && i > 0 && str.charAt(i - 1).match(/[\w]/)) {
        return false;
      }

      if (
        str.substr(i, passedTrigger.length) === passedTrigger ||
        (triggerCaseInsensitive &&
          str.substr(i, passedTrigger.length).toLowerCase() ===
            passedTrigger.toLowerCase())
      ) {
        return true;
      }

      return false;
    };

    const getMatch = (
      str: string,
      caret: number,
      providedOptions: Props<any>["options"]
    ) => {
      const re = new RegExp(regex);

      const triggers = (
        !Array.isArray(trigger) ? new Array(trigger) : trigger
      ).sort();

      const providedOptionsObject = triggers.reduce((acc, eachTrigger) => {
        if (Array.isArray(providedOptions)) {
          acc[eachTrigger] = providedOptions;
        }
        return acc;
      }, {} as Record<string, string[]>);

      const triggersMatch = arrayTriggerMatch(triggers, re);

      let slugData: {
        trigger: string;
        matchStart: number;
        matchLength: number;
        options: string[];
      } | null = null;

      for (
        let triggersIndex = 0;
        triggersIndex < triggersMatch.length;
        triggersIndex++
      ) {
        const { triggerStr, triggerMatch, triggerLength } =
          triggersMatch[triggersIndex];

        for (let i = caret - 1; i >= 0; --i) {
          const substr = str.substring(i, caret);
          const match = substr.match(re);
          let matchStart = -1;

          if (triggerLength > 0) {
            const triggerIdx = triggerMatch ? i : i - triggerLength + 1;

            if (triggerIdx < 0) {
              // out of input
              break;
            }

            if (isTrigger(triggerStr, str, triggerIdx)) {
              matchStart = triggerIdx + triggerLength;
            }

            if (!match && matchStart < 0) {
              break;
            }
          } else {
            if (match && i > 0) {
              // find first non-matching character or begin of input
              continue;
            }
            matchStart = i === 0 && match ? 0 : i + 1;

            if (caret - matchStart === 0) {
              // matched slug is empty
              break;
            }
          }

          if (matchStart >= 0) {
            const triggerOptions = providedOptionsObject[triggerStr];
            if (!triggerOptions) {
              continue;
            }

            const matchedSlug = str.substring(matchStart, caret);

            const options = triggerOptions.filter((slug) => {
              const idx = slug.toLowerCase().indexOf(matchedSlug.toLowerCase());
              return idx !== -1 && (matchAny || idx === 0);
            });

            const currTrigger = triggerStr;
            const matchLength = matchedSlug.length;

            if (!slugData) {
              slugData = {
                trigger: currTrigger,
                matchStart,
                matchLength,
                options,
              };
            } else {
              slugData = {
                ...(slugData as Record<string, any>),
                trigger: currTrigger,
                matchStart,
                matchLength,
                options,
              };
            }
          }
        }
      }

      return slugData;
    };

    const updateHelper = (
      str: string,
      caret: number,
      passedOptions: NonNullable<Props<any>["options"]>,
      makeRequest = true
    ) => {
      const input = refInput.current!;
      const slug = getMatch(str, caret, passedOptions);

      if (slug) {
        const caretPos = getCaretCoordinates(input, caret);
        const { top, left, width } = input.getBoundingClientRect();

        const isCloseToEnd = width - caretPos.left < 150;
        const topOffset = top + window.scrollY;
        const leftOffset = left + window.scrollX;

        const newTop = caretPos.top + topOffset - input.scrollTop + 24;
        const newLeft = Math.min(
          /* Fully inside the viewport */
          caretPos.left + leftOffset - input.scrollLeft - slug.matchLength,
          /* Ensure minimal width inside viewport */
          window.innerWidth - OPTION_LIST_MIN_WIDTH
        );

        if (slug.matchLength >= minChars) {
          if (makeRequest) {
            handleOnRequestOptionsDebounce(
              str.substr(slug.matchStart, slug.matchLength)
            );
          }
          setTop(newTop);
          setLeft(isCloseToEnd ? newLeft - 175 : newLeft);
          setStateTrigger(slug.trigger);
          setStateOptions(slug.options);
          setMatchLength(slug.matchLength);
          setMatchStart(slug.matchStart);
          setHelperVisible(true);
        } else {
          resetHelper();
        }
      } else {
        resetHelper();
      }
    };

    useEffect(() => {
      window.addEventListener("resize", handleResize);

      return () => {
        window.removeEventListener("resize", handleResize);
      };
    }, []);

    useEffect(() => {
      if (typeof caret === "number" && !!options) {
        updateHelper(recentValue.current!, caret, options, false);
      }
    }, [JSON.stringify(options)]);

    useEffect(() => {
      if (helperVisible && refCurrent.current) {
        scrollIntoView(refCurrent.current, {
          boundary: refParent.current,
          scrollMode: "if-needed",
        });
      }
    }, [helperVisible]);

    const resetHelper = () => {
      setHelperVisible(false);
      setSelection(0);
    };

    const updateCaretPosition = (caret: number) => {
      setCaret(caret);
      setCaretPosition(refInput.current, caret);
    };

    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
      const old = recentValue.current;
      const str = e.target.value;
      const caret = getInputSelection(e.target).end;

      if (!str.length) {
        setHelperVisible(false);
      }

      recentValue.current = str;

      setCaret(caret);
      setStateValue(str);

      if (!str.length || !caret) {
        return onChange?.(e.target.value);
      }

      // '@wonderjenny ,|' -> '@wonderjenny, |'
      if (
        enableSpaceRemovers.current &&
        spaceRemovers.length &&
        str.length > 2 &&
        spacer.length
      ) {
        for (let i = 0; i < Math.max(old!.length, str.length); ++i) {
          if (old![i] !== str[i]) {
            if (
              i >= 2 &&
              str[i - 1] === spacer &&
              spaceRemovers.indexOf(str[i - 2]) === -1 &&
              spaceRemovers.indexOf(str[i]) !== -1 &&
              getMatch(str.substring(0, i - 2), caret - 3, options!)
            ) {
              const newValue = `${str.slice(0, i - 1)}${str.slice(
                i,
                i + 1
              )}${str.slice(i - 1, i)}${str.slice(i + 1)}`;

              updateCaretPosition(i + 1);
              if (refInput.current) {
                refInput.current.value = newValue;
              }

              if (!value) {
                setStateValue(newValue);
              }

              return onChange?.(newValue);
            }

            break;
          }
        }

        enableSpaceRemovers.current = false;
      }

      updateHelper(str, caret, options!);

      if (!value) {
        setStateValue(e.target.value);
      }

      return onChange?.(e.target.value);
    };

    const handleBlur = (e: KeyboardEvent) => {
      resetHelper();
      onBlur?.(e);
    };

    const handleSelection = (idx: number) => {
      const slug = stateOptions[idx];
      const value = recentValue.current!;
      const part1 =
        stateTrigger?.length === 0
          ? ""
          : value.substring(0, matchStart - trigger.length);
      const part2 = value.substring(matchStart + matchLength);

      const event = { target: refInput.current! };
      const changedStr = changeOnSelect(stateTrigger!, slug);

      event.target.value = `${part1}${changedStr}${spacer}${part2}`;
      handleChange(event as any);
      onSelect?.(event.target.value);

      resetHelper();

      const advanceCaretDistance =
        part1.length + changedStr.length + (spacer ? spacer.length : 1);

      updateCaretPosition(advanceCaretDistance);

      enableSpaceRemovers.current = true;
    };

    const handleKeyDown = (event: KeyboardEvent) => {
      const optionsCount =
        maxOptions > 0
          ? Math.min(stateOptions!.length, maxOptions)
          : stateOptions!.length;

      if (helperVisible) {
        switch (event.keyCode) {
          case KEY_ESCAPE:
            event.preventDefault();
            resetHelper();
            break;
          case KEY_UP:
            event.preventDefault();
            if (optionsCount > 0) {
              setSelection(
                Math.max(0, optionsCount + selection - 1) % optionsCount
              );
            }
            break;
          case KEY_DOWN:
            event.preventDefault();
            if (optionsCount > 0) {
              setSelection((selection + 1) % optionsCount);
            }
            break;
          case KEY_ENTER:
          case KEY_RETURN:
            if (!passThroughEnter) {
              event.preventDefault();
            }
            handleSelection(selection);
            break;
          case KEY_TAB:
            if (!passThroughTab) {
              event.preventDefault();
            }
            handleSelection(selection);
            break;
          default:
            onKeyDown?.(event);
            break;
        }
      } else {
        onKeyDown?.(event);
      }
    };

    const renderAutocompleteList = () => {
      if (!helperVisible) {
        return null;
      }

      if (stateOptions.length === 0) {
        return null;
      }

      if (selection >= stateOptions.length) {
        setSelection(0);

        return null;
      }

      const optionNumber = maxOptions === 0 ? stateOptions.length : maxOptions;

      const helperOptions = stateOptions
        .slice(0, optionNumber)
        .map((val, idx) => {
          const highlightStart = val
            .toLowerCase()
            .indexOf(stateValue!.substr(matchStart, matchLength).toLowerCase());

          return (
            <li
              className={
                idx === selection
                  ? `${styles["active"]} ${styles["react-autocomplete-input-li"]}`
                  : styles["react-autocomplete-input-li"]
              }
              ref={idx === selection ? refCurrent : undefined}
              key={val}
              onClick={() => {
                handleSelection(idx);
              }}
              onMouseDown={(e) => {
                e.preventDefault();
              }}
              onMouseEnter={() => {
                setSelection(idx);
              }}
            >
              <Typography type="body3">
                {val.slice(0, highlightStart)}
                <strong>{val.substr(highlightStart, matchLength)}</strong>
                {val.slice(highlightStart + matchLength)}
              </Typography>
            </li>
          );
        });

      /* FIXME: de-hardcode that 5 pixels margin */
      const maxWidth = window.innerWidth - left - offsetX - 5;
      const maxHeight = window.innerHeight - top - offsetY - 5;

      return (
        <ul
          className={styles["react-autocomplete-input"]}
          style={{
            left: left + offsetX,
            top: top + offsetY,
            maxHeight,
            maxWidth,
          }}
          ref={refParent}
        >
          {helperOptions}
        </ul>
      );
    };

    const val =
      typeof value !== "undefined" && value !== null
        ? value
        : stateValue
        ? stateValue
        : defaultValue;

    return (
      <>
        <Component
          disabled={disabled}
          onBlur={handleBlur}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          ref={refInput}
          value={val}
          {...rest}
        />
        {renderAutocompleteList()}
      </>
    );
  }
);

TextAreaAutocomplete.displayName = "TextAreaAutocomplete";

export default TextAreaAutocomplete as <
  C extends string | ForwardRefExoticComponent<any>
>(
  props: Props<C> & { ref?: RefObject<HTMLInputElement | HTMLTextAreaElement> }
) => ReactNode;
martinmckenna commented 1 month ago

and thanks @yury-dymov for writing this. There's not really great options for autocompletion, but I found this is one of the best ones, even though it's not maintained

ezgif-1-0cb6468557

yury-dymov commented 1 month ago

Thank you! I'll keep this open for visibility so other folks could find this easier and use as needed