Files
triggerdotdev--trigger.dev/apps/webapp/app/components/primitives/SearchInput.tsx
T
Chris Arderne 7673c46a02 chore(webapp): scope component effect synchronization (#4727)
## Summary

Scopes React Compiler diagnostics for component and hook effects that
intentionally synchronize with navigation, submissions, browser APIs,
streams, timers, or authoritative server values. Each suppression stays
on the reported synchronization call rather than disabling analysis for
the component.
2026-08-20 09:59:43 +01:00

201 lines
6.9 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;
minLength?: number;
/** Normalize the submitted value before applying minLength validation. */
normalizeForValidation?: (value: string) => string;
/**
* 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,
minLength,
normalizeForValidation,
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;
// oxlint-disable-next-line react/react-compiler -- This effect intentionally synchronizes local state after an external or lifecycle change.
setText(urlSearch);
}
}, [isControlled, controlledValue, value, isFocused, paramName]);
const updateText = (next: string) => {
inputRef.current?.setCustomValidity("");
setText(next);
if (isControlled) {
onValueChange?.(next);
}
};
const handleSubmit = () => {
const trimmedText = text.trim();
const validationText = normalizeForValidation?.(trimmedText) ?? trimmedText;
if (
minLength !== undefined &&
trimmedText.length > 0 &&
[...validationText].length < minLength
) {
inputRef.current?.setCustomValidity(`Enter at least ${minLength} characters`);
inputRef.current?.reportValidity();
return;
}
inputRef.current?.setCustomValidity("");
if (isControlled) {
// Live updates already fired through onValueChange; submit is a no-op.
return;
}
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
if (trimmedText) {
replace({ [paramName]: trimmedText, ...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) => {
e.currentTarget.setCustomValidity("");
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>
);
}