Search Bar

Champ de recherche textuelle avec icone et fonction d'effacement. Installez Search Bar depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

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.json

Source

"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.

Dependances

  • @vllnt/ui@^0.3.0