Detailed changes
@@ -0,0 +1,150 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Search } from "lucide-react";
+import { useState } from "react";
+
+import type { CompletionProvider } from "./query-input";
+import * as QueryInput from "./query-input";
+
+const meta = {
+ component: QueryInput.Root,
+} satisfies Meta<typeof QueryInput.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const sampleLabels = [
+ { name: "bug", color: { R: 252, G: 41, B: 41 } },
+ { name: "enhancement", color: { R: 163, G: 230, B: 53 } },
+ { name: "documentation", color: { R: 30, G: 80, B: 160 } },
+ { name: "help wanted", color: { R: 0, G: 150, B: 136 } },
+ { name: "wontfix", color: { R: 200, G: 200, B: 200 } },
+];
+
+const sampleAuthors = [
+ { displayName: "Jane Doe", login: "janedoe" },
+ { displayName: "Bob Smith", login: "bobsmith" },
+ { displayName: "Alice Wu", login: "alicewu" },
+];
+
+const providers: CompletionProvider[] = [
+ {
+ prefix: "label:",
+ highlightClass: "text-yellow-600 dark:text-yellow-500",
+ getSuggestions: (query) =>
+ sampleLabels
+ .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase()))
+ .map((l) => ({
+ value: l.name.includes(" ") ? `"${l.name}"` : l.name,
+ label: l.name,
+ icon: (
+ <span
+ className="size-2 shrink-0 rounded-full"
+ style={{ backgroundColor: `rgb(${l.color.R},${l.color.G},${l.color.B})` }}
+ />
+ ),
+ })),
+ },
+ {
+ prefix: "author:",
+ highlightClass: "text-blue-600 dark:text-blue-400",
+ getSuggestions: (query) =>
+ sampleAuthors
+ .filter(
+ (a) =>
+ query === "" ||
+ a.displayName.toLowerCase().includes(query.toLowerCase()) ||
+ a.login.toLowerCase().includes(query.toLowerCase()),
+ )
+ .map((a) => ({
+ value: a.login,
+ label: a.displayName,
+ description: `@${a.login}`,
+ })),
+ },
+];
+
+export const Default: Story = {
+ args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+ render: () => {
+ const [value, setValue] = useState("status:open");
+ return (
+ <QueryInput.Root
+ value={value}
+ onChange={setValue}
+ onSubmit={() => {}}
+ providers={providers}
+ >
+ <QueryInput.Icon><Search /></QueryInput.Icon>
+ <QueryInput.Input placeholder="status:open author:β¦ label:β¦" />
+ <QueryInput.Completions />
+ </QueryInput.Root>
+ );
+ },
+};
+
+export const WithFilters: Story = {
+ args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+ render: () => {
+ const [value, setValue] = useState('status:open label:bug author:janedoe fix login');
+ return (
+ <QueryInput.Root
+ value={value}
+ onChange={setValue}
+ onSubmit={() => {}}
+ providers={providers}
+ >
+ <QueryInput.Icon><Search /></QueryInput.Icon>
+ <QueryInput.Input placeholder="status:open author:β¦ label:β¦" />
+ <QueryInput.Completions />
+ </QueryInput.Root>
+ );
+ },
+};
+
+export const SyntaxOnly: Story = {
+ args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+ render: () => {
+ const [value, setValue] = useState("status:open label:bug");
+ return (
+ <QueryInput.Root value={value} onChange={setValue} onSubmit={() => {}}>
+ <QueryInput.Icon><Search /></QueryInput.Icon>
+ <QueryInput.Input placeholder="Searchβ¦" />
+ </QueryInput.Root>
+ );
+ },
+};
+
+const asyncProviders: CompletionProvider[] = [
+ {
+ prefix: "label:",
+ highlightClass: "text-yellow-600 dark:text-yellow-500",
+ getSuggestions: async (query) => {
+ await new Promise((r) => setTimeout(r, 500));
+ return sampleLabels
+ .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase()))
+ .map((l) => ({
+ value: l.name.includes(" ") ? `"${l.name}"` : l.name,
+ label: l.name,
+ }));
+ },
+ },
+];
+
+export const AsyncCompletions: Story = {
+ args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+ render: () => {
+ const [value, setValue] = useState("");
+ return (
+ <QueryInput.Root
+ value={value}
+ onChange={setValue}
+ onSubmit={() => {}}
+ providers={asyncProviders}
+ >
+ <QueryInput.Icon><Search /></QueryInput.Icon>
+ <QueryInput.Input placeholder="Type label: to see async loadingβ¦" />
+ <QueryInput.Completions />
+ </QueryInput.Root>
+ );
+ },
+};
@@ -0,0 +1,456 @@
+// Generic syntax-highlighted search input with pluggable autocomplete providers.
+//
+// Architecture: two layers share the same font/padding so they appear identical:
+// 1. A "backdrop" div (aria-hidden) renders colored <span>s for each token.
+// 2. The real <input> floats on top with transparent text and bg, so the caret
+// is visible but the text itself is hidden in favour of the backdrop.
+
+import {
+ createContext,
+ useContext,
+ useState,
+ useRef,
+ useMemo,
+ useEffect,
+ type ChangeEvent,
+ type ReactNode,
+} from "react";
+
+import { cn } from "@/lib/utils";
+
+// ββ Public types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export interface Suggestion {
+ /** What gets inserted into the input (already quoted if needed). */
+ value: string;
+ /** Display label shown in the dropdown. */
+ label: string;
+ /** Optional leading decoration (icon, color dot, etc.). */
+ icon?: ReactNode;
+ /** Optional right-aligned secondary text. */
+ description?: string;
+}
+
+export interface CompletionProvider {
+ /** The prefix this provider handles, including the colon (e.g. "label:"). */
+ prefix: string;
+ /** Tailwind classes for syntax-highlighting this prefix in the backdrop. */
+ highlightClass: string;
+ /** Return suggestions for the partial query typed after the prefix. */
+ getSuggestions(query: string): Suggestion[] | Promise<Suggestion[]>;
+}
+
+/** Static syntax rules for tokens that aren't completable but should be colored. */
+export interface SyntaxRule {
+ /** Exact token match (e.g. "status:open") or a prefix match (e.g. "sort:"). */
+ match: string | ((token: string) => boolean);
+ /** Tailwind classes for the prefix/key portion. */
+ highlightClass: string;
+}
+
+// ββ Defaults ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const DEFAULT_SYNTAX_RULES: SyntaxRule[] = [
+ { match: "status:open", highlightClass: "text-green-600 dark:text-green-400" },
+ { match: "status:closed", highlightClass: "text-purple-600 dark:text-purple-400" },
+ { match: (t) => t.startsWith("sort:"), highlightClass: "text-orange-600 dark:text-orange-400" },
+];
+
+// ββ Segment parsing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface Segment {
+ text: string;
+ highlightClass: string | null;
+}
+
+function parseSegments(
+ input: string,
+ providers: CompletionProvider[],
+ syntaxRules: SyntaxRule[],
+): Segment[] {
+ const segments: Segment[] = [];
+ let i = 0;
+
+ while (i < input.length) {
+ // Whitespace runs
+ if (input[i] === " ") {
+ let j = i;
+ while (j < input.length && input[j] === " ") j++;
+ segments.push({ text: input.slice(i, j), highlightClass: null });
+ i = j;
+ continue;
+ }
+
+ // Token β consume until an unquoted space
+ let j = i;
+ let inQuote = false;
+ while (j < input.length) {
+ if (input[j] === '"') {
+ inQuote = !inQuote;
+ j++;
+ continue;
+ }
+ if (!inQuote && input[j] === " ") break;
+ j++;
+ }
+
+ const token = input.slice(i, j);
+
+ // Check providers first (they also define syntax highlighting)
+ let highlightClass: string | null = null;
+ for (const p of providers) {
+ if (token.startsWith(p.prefix)) {
+ highlightClass = p.highlightClass;
+ break;
+ }
+ }
+
+ // Then check static syntax rules
+ if (!highlightClass) {
+ for (const rule of syntaxRules) {
+ const matches =
+ typeof rule.match === "string" ? token === rule.match : rule.match(token);
+ if (matches) {
+ highlightClass = rule.highlightClass;
+ break;
+ }
+ }
+ }
+
+ segments.push({ text: token, highlightClass });
+ i = j;
+ }
+
+ return segments;
+}
+
+function renderSegment(seg: Segment, i: number): ReactNode {
+ if (!seg.highlightClass) {
+ return <span key={i}>{seg.text}</span>;
+ }
+ const colon = seg.text.indexOf(":");
+ if (colon === -1) {
+ return (
+ <span key={i} className={seg.highlightClass}>
+ {seg.text}
+ </span>
+ );
+ }
+ const key = seg.text.slice(0, colon + 1);
+ const val = seg.text.slice(colon + 1);
+ return (
+ <span key={i}>
+ <span className={seg.highlightClass}>{key}</span>
+ <span>{val}</span>
+ </span>
+ );
+}
+
+// ββ Cursor / token utilities ββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface CompletionInfo {
+ provider: CompletionProvider;
+ query: string;
+ tokenStart: number;
+}
+
+function getCompletionInfo(
+ value: string,
+ cursor: number,
+ providers: CompletionProvider[],
+): CompletionInfo | null {
+ let tokenStart = 0;
+ for (let i = cursor - 1; i >= 0; i--) {
+ if (value[i] === " ") {
+ tokenStart = i + 1;
+ break;
+ }
+ }
+
+ const partial = value.slice(tokenStart, cursor);
+ for (const provider of providers) {
+ if (partial.startsWith(provider.prefix)) {
+ const query = partial.slice(provider.prefix.length).replace(/^"/, "");
+ return { provider, query, tokenStart };
+ }
+ }
+ return null;
+}
+
+function getTokenEnd(value: string, tokenStart: number): number {
+ let inQuote = false;
+ for (let i = tokenStart; i < value.length; i++) {
+ if (value[i] === '"') {
+ inQuote = !inQuote;
+ continue;
+ }
+ if (!inQuote && value[i] === " ") return i;
+ }
+ return value.length;
+}
+
+// ββ Context βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface QueryInputContextValue {
+ value: string;
+ segments: Segment[];
+ inputRef: React.RefObject<HTMLInputElement | null>;
+ suggestions: Suggestion[];
+ activeIndex: number;
+ showDropdown: boolean;
+ loading: boolean;
+ handleChange: (e: ChangeEvent<HTMLInputElement>) => void;
+ handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
+ handleSelect: (e: React.SyntheticEvent<HTMLInputElement>) => void;
+ selectSuggestion: (index: number) => void;
+}
+
+const QueryInputContext = createContext<QueryInputContextValue | null>(null);
+
+function useQueryInput() {
+ const ctx = useContext(QueryInputContext);
+ if (!ctx) throw new Error("QueryInput sub-components must be used within QueryInput.Root");
+ return ctx;
+}
+
+// ββ Components ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+interface RootProps {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ providers?: CompletionProvider[];
+ syntaxRules?: SyntaxRule[];
+ className?: string;
+ children: ReactNode;
+}
+
+export function Root({
+ value,
+ onChange,
+ onSubmit,
+ providers = [],
+ syntaxRules = DEFAULT_SYNTAX_RULES,
+ className,
+ children,
+}: RootProps) {
+ const inputRef = useRef<HTMLInputElement>(null);
+ const [completion, setCompletion] = useState<CompletionInfo | null>(null);
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
+ const [loading, setLoading] = useState(false);
+
+ const segments = useMemo(
+ () => parseSegments(value, providers, syntaxRules),
+ [value, providers, syntaxRules],
+ );
+
+ // Fetch suggestions when completion changes
+ useEffect(() => {
+ if (!completion) {
+ setSuggestions([]);
+ setLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ const result = completion.provider.getSuggestions(completion.query);
+
+ if (result instanceof Promise) {
+ setLoading(true);
+ void result.then((items) => {
+ if (!cancelled) {
+ setSuggestions(items);
+ setLoading(false);
+ }
+ });
+ } else {
+ setSuggestions(result);
+ setLoading(false);
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [completion]);
+
+ function updateCompletion(newValue: string, cursor: number) {
+ const info = getCompletionInfo(newValue, cursor, providers);
+ setCompletion(info);
+ setActiveIndex(0);
+ }
+
+ function handleChange(e: ChangeEvent<HTMLInputElement>) {
+ const newValue = e.target.value;
+ const cursor = e.target.selectionStart ?? newValue.length;
+ onChange(newValue);
+ updateCompletion(newValue, cursor);
+ }
+
+ function handleSelect(e: React.SyntheticEvent<HTMLInputElement>) {
+ updateCompletion(value, e.currentTarget.selectionStart ?? value.length);
+ }
+
+ function applySuggestion(s: Suggestion) {
+ if (!completion) return;
+ const tokenEnd = getTokenEnd(value, completion.tokenStart);
+ const completedToken = `${completion.provider.prefix}${s.value}`;
+ const newValue =
+ value.slice(0, completion.tokenStart) +
+ completedToken +
+ " " +
+ value.slice(tokenEnd).trimStart();
+ onChange(newValue);
+ setCompletion(null);
+ setSuggestions([]);
+
+ const newCursor = completion.tokenStart + completedToken.length + 1;
+ requestAnimationFrame(() => {
+ inputRef.current?.focus();
+ inputRef.current?.setSelectionRange(newCursor, newCursor);
+ });
+ }
+
+ function selectSuggestion(index: number) {
+ const s = suggestions[index];
+ if (s) applySuggestion(s);
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
+ if (e.key === "Enter" && !completion) {
+ e.preventDefault();
+ onSubmit();
+ return;
+ }
+
+ if (!completion || suggestions.length === 0) return;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIndex((i) => (i + 1) % suggestions.length);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
+ } else if (e.key === "Enter" || e.key === "Tab") {
+ e.preventDefault();
+ selectSuggestion(activeIndex);
+ } else if (e.key === "Escape") {
+ setCompletion(null);
+ setSuggestions([]);
+ }
+ }
+
+ const showDropdown = suggestions.length > 0;
+
+ const ctx: QueryInputContextValue = {
+ value,
+ segments,
+ inputRef,
+ suggestions,
+ activeIndex,
+ showDropdown,
+ loading,
+ handleChange,
+ handleKeyDown,
+ handleSelect,
+ selectSuggestion,
+ };
+
+ return (
+ <QueryInputContext value={ctx}>
+ <div
+ className={cn(
+ "relative flex flex-1 items-center rounded-md border border-input bg-background",
+ "ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
+ className,
+ )}
+ onClick={() => inputRef.current?.focus()}
+ >
+ {children}
+ </div>
+ </QueryInputContext>
+ );
+}
+
+interface IconProps {
+ children: ReactNode;
+}
+
+export function Icon({ children }: IconProps) {
+ return (
+ <div className="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0">
+ {children}
+ </div>
+ );
+}
+
+interface InputProps {
+ placeholder?: string;
+ className?: string;
+}
+
+export function Input({ placeholder, className }: InputProps) {
+ const { value, segments, inputRef, handleChange, handleKeyDown, handleSelect } = useQueryInput();
+
+ return (
+ <>
+ {/* Colored backdrop */}
+ <div
+ aria-hidden
+ className="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre"
+ >
+ {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))}
+ </div>
+
+ {/* Actual input */}
+ <input
+ ref={inputRef}
+ type="text"
+ value={value}
+ placeholder={placeholder}
+ onChange={handleChange}
+ onKeyDown={handleKeyDown}
+ onSelect={handleSelect}
+ className={cn(
+ "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",
+ className,
+ )}
+ spellCheck={false}
+ autoComplete="off"
+ />
+ </>
+ );
+}
+
+export function Completions() {
+ const { suggestions, activeIndex, showDropdown, loading, selectSuggestion } = useQueryInput();
+
+ if (!showDropdown && !loading) return null;
+
+ return (
+ <div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md">
+ {loading && suggestions.length === 0 && (
+ <div className="text-muted-foreground px-3 py-2 text-sm">Loadingβ¦</div>
+ )}
+ {suggestions.map((s, i) => (
+ <button
+ key={`${s.value}-${s.label}`}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ selectSuggestion(i);
+ }}
+ className={cn(
+ "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
+ i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
+ )}
+ >
+ {s.icon}
+ <span className="font-mono">{s.label}</span>
+ {s.description && (
+ <span className="text-muted-foreground ml-auto text-xs">{s.description}</span>
+ )}
+ </button>
+ ))}
+ </div>
+ );
+}
@@ -1,18 +1,19 @@
import { useReadQuery } from "@apollo/client/react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
-import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
-import { useState } from "react";
+import { CircleDot, CircleCheck, ChevronLeft, ChevronRight, Search } from "lucide-react";
+import { useMemo, useState } from "react";
import * as v from "valibot";
import { type BugListQuery, BugListDocument } from "@/__generated__/graphql";
import { IssueFilters } from "@/components/bugs/IssueFilters";
+import type { SortValue } from "@/components/bugs/IssueFilters";
import * as IssueRow from "@/components/bugs/IssueRow";
import { LabelBadgeLink } from "@/components/bugs/LabelBadge";
-import type { SortValue } from "@/components/bugs/IssueFilters";
-import { QueryInput } from "@/components/bugs/QueryInput";
import { Button } from "@/components/ui/button";
import { ButtonLink } from "@/components/ui/button-link";
+import * as QueryInput from "@/components/ui/query-input";
+import type { CompletionProvider } from "@/components/ui/query-input";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
@@ -75,8 +76,58 @@ function RouteComponent() {
const openCount = data?.repository?.openCount.totalCount ?? 0;
const closedCount = data?.repository?.closedCount.totalCount ?? 0;
const bugs = data?.repository?.bugs;
- const validLabels = labelsData?.repository?.validLabels.nodes ?? [];
- const allIdentities = identitiesData?.repository?.allIdentities.nodes ?? [];
+ const validLabels = labelsData?.repository?.validLabels.nodes;
+ const allIdentities = identitiesData?.repository?.allIdentities.nodes;
+
+ const completionProviders: CompletionProvider[] = useMemo(
+ () => [
+ {
+ prefix: "label:",
+ highlightClass: "text-yellow-600 dark:text-yellow-500",
+ getSuggestions: (query: string) =>
+ (validLabels ?? [])
+ .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase()))
+ .slice(0, 8)
+ .map((l) => ({
+ value: l.name.includes(" ") ? `"${l.name}"` : l.name,
+ label: l.name,
+ icon: (
+ <span
+ className="size-2 shrink-0 rounded-full"
+ style={{
+ backgroundColor: `rgb(${l.color.R},${l.color.G},${l.color.B})`,
+ }}
+ />
+ ),
+ })),
+ },
+ {
+ prefix: "author:",
+ highlightClass: "text-blue-600 dark:text-blue-400",
+ getSuggestions: (query: string) =>
+ (allIdentities ?? [])
+ .filter(
+ (a) =>
+ query === "" ||
+ a.displayName.toLowerCase().includes(query.toLowerCase()) ||
+ (a.login ?? "").toLowerCase().includes(query.toLowerCase()) ||
+ (a.name ?? "").toLowerCase().includes(query.toLowerCase()),
+ )
+ .slice(0, 8)
+ .map((a) => {
+ const qv = a.login || a.name || a.humanId;
+ return {
+ value: qv.includes(" ") ? `"${qv}"` : qv,
+ label: a.displayName,
+ description:
+ a.login && a.login !== a.displayName ? `@${a.login}` : undefined,
+ };
+ }),
+ },
+ ],
+ [validLabels, allIdentities],
+ );
+
const totalCount = bugs?.totalCount ?? 0;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const hasNext = bugs?.pageInfo.hasNextPage ?? false;
@@ -114,14 +165,16 @@ function RouteComponent() {
<div>
{/* Search bar */}
<form onSubmit={handleSearch} className="mb-4 flex gap-2">
- <QueryInput
+ <QueryInput.Root
value={draft}
onChange={setDraft}
onSubmit={handleSearch}
- placeholder="status:open author:β¦ label:β¦"
- labels={validLabels}
- identities={allIdentities}
- />
+ providers={completionProviders}
+ >
+ <QueryInput.Icon><Search /></QueryInput.Icon>
+ <QueryInput.Input placeholder="status:open author:β¦ label:β¦" />
+ <QueryInput.Completions />
+ </QueryInput.Root>
<Button type="submit">Search</Button>
</form>
@@ -179,8 +232,8 @@ function RouteComponent() {
<div className="ml-auto">
<IssueFilters
- labels={validLabels}
- identities={allIdentities}
+ labels={validLabels ?? []}
+ identities={allIdentities ?? []}
selectedLabels={selectedLabels}
onLabelsChange={(labels) =>
applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)