heartwood every commit a ring
4.7 KB raw
import React, { useState, useRef, useMemo, useCallback, useEffect } from "react";
import PropTypes from "prop-types";

import styles from "../styles/components/tagNoteInput.module.css";

const tokenAtCursor = (text, cursorPos) => {
  let start = cursorPos;
  while (start > 0 && !/\s/.test(text[start - 1])) start--;
  let end = cursorPos;
  while (end < text.length && !/\s/.test(text[end])) end++;
  return { start, end, token: text.slice(start, end) };
};

const TagNoteInput = React.forwardRef(function TagNoteInput(
  {
    value,
    onChange,
    allTags,
    className,
    wrapperClassName,
    placeholder,
    autoFocus,
    "aria-label": ariaLabel,
  },
  forwardedRef
) {
  const inputRef = useRef(null);
  const [cursorPos, setCursorPos] = useState(0);
  const [isFocused, setIsFocused] = useState(false);
  const [highlightIndex, setHighlightIndex] = useState(0);
  const [pendingCursor, setPendingCursor] = useState(null);

  const setRefs = useCallback(
    (node) => {
      inputRef.current = node;
      if (typeof forwardedRef === "function") forwardedRef(node);
      else if (forwardedRef && "current" in forwardedRef)
        forwardedRef.current = node;
    },
    [forwardedRef]
  );

  useEffect(() => {
    if (pendingCursor != null && inputRef.current) {
      inputRef.current.setSelectionRange(pendingCursor, pendingCursor);
      setPendingCursor(null);
    }
  }, [pendingCursor]);

  const { start, end, token } = useMemo(
    () => tokenAtCursor(value || "", cursorPos),
    [value, cursorPos]
  );

  const suggestions = useMemo(() => {
    if (!isFocused) return [];
    if (!token.startsWith("#") || token.length < 2) return [];
    const needle = token.toLowerCase();
    return allTags
      .filter((t) => t !== needle && t.startsWith(needle))
      .slice(0, 8);
  }, [isFocused, token, allTags]);

  useEffect(() => {
    if (highlightIndex >= suggestions.length) setHighlightIndex(0);
  }, [suggestions.length, highlightIndex]);

  const applySuggestion = (tag) => {
    const before = (value || "").slice(0, start);
    const after = (value || "").slice(end);
    const needsSpace = after.length === 0 || !/^\s/.test(after);
    const replacement = tag + (needsSpace ? " " : "");
    const next = before + replacement + after;
    const newCursor = before.length + replacement.length;
    onChange(next);
    setPendingCursor(newCursor);
  };

  const handleKeyDown = (e) => {
    if (suggestions.length === 0) return;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setHighlightIndex((i) => (i + 1) % suggestions.length);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setHighlightIndex(
        (i) => (i - 1 + suggestions.length) % suggestions.length
      );
    } else if (e.key === "Tab" || (e.key === "Enter" && suggestions.length)) {
      if (e.key === "Enter" && !isFocused) return;
      e.preventDefault();
      applySuggestion(suggestions[highlightIndex]);
    } else if (e.key === "Escape") {
      setIsFocused(false);
      e.stopPropagation();
    }
  };

  const handleChange = (e) => {
    onChange(e.target.value);
    setCursorPos(e.target.selectionStart ?? e.target.value.length);
    setHighlightIndex(0);
  };

  const handleSelect = (e) => {
    setCursorPos(e.target.selectionStart ?? 0);
  };

  return (
    <div className={`${styles.wrap} ${wrapperClassName || ""}`.trim()}>
      <input
        type="text"
        ref={setRefs}
        className={className}
        value={value || ""}
        onChange={handleChange}
        onKeyDown={handleKeyDown}
        onSelect={handleSelect}
        onFocus={() => setIsFocused(true)}
        onBlur={() => setTimeout(() => setIsFocused(false), 120)}
        placeholder={placeholder}
        aria-label={ariaLabel}
        autoFocus={autoFocus}
        autoComplete="off"
      />
      {suggestions.length > 0 && (
        <ul
          className={styles.list}
          role="listbox"
          onMouseDown={(e) => e.preventDefault()}
        >
          {suggestions.map((tag, i) => (
            <li
              key={tag}
              role="option"
              aria-selected={i === highlightIndex}
              className={`${styles.item} ${i === highlightIndex ? styles.itemActive : ""}`.trim()}
              onMouseEnter={() => setHighlightIndex(i)}
              onClick={() => applySuggestion(tag)}
            >
              {tag}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
});

TagNoteInput.propTypes = {
  value: PropTypes.string,
  onChange: PropTypes.func.isRequired,
  allTags: PropTypes.arrayOf(PropTypes.string).isRequired,
  className: PropTypes.string,
  wrapperClassName: PropTypes.string,
  placeholder: PropTypes.string,
  autoFocus: PropTypes.bool,
  "aria-label": PropTypes.string,
};

export default TagNoteInput;