179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
|
import { motion } from "framer-motion";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
|
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
|
import { useSearchParams } from "~/hooks/useSearchParam";
|
|
import { cn } from "~/utils/cn";
|
|
|
|
export type SearchInputProps = {
|
|
placeholder?: string;
|
|
/** The URL search param name to read/write. Defaults to "search". */
|
|
paramName?: string;
|
|
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
|
|
resetParams?: string[];
|
|
autoFocus?: boolean;
|
|
/**
|
|
* Controlled value. When provided alongside `onValueChange`, the input
|
|
* skips URL params entirely and acts as a controlled component — useful
|
|
* for client-side filters (e.g. tree filtering) that don't round-trip
|
|
* to the server.
|
|
*/
|
|
value?: string;
|
|
/**
|
|
* Called on every keystroke and on clear when in controlled mode. The
|
|
* presence of this prop is what switches the input into controlled
|
|
* mode; `paramName`/`resetParams` are ignored when it's set.
|
|
*/
|
|
onValueChange?: (value: string) => void;
|
|
};
|
|
|
|
export function SearchInput({
|
|
placeholder = "Search logs…",
|
|
paramName = "search",
|
|
resetParams = ["cursor", "direction"],
|
|
autoFocus,
|
|
value: controlledValue,
|
|
onValueChange,
|
|
}: SearchInputProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const { value, replace, del } = useSearchParams();
|
|
const isControlled = onValueChange !== undefined;
|
|
|
|
const initialSearch = isControlled ? (controlledValue ?? "") : (value(paramName) ?? "");
|
|
|
|
const [text, setText] = useState(initialSearch);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
|
|
// Compare against a ref, not `text`, so the effect stays off the keystroke path.
|
|
// Trade-off: controlled mode assumes the parent accepts onValueChange; it won't
|
|
// re-sync `text` if the parent rejects a change and holds `value` unchanged.
|
|
const lastSyncedRef = useRef(initialSearch);
|
|
|
|
useEffect(() => {
|
|
if (isControlled) {
|
|
if (controlledValue !== undefined && controlledValue !== lastSyncedRef.current) {
|
|
lastSyncedRef.current = controlledValue;
|
|
setText(controlledValue);
|
|
}
|
|
return;
|
|
}
|
|
const urlSearch = value(paramName) ?? "";
|
|
if (urlSearch === lastSyncedRef.current) return;
|
|
// Only mark synced once we actually apply it, so a URL change during focus still syncs on blur.
|
|
if (!isFocused) {
|
|
lastSyncedRef.current = urlSearch;
|
|
setText(urlSearch);
|
|
}
|
|
}, [isControlled, controlledValue, value, isFocused, paramName]);
|
|
|
|
const updateText = (next: string) => {
|
|
setText(next);
|
|
if (isControlled) {
|
|
onValueChange?.(next);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
if (isControlled) {
|
|
// Live updates already fired through onValueChange; submit is a no-op.
|
|
return;
|
|
}
|
|
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
|
|
if (text.trim()) {
|
|
replace({ [paramName]: text.trim(), ...resetValues });
|
|
} else {
|
|
del([paramName, ...resetParams]);
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setText("");
|
|
if (isControlled) {
|
|
onValueChange?.("");
|
|
} else {
|
|
del([paramName, ...resetParams]);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<motion.div
|
|
initial={{ width: "auto" }}
|
|
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 300,
|
|
damping: 30,
|
|
}}
|
|
className="relative h-6 min-w-52"
|
|
>
|
|
<Input
|
|
type="text"
|
|
ref={inputRef}
|
|
variant="secondary-small"
|
|
placeholder={placeholder}
|
|
value={text}
|
|
onChange={(e) => updateText(e.target.value)}
|
|
fullWidth
|
|
autoFocus={autoFocus}
|
|
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
if (e.key === "Escape") {
|
|
if (text.length > 0) {
|
|
e.stopPropagation();
|
|
handleClear();
|
|
} else {
|
|
e.currentTarget.blur();
|
|
}
|
|
}
|
|
}}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
icon={<MagnifyingGlassIcon className="size-4 text-text-bright" />}
|
|
accessory={
|
|
text.length > 0 ? (
|
|
<div className="-mr-1 flex items-center gap-1.5">
|
|
{!isControlled && (
|
|
<ShortcutKey
|
|
shortcut={{ key: "enter" }}
|
|
variant="medium"
|
|
className="border-none"
|
|
/>
|
|
)}
|
|
<SimpleTooltip
|
|
asChild
|
|
button={
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => handleClear()}
|
|
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-surface-control hover:text-text-bright"
|
|
>
|
|
<XMarkIcon className="size-3" />
|
|
</button>
|
|
}
|
|
content={
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-text-dimmed">Clear field</span>
|
|
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
|
|
</div>
|
|
}
|
|
className="px-2 py-1.5 text-xs"
|
|
disableHoverableContent
|
|
/>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</motion.div>
|
|
</div>
|
|
);
|
|
}
|