repos
/ timelite-nextjs master

timelite-nextjs

mirror archived upstream

A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.

handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer

4.7 KB · 162 lines · JavaScript Raw History
  1import React, { useState, useRef, useMemo, useCallback, useEffect } from "react";
  2import PropTypes from "prop-types";
  3
  4import styles from "../styles/components/tagNoteInput.module.css";
  5
  6const tokenAtCursor = (text, cursorPos) => {
  7  let start = cursorPos;
  8  while (start > 0 && !/\s/.test(text[start - 1])) start--;
  9  let end = cursorPos;
 10  while (end < text.length && !/\s/.test(text[end])) end++;
 11  return { start, end, token: text.slice(start, end) };
 12};
 13
 14const TagNoteInput = React.forwardRef(function TagNoteInput(
 15  {
 16    value,
 17    onChange,
 18    allTags,
 19    className,
 20    wrapperClassName,
 21    placeholder,
 22    autoFocus,
 23    "aria-label": ariaLabel,
 24  },
 25  forwardedRef
 26) {
 27  const inputRef = useRef(null);
 28  const [cursorPos, setCursorPos] = useState(0);
 29  const [isFocused, setIsFocused] = useState(false);
 30  const [highlightIndex, setHighlightIndex] = useState(0);
 31  const [pendingCursor, setPendingCursor] = useState(null);
 32
 33  const setRefs = useCallback(
 34    (node) => {
 35      inputRef.current = node;
 36      if (typeof forwardedRef === "function") forwardedRef(node);
 37      else if (forwardedRef && "current" in forwardedRef)
 38        forwardedRef.current = node;
 39    },
 40    [forwardedRef]
 41  );
 42
 43  useEffect(() => {
 44    if (pendingCursor != null && inputRef.current) {
 45      inputRef.current.setSelectionRange(pendingCursor, pendingCursor);
 46      setPendingCursor(null);
 47    }
 48  }, [pendingCursor]);
 49
 50  const { start, end, token } = useMemo(
 51    () => tokenAtCursor(value || "", cursorPos),
 52    [value, cursorPos]
 53  );
 54
 55  const suggestions = useMemo(() => {
 56    if (!isFocused) return [];
 57    if (!token.startsWith("#") || token.length < 2) return [];
 58    const needle = token.toLowerCase();
 59    return allTags
 60      .filter((t) => t !== needle && t.startsWith(needle))
 61      .slice(0, 8);
 62  }, [isFocused, token, allTags]);
 63
 64  useEffect(() => {
 65    if (highlightIndex >= suggestions.length) setHighlightIndex(0);
 66  }, [suggestions.length, highlightIndex]);
 67
 68  const applySuggestion = (tag) => {
 69    const before = (value || "").slice(0, start);
 70    const after = (value || "").slice(end);
 71    const needsSpace = after.length === 0 || !/^\s/.test(after);
 72    const replacement = tag + (needsSpace ? " " : "");
 73    const next = before + replacement + after;
 74    const newCursor = before.length + replacement.length;
 75    onChange(next);
 76    setPendingCursor(newCursor);
 77  };
 78
 79  const handleKeyDown = (e) => {
 80    if (suggestions.length === 0) return;
 81    if (e.key === "ArrowDown") {
 82      e.preventDefault();
 83      setHighlightIndex((i) => (i + 1) % suggestions.length);
 84    } else if (e.key === "ArrowUp") {
 85      e.preventDefault();
 86      setHighlightIndex(
 87        (i) => (i - 1 + suggestions.length) % suggestions.length
 88      );
 89    } else if (e.key === "Tab" || (e.key === "Enter" && suggestions.length)) {
 90      if (e.key === "Enter" && !isFocused) return;
 91      e.preventDefault();
 92      applySuggestion(suggestions[highlightIndex]);
 93    } else if (e.key === "Escape") {
 94      setIsFocused(false);
 95      e.stopPropagation();
 96    }
 97  };
 98
 99  const handleChange = (e) => {
100    onChange(e.target.value);
101    setCursorPos(e.target.selectionStart ?? e.target.value.length);
102    setHighlightIndex(0);
103  };
104
105  const handleSelect = (e) => {
106    setCursorPos(e.target.selectionStart ?? 0);
107  };
108
109  return (
110    <div className={`${styles.wrap} ${wrapperClassName || ""}`.trim()}>
111      <input
112        type="text"
113        ref={setRefs}
114        className={className}
115        value={value || ""}
116        onChange={handleChange}
117        onKeyDown={handleKeyDown}
118        onSelect={handleSelect}
119        onFocus={() => setIsFocused(true)}
120        onBlur={() => setTimeout(() => setIsFocused(false), 120)}
121        placeholder={placeholder}
122        aria-label={ariaLabel}
123        autoFocus={autoFocus}
124        autoComplete="off"
125      />
126      {suggestions.length > 0 && (
127        <ul
128          className={styles.list}
129          role="listbox"
130          onMouseDown={(e) => e.preventDefault()}
131        >
132          {suggestions.map((tag, i) => (
133            <li
134              key={tag}
135              role="option"
136              aria-selected={i === highlightIndex}
137              className={`${styles.item} ${i === highlightIndex ? styles.itemActive : ""}`.trim()}
138              onMouseEnter={() => setHighlightIndex(i)}
139              onClick={() => applySuggestion(tag)}
140            >
141              {tag}
142            </li>
143          ))}
144        </ul>
145      )}
146    </div>
147  );
148});
149
150TagNoteInput.propTypes = {
151  value: PropTypes.string,
152  onChange: PropTypes.func.isRequired,
153  allTags: PropTypes.arrayOf(PropTypes.string).isRequired,
154  className: PropTypes.string,
155  wrapperClassName: PropTypes.string,
156  placeholder: PropTypes.string,
157  autoFocus: PropTypes.bool,
158  "aria-label": PropTypes.string,
159};
160
161export default TagNoteInput;