QueryInput.tsx

  1// Syntax-highlighted search input with label/author autocomplete.
  2//
  3// Architecture: two layers share the same font/padding so they appear identical:
  4//   1. A "backdrop" div (aria-hidden) renders colored <span>s for each token.
  5//   2. The real <input> floats on top with transparent text and bg, so the caret
  6//      is visible but the text itself is hidden in favour of the backdrop.
  7//
  8// Autocomplete: when the cursor is inside a `label:` or `author:` token, a
  9// dropdown appears with filtered suggestions fetched from GraphQL. Clicking or
 10// keyboard-selecting a suggestion replaces the current token in the input.
 11
 12import { Search } from "lucide-react";
 13import { useState, useRef, useMemo, type ChangeEvent } from "react";
 14
 15import type { LabelItem, IdentityItem } from "@/components/bugs/IssueFilters";
 16import { cn } from "@/lib/utils";
 17
 18// ── Segment parsing (for the syntax-highlight backdrop) ───────────────────────
 19
 20type SegmentType = "status-open" | "status-closed" | "label" | "author" | "text" | "space";
 21
 22interface Segment {
 23  text: string;
 24  type: SegmentType;
 25}
 26
 27// Parse the query string into typed segments, preserving all whitespace.
 28// Walks char-by-char so that quoted values (e.g. label:"my label") are kept as
 29// a single token and spaces inside quotes don't split the segment.
 30function parseSegments(input: string): Segment[] {
 31  const segments: Segment[] = [];
 32  let i = 0;
 33
 34  while (i < input.length) {
 35    // Whitespace runs — preserved as a separate 'space' segment so the backdrop
 36    // can use whitespace-pre and match the input exactly.
 37    if (input[i] === " ") {
 38      let j = i;
 39      while (j < input.length && input[j] === " ") j++;
 40      segments.push({ text: input.slice(i, j), type: "space" });
 41      i = j;
 42      continue;
 43    }
 44
 45    // Token — consume until an unquoted space
 46    let j = i;
 47    let inQuote = false;
 48    while (j < input.length) {
 49      if (input[j] === '"') {
 50        inQuote = !inQuote;
 51        j++;
 52        continue;
 53      }
 54      if (!inQuote && input[j] === " ") break;
 55      j++;
 56    }
 57
 58    const token = input.slice(i, j);
 59    let type: SegmentType = "text";
 60    if (token === "status:open") type = "status-open";
 61    else if (token === "status:closed") type = "status-closed";
 62    else if (token.startsWith("label:")) type = "label";
 63    else if (token.startsWith("author:")) type = "author";
 64
 65    segments.push({ text: token, type });
 66    i = j;
 67  }
 68
 69  return segments;
 70}
 71
 72// Only the key portion (e.g. "label:") is colored; the value stays in foreground.
 73function renderSegment(seg: Segment, i: number): React.ReactNode {
 74  if (seg.type === "space" || seg.type === "text") {
 75    return <span key={i}>{seg.text}</span>;
 76  }
 77  const colon = seg.text.indexOf(":");
 78  const key = seg.text.slice(0, colon + 1);
 79  const val = seg.text.slice(colon + 1);
 80
 81  const keyClass =
 82    seg.type === "status-open"
 83      ? "text-green-600 dark:text-green-400"
 84      : seg.type === "status-closed"
 85        ? "text-purple-600 dark:text-purple-400"
 86        : seg.type === "label"
 87          ? "text-yellow-600 dark:text-yellow-500"
 88          : /* author */ "text-blue-600 dark:text-blue-400";
 89
 90  return (
 91    <span key={i}>
 92      <span className={keyClass}>{key}</span>
 93      <span>{val}</span>
 94    </span>
 95  );
 96}
 97
 98// ── Autocomplete logic ────────────────────────────────────────────────────────
 99
100interface CompletionInfo {
101  type: "label" | "author";
102  /** Text typed after the prefix (e.g. "bu" for "label:bu"). Quotes stripped. */
103  query: string;
104  /** Byte position in `value` where the current token starts. */
105  tokenStart: number;
106}
107
108// Inspects the text to the left of `cursor` to determine if the user is in the
109// middle of a `label:` or `author:` token and what they've typed so far.
110// Returns null when not in an autocomplete-eligible position.
111function getCompletionInfo(value: string, cursor: number): CompletionInfo | null {
112  // Walk backward to find the start of the current token
113  let tokenStart = 0;
114  for (let i = cursor - 1; i >= 0; i--) {
115    if (value[i] === " ") {
116      tokenStart = i + 1;
117      break;
118    }
119  }
120
121  const partial = value.slice(tokenStart, cursor);
122  if (partial.startsWith("label:")) {
123    return { type: "label", query: partial.slice(6), tokenStart };
124  }
125  if (partial.startsWith("author:")) {
126    // Strip a leading quote that the user may have typed
127    return { type: "author", query: partial.slice(7).replace(/^"/, ""), tokenStart };
128  }
129  return null;
130}
131
132// Find where the current token ends (next unquoted space, or end of string).
133// Used when replacing a token on suggestion selection so we don't leave stale text.
134function getTokenEnd(value: string, tokenStart: number): number {
135  let inQuote = false;
136  for (let i = tokenStart; i < value.length; i++) {
137    if (value[i] === '"') {
138      inQuote = !inQuote;
139      continue;
140    }
141    if (!inQuote && value[i] === " ") return i;
142  }
143  return value.length;
144}
145
146// ── Component ─────────────────────────────────────────────────────────────────
147
148interface QueryInputProps {
149  value: string;
150  onChange: (value: string) => void;
151  onSubmit: () => void;
152  placeholder?: string;
153  className?: string;
154  labels: readonly LabelItem[];
155  identities: readonly IdentityItem[];
156}
157
158export function QueryInput({
159  value,
160  onChange,
161  onSubmit,
162  placeholder,
163  className,
164  labels,
165  identities,
166}: QueryInputProps) {
167  const inputRef = useRef<HTMLInputElement>(null);
168
169  // Autocomplete state: null when the dropdown is hidden.
170  const [completion, setCompletion] = useState<CompletionInfo | null>(null);
171  // Keyboard-highlighted index within the visible suggestions list.
172  const [acIndex, setAcIndex] = useState(0);
173
174  const allLabels = labels;
175  const allAuthors = identities;
176
177  // Compute the filtered suggestion list whenever completion info changes.
178  const suggestions = useMemo(() => {
179    if (!completion) return [];
180
181    if (completion.type === "label") {
182      const q = completion.query.toLowerCase();
183      return allLabels
184        .filter((l) => q === "" || l.name.toLowerCase().includes(q))
185        .slice(0, 8)
186        .map((l) => ({
187          display: l.name,
188          // Quote the token value if the label name contains a space
189          completedToken: `label:${l.name.includes(" ") ? `"${l.name}"` : l.name}`,
190          color: l.color,
191        }));
192    }
193
194    // author suggestions — match against displayName, login, and name
195    const q = completion.query.toLowerCase();
196    return allAuthors
197      .filter(
198        (a) =>
199          q === "" ||
200          a.displayName.toLowerCase().includes(q) ||
201          (a.login ?? "").toLowerCase().includes(q) ||
202          (a.name ?? "").toLowerCase().includes(q),
203      )
204      .slice(0, 8)
205      .map((a) => {
206        // Prefer login (no spaces, stable) → name → humanId as the query value.
207        // Same preference used by IssueFilters.authorQueryValue.
208        const qv = a.login ?? a.name ?? a.humanId;
209        return {
210          display: a.displayName,
211          completedToken: `author:${qv.includes(" ") ? `"${qv}"` : qv}`,
212          color: null,
213        };
214      });
215  }, [completion, allLabels, allAuthors]);
216
217  // ── Recompute completion state after every input change or cursor move ──────
218
219  function updateCompletion(newValue: string, cursor: number) {
220    const info = getCompletionInfo(newValue, cursor);
221    setCompletion(info);
222    setAcIndex(0);
223  }
224
225  function handleChange(e: ChangeEvent<HTMLInputElement>) {
226    const newValue = e.target.value;
227    const cursor = e.target.selectionStart ?? newValue.length;
228    onChange(newValue);
229    updateCompletion(newValue, cursor);
230  }
231
232  // onSelect fires on cursor movement (arrow keys, click-to-reposition), which
233  // lets us show/hide the dropdown correctly when the cursor moves into or out
234  // of an autocomplete-eligible token without changing the text.
235  function handleSelect(e: React.SyntheticEvent<HTMLInputElement>) {
236    updateCompletion(value, e.currentTarget.selectionStart ?? value.length);
237  }
238
239  // ── Keyboard navigation ───────────────────────────────────────────────────
240
241  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
242    if (e.key === "Enter" && !completion) {
243      e.preventDefault();
244      onSubmit();
245      return;
246    }
247
248    if (!completion || suggestions.length === 0) return;
249
250    if (e.key === "ArrowDown") {
251      e.preventDefault();
252      setAcIndex((i) => (i + 1) % suggestions.length);
253    } else if (e.key === "ArrowUp") {
254      e.preventDefault();
255      setAcIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
256    } else if (e.key === "Enter" || e.key === "Tab") {
257      e.preventDefault();
258      const suggestion = suggestions[acIndex];
259      if (suggestion) applySuggestion(suggestion);
260    } else if (e.key === "Escape") {
261      setCompletion(null);
262    }
263  }
264
265  // ── Apply a selected suggestion ──────────────────────────────────────────
266
267  function applySuggestion(s: { completedToken: string }) {
268    if (!completion) return;
269    const tokenEnd = getTokenEnd(value, completion.tokenStart);
270    // Replace the current token (from tokenStart to tokenEnd) with the completed
271    // token, then add a space so the user can type the next filter immediately.
272    const newValue =
273      value.slice(0, completion.tokenStart) +
274      s.completedToken +
275      " " +
276      value.slice(tokenEnd).trimStart();
277    onChange(newValue);
278    setCompletion(null);
279
280    // Restore focus and position cursor after the inserted token + space
281    const newCursor = completion.tokenStart + s.completedToken.length + 1;
282    requestAnimationFrame(() => {
283      inputRef.current?.focus();
284      inputRef.current?.setSelectionRange(newCursor, newCursor);
285    });
286  }
287
288  // ── Render ────────────────────────────────────────────────────────────────
289
290  const segments = parseSegments(value);
291  const showDropdown = completion !== null && suggestions.length > 0;
292
293  return (
294    <div
295      className={cn(
296        "relative flex-1 flex items-center rounded-md border border-input bg-background",
297        "ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
298        className,
299      )}
300      onClick={() => inputRef.current?.focus()}
301    >
302      <Search className="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0" />
303
304      {/* Colored backdrop — same font/size/padding as the input. aria-hidden so
305          screen readers only see the real input, not the duplicate text. */}
306      <div
307        aria-hidden
308        className="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre"
309      >
310        {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))}
311      </div>
312
313      {/* Actual input — transparent bg and text so the backdrop shows through.
314          caret-foreground keeps the cursor visible despite text-transparent. */}
315      <input
316        ref={inputRef}
317        type="text"
318        value={value}
319        placeholder={placeholder}
320        onChange={handleChange}
321        onKeyDown={handleKeyDown}
322        onSelect={handleSelect}
323        className="caret-foreground placeholder:text-muted-foreground relative w-full bg-transparent py-2 pr-3 pl-9 font-mono text-sm text-transparent outline-hidden placeholder:font-sans"
324        spellCheck={false}
325        autoComplete="off"
326      />
327
328      {/* Autocomplete dropdown — positioned below the input via absolute+top-full.
329          Uses onMouseDown+preventDefault so clicking a suggestion doesn't blur
330          the input before the click registers (classic focus-race problem). */}
331      {showDropdown && (
332        <div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md">
333          {suggestions.map((s, i) => (
334            <button
335              key={s.completedToken}
336              onMouseDown={(e) => {
337                e.preventDefault();
338                applySuggestion(s);
339              }}
340              className={cn(
341                "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
342                i === acIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
343              )}
344            >
345              {s.color && (
346                <span
347                  className="size-2 shrink-0 rounded-full"
348                  style={{ backgroundColor: `rgb(${s.color.R},${s.color.G},${s.color.B})` }}
349                />
350              )}
351              <span className="font-mono">{s.completedToken}</span>
352              {s.display !== s.completedToken.split(":")[1]?.replace(/"/g, "") && (
353                <span className="text-muted-foreground ml-auto text-xs">{s.display}</span>
354              )}
355            </button>
356          ))}
357        </div>
358      )}
359    </div>
360  );
361}