Search Bar
Champ de recherche textuelle avec icone et fonction d'effacement. Installez Search Bar depuis le registre VLLNT UI avec la CLI shadcn.
Champ de recherche textuelle avec icone et fonction d'effacement. Fait partie de la famille form dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Search Bar a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/search-bar.jsonSource
"use client";
import { Suspense, useEffect, useEffectEvent, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDebounce } from "../../lib/use-debounce";
import { Button } from "../button/button";
import { Input } from "../input/input";
type SearchBarProps = {
className?: string;
onSearch?: (query: string) => void;
placeholder?: string;
};
export function SearchBar(props: SearchBarProps) {
// useSearchParams suspends during SSR — Suspense boundary keeps the
// surrounding tree streamable. See react-doctor rule
// nextjs-no-use-search-params-without-suspense + Next.js docs.
return (
<Suspense fallback={<SearchBarFallback {...props} />}>
<SearchBarInner {...props} />
</Suspense>
);
}
function SearchBarFallback({
className,
placeholder = "Search posts...",
}: SearchBarProps) {
return (
<form className={`flex gap-2 ${className}`}>
<Input
aria-label={placeholder}
className="flex-1"
disabled
placeholder={placeholder}
type="text"
value=""
/>
<Button disabled type="submit" variant="outline">
Search
</Button>
</form>
);
}
function SearchBarInner({
className,
onSearch,
placeholder = "Search posts...",
}: SearchBarProps) {
const router = useRouter();
const searchParameters = useSearchParams();
const initialQuery = searchParameters.get("search") ?? "";
const [query, setQuery] = useState(initialQuery);
const debouncedQuery = useDebounce(query, 300);
const isInitialMount = useRef(true);
const isUserTyping = useRef(false);
const typingTimeoutReference = useRef<NodeJS.Timeout | undefined>(undefined);
const lastSetSearchParameterReference = useRef<string>("");
const lastDebouncedQueryReference = useRef<string>("");
// Notify parent without making `onSearch` a reactive effect dependency:
// an Effect Event always sees the latest prop but never re-triggers the
// effect, so a parent passing a fresh callback can't drive an update loop.
const emitSearch = useEffectEvent((nextQuery: string) => {
onSearch?.(nextQuery);
});
// Sync query with URL search params (e.g., on browser back/forward)
// Sync when user is not actively typing and URL changed externally
useEffect(() => {
const searchParameter = searchParameters.get("search") ?? "";
// Skip if this is the search param we set ourselves
if (searchParameter === lastSetSearchParameterReference.current) {
return;
}
// Sync if user is not actively typing and values differ
if (!isUserTyping.current && query !== searchParameter) {
requestAnimationFrame(() => {
setQuery(searchParameter);
lastDebouncedQueryReference.current = searchParameter;
});
}
}, [searchParameters, query]); // Include query to properly sync state
// Update URL when debounced query changes
useEffect(() => {
// Skip initial mount to avoid unnecessary URL update
if (isInitialMount.current) {
isInitialMount.current = false;
const initialTrimmed = debouncedQuery.trim();
lastDebouncedQueryReference.current = initialTrimmed;
lastSetSearchParameterReference.current = initialTrimmed;
return;
}
const trimmedQuery = debouncedQuery.trim();
// Skip if this is the same value we already processed
if (trimmedQuery === lastDebouncedQueryReference.current) {
return;
}
lastDebouncedQueryReference.current = trimmedQuery;
if (onSearch) {
emitSearch(trimmedQuery);
return;
}
// Check current URL to avoid unnecessary updates
const currentUrlParameter = searchParameters.get("search") ?? "";
// Skip if URL already matches the debounced query
if (trimmedQuery === currentUrlParameter) {
lastSetSearchParameterReference.current = trimmedQuery;
return;
}
const parameters = new URLSearchParams(searchParameters);
if (trimmedQuery) {
parameters.set("search", trimmedQuery);
} else {
parameters.delete("search");
}
const newUrl = parameters.toString();
lastSetSearchParameterReference.current = trimmedQuery;
// next/navigation router.replace is the canonical client-side
// navigation primitive in Next App Router. The react-doctor
// nextjs-no-client-side-redirect rule targets window.location
// hard redirects, not Next router.replace — but ESLint doesn't
// know that rule, so we don't add an eslint-disable for it.
router.replace(`?${newUrl}`);
}, [debouncedQuery, router, onSearch, searchParameters]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (typingTimeoutReference.current) {
clearTimeout(typingTimeoutReference.current);
}
};
}, []);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
isUserTyping.current = true;
setQuery(event.target.value);
// Clear existing timeout
if (typingTimeoutReference.current) {
clearTimeout(typingTimeoutReference.current);
}
// Reset typing flag after debounce delay + buffer
typingTimeoutReference.current = setTimeout(() => {
isUserTyping.current = false;
}, 350);
};
const handleSubmit = (event: React.SyntheticEvent) => {
event.preventDefault();
isUserTyping.current = false;
// Clear typing timeout
if (typingTimeoutReference.current) {
clearTimeout(typingTimeoutReference.current);
}
const trimmedQuery = query.trim();
if (onSearch) {
onSearch(trimmedQuery);
} else {
const parameters = new URLSearchParams(searchParameters);
if (trimmedQuery) {
parameters.set("search", trimmedQuery);
} else {
parameters.delete("search");
}
const newUrl = parameters.toString();
lastSetSearchParameterReference.current = trimmedQuery;
router.replace(`?${newUrl}`);
}
};
return (
<form className={`flex gap-2 ${className}`} onSubmit={handleSubmit}>
<Input
aria-label={placeholder}
className="flex-1"
onChange={handleInputChange}
placeholder={placeholder}
type="text"
value={query}
/>
<Button type="submit" variant="outline">
Search
</Button>
</form>
);
}
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.