{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"A keyboard-driven search field that shows each result's favicon, with a combobox listbox and highlighted matches.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Search } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { SPRING_DEFAULT } from \"@/components/smoothui/lib/animation\";\n\nconst DEFAULT_SKELETON_ROWS = 4;\nconst STAGGER_DELAY_S = 0.03;\nconst SKELETON_ROW_IDS = Array.from(\n  { length: DEFAULT_SKELETON_ROWS },\n  (_, index) => `skeleton-${index}`\n);\n\nexport interface FaviconSearchResult {\n  description?: string;\n  favicon?: string;\n  id: string;\n  title: string;\n  url: string;\n}\n\nexport interface FaviconSearchProps {\n  className?: string;\n  emptyMessage?: string;\n  groupLabel?: string;\n  hotkey?: string;\n  loading?: boolean;\n  maxResults?: number;\n  onSelect?: (result: FaviconSearchResult) => void;\n  onValueChange?: (value: string) => void;\n  results: FaviconSearchResult[];\n  showShortcut?: boolean;\n  value?: string;\n}\n\nconst getFaviconSrc = (result: FaviconSearchResult): string | undefined => {\n  if (result.favicon) {\n    return result.favicon;\n  }\n  try {\n    const host = new URL(result.url).hostname;\n    return `https://www.google.com/s2/favicons?domain=${host}&sz=64`;\n    // biome-ignore lint/suspicious/noEmptyBlockStatements: an invalid result.url intentionally falls through to the monogram tile\n  } catch {}\n};\n\nconst highlightMatch = (text: string, query: string): ReactNode => {\n  if (!query) {\n    return text;\n  }\n  const index = text.toLowerCase().indexOf(query.toLowerCase());\n  if (index === -1) {\n    return text;\n  }\n  const before = text.slice(0, index);\n  const match = text.slice(index, index + query.length);\n  const after = text.slice(index + query.length);\n  return (\n    <>\n      {before}\n      <mark className=\"rounded-sm bg-brand/20 text-foreground\">{match}</mark>\n      {after}\n    </>\n  );\n};\n\ninterface FaviconTileProps {\n  result: FaviconSearchResult;\n}\n\nconst FaviconTile = ({ result }: FaviconTileProps) => {\n  const [hasError, setHasError] = useState(false);\n  const src = hasError ? undefined : getFaviconSrc(result);\n\n  if (!src) {\n    return (\n      <span\n        aria-hidden=\"true\"\n        className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted font-semibold text-[10px] text-muted-foreground uppercase\"\n      >\n        {result.title.charAt(0)}\n      </span>\n    );\n  }\n\n  return (\n    <img\n      alt=\"\"\n      aria-hidden=\"true\"\n      className=\"h-6 w-6 shrink-0 rounded\"\n      loading=\"lazy\"\n      onError={() => setHasError(true)}\n      src={src}\n    />\n  );\n};\n\nexport default function FaviconSearch({\n  results,\n  value: valueProp,\n  onValueChange,\n  onSelect,\n  loading = false,\n  emptyMessage = \"No results found\",\n  groupLabel = \"Search results\",\n  showShortcut = false,\n  hotkey = \"k\",\n  maxResults,\n  className,\n}: FaviconSearchProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const uid = useId();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const isControlled = valueProp !== undefined;\n  const [internalValue, setInternalValue] = useState(\"\");\n  const value = isControlled ? (valueProp as string) : internalValue;\n\n  const [isOpen, setIsOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(-1);\n\n  const displayedResults =\n    typeof maxResults === \"number\" ? results.slice(0, maxResults) : results;\n\n  const listboxId = `${uid}-listbox`;\n  const activeId =\n    activeIndex >= 0 && displayedResults[activeIndex]\n      ? `${uid}-option-${activeIndex}`\n      : undefined;\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: value/loading intentionally reset the highlighted index whenever the result set changes\n  useEffect(() => {\n    setActiveIndex(-1);\n  }, [value, loading]);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        containerRef.current &&\n        !containerRef.current.contains(event.target as Node)\n      ) {\n        setIsOpen(false);\n      }\n    };\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n  }, []);\n\n  useEffect(() => {\n    if (!showShortcut) {\n      return;\n    }\n    const handleHotkey = (event: globalThis.KeyboardEvent) => {\n      const isMeta = event.metaKey || event.ctrlKey;\n      if (isMeta && event.key.toLowerCase() === hotkey.toLowerCase()) {\n        event.preventDefault();\n        inputRef.current?.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", handleHotkey);\n    return () => document.removeEventListener(\"keydown\", handleHotkey);\n  }, [showShortcut, hotkey]);\n\n  const handleChange = (next: string) => {\n    if (!isControlled) {\n      setInternalValue(next);\n    }\n    onValueChange?.(next);\n  };\n\n  const handleSelect = (result: FaviconSearchResult) => {\n    onSelect?.(result);\n    setIsOpen(false);\n    setActiveIndex(-1);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Escape\") {\n      setIsOpen(false);\n      setActiveIndex(-1);\n      return;\n    }\n    if (!isOpen && (event.key === \"ArrowDown\" || event.key === \"ArrowUp\")) {\n      setIsOpen(true);\n      return;\n    }\n    if (displayedResults.length === 0) {\n      return;\n    }\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      setActiveIndex((prev) => (prev + 1) % displayedResults.length);\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      setActiveIndex(\n        (prev) => (prev - 1 + displayedResults.length) % displayedResults.length\n      );\n    } else if (event.key === \"Enter\" && activeIndex >= 0) {\n      event.preventDefault();\n      const result = displayedResults[activeIndex];\n      if (result) {\n        handleSelect(result);\n      }\n    }\n  };\n\n  const showPanel =\n    isOpen && (loading || displayedResults.length > 0 || value.length > 0);\n\n  const renderSkeletonRows = () =>\n    SKELETON_ROW_IDS.map((rowId) => (\n      <div\n        aria-hidden=\"true\"\n        className=\"flex items-center gap-3 px-4 py-2\"\n        key={rowId}\n      >\n        <span\n          className={cn(\n            \"h-6 w-6 shrink-0 rounded bg-muted\",\n            !shouldReduceMotion && \"animate-pulse\"\n          )}\n        />\n        <span className=\"flex-1 space-y-1.5\">\n          <span\n            className={cn(\n              \"block h-3 w-2/3 rounded bg-muted\",\n              !shouldReduceMotion && \"animate-pulse\"\n            )}\n          />\n          <span\n            className={cn(\n              \"block h-2.5 w-1/3 rounded bg-muted\",\n              !shouldReduceMotion && \"animate-pulse\"\n            )}\n          />\n        </span>\n      </div>\n    ));\n\n  const renderResultRow = (result: FaviconSearchResult, index: number) => {\n    const isActive = index === activeIndex;\n    return (\n      <motion.div\n        animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n        aria-selected={isActive}\n        exit={\n          shouldReduceMotion\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { opacity: 0 }\n        }\n        id={`${uid}-option-${index}`}\n        initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 6 }}\n        key={result.id}\n        role=\"option\"\n        transition={\n          shouldReduceMotion\n            ? { duration: 0 }\n            : { ...SPRING_DEFAULT, delay: index * STAGGER_DELAY_S }\n        }\n      >\n        <button\n          className={cn(\n            \"relative flex min-h-[44px] w-full items-center gap-3 px-4 text-left text-sm\",\n            !shouldReduceMotion && isActive && \"text-foreground\",\n            shouldReduceMotion && isActive && \"bg-muted\"\n          )}\n          onClick={() => handleSelect(result)}\n          onMouseEnter={() => setActiveIndex(index)}\n          type=\"button\"\n        >\n          {isActive && !shouldReduceMotion ? (\n            <motion.span\n              className=\"absolute inset-0 rounded-md bg-muted\"\n              layoutId={`${uid}-highlight`}\n              transition={SPRING_DEFAULT}\n            />\n          ) : null}\n          <span className=\"relative z-10\">\n            <FaviconTile result={result} />\n          </span>\n          <span className=\"relative z-10 min-w-0 flex-1\">\n            <span className=\"block truncate font-medium\">\n              {highlightMatch(result.title, value)}\n            </span>\n            {result.description ? (\n              <span className=\"block truncate text-muted-foreground text-xs\">\n                {highlightMatch(result.description, value)}\n              </span>\n            ) : null}\n          </span>\n        </button>\n      </motion.div>\n    );\n  };\n\n  const renderPanelBody = () => {\n    if (loading) {\n      return renderSkeletonRows();\n    }\n    if (displayedResults.length > 0) {\n      return (\n        <AnimatePresence mode=\"popLayout\">\n          {displayedResults.map((result, index) =>\n            renderResultRow(result, index)\n          )}\n        </AnimatePresence>\n      );\n    }\n    return (\n      <div className=\"px-4 py-8 text-center text-muted-foreground text-sm\">\n        {emptyMessage}\n      </div>\n    );\n  };\n\n  return (\n    <div className={cn(\"relative w-full\", className)} ref={containerRef}>\n      <div className=\"relative\">\n        <Search\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground\"\n        />\n        <input\n          aria-activedescendant={activeId}\n          aria-autocomplete=\"list\"\n          aria-controls={listboxId}\n          aria-expanded={isOpen}\n          aria-haspopup=\"listbox\"\n          aria-label=\"Search\"\n          className=\"min-h-[44px] w-full rounded-lg border bg-background pr-16 pl-9 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n          onChange={(event) => handleChange(event.target.value)}\n          onFocus={() => setIsOpen(true)}\n          onKeyDown={handleKeyDown}\n          placeholder=\"Search…\"\n          ref={inputRef}\n          role=\"combobox\"\n          type=\"text\"\n          value={value}\n        />\n        {showShortcut && value.length === 0 ? (\n          <kbd className=\"pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground\">\n            {`⌘${hotkey.toUpperCase()}`}\n          </kbd>\n        ) : null}\n      </div>\n\n      <p aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        {loading ? \"Loading results…\" : \"\"}\n      </p>\n\n      <AnimatePresence>\n        {showPanel ? (\n          <motion.div\n            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n            className=\"absolute z-50 mt-2 w-full overflow-hidden rounded-lg border bg-background shadow-lg\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, y: -4 }\n            }\n            initial={\n              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -4 }\n            }\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n          >\n            <div\n              aria-busy={loading}\n              aria-label={groupLabel}\n              className=\"max-h-80 overflow-y-auto py-2\"\n              id={listboxId}\n              role=\"listbox\"\n            >\n              {renderPanelBody()}\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/favicon-search/index.tsx","type":"registry:ui"}],"name":"favicon-search","registryDependencies":["https://smoothui.dev/r/lib.json","https://smoothui.dev/r/tokens.json"],"title":"Favicon Search","type":"registry:ui"}