Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
martinmckenna opened this issue Sep 28, 2024 · 1 comment
Open

Comments

@martinmckenna
Copy link

martinmckenna commented Sep 28, 2024

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:

  • onRequestOptions is debounced by 150ms (currently not configurable via props but very well could be)
  • Fixed issue where if you scroll partially down the page, the suggestions don't appear next to the typed text
  • Allows you to pass your own ref from the parent to the input
  • Forwards any props down to the Component you pass, along with good TS support so it knows which props it needs if your custom component has required props
  • removed the scroll event listener, because I thought the suggestions should persist if you scroll because sometimes you have to scroll on mobile devices to see the full suggestions list
  • fixed issue where the suggestions box would go off-screen if you were too close the right edge of the window. Now it subtracts a few pixels off once you start getting close to the end of the window
    • not 100% confident this will look good for textareas that don't take most of the window width tho!!

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
Copy link
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant