{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search",
  "type": "registry:block",
  "title": "Algolia crafted search experience",
  "description": "Opinionated site search experience, powered by Algolia's InstantSearch",
  "dependencies": [
    "algoliasearch@^5",
    "react-instantsearch@^7.16.2",
    "lucide-react",
    "tw-animate-css"
  ],
  "devDependencies": [
    "tw-animate-css"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/experiences/search/components/search.tsx",
      "content": "/** biome-ignore-all lint/a11y/useSemanticElements: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useFocusableInteractive: hand crafted interactions */\n/** biome-ignore-all lint/suspicious/noExplicitAny: too ambiguous */\n/** biome-ignore-all lint/a11y/noStaticElementInteractions: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useKeyWithClickEvents: hand crafted interactions */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport { liteClient as algoliasearch } from \"algoliasearch/lite\";\nimport type { BaseHit, Hit } from \"instantsearch.js\";\nimport { ArrowDown, ArrowUp, CornerDownLeft, SearchIcon } from \"lucide-react\";\nimport type React from \"react\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  Configure,\n  Highlight,\n  InstantSearch,\n  useHits,\n  useInstantSearch,\n  useSearchBox,\n} from \"react-instantsearch\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useKeyboardNavigation } from \"@/registry/experiences/search/hooks/use-keyboard-navigation\";\n\nexport interface SearchConfig {\n  /** Algolia Application ID (required) */\n  applicationId: string;\n  /** Algolia API Key (required) */\n  apiKey: string;\n  /** Algolia Index Name (required) */\n  indexName: string;\n  /** Placeholder text for search input (optional, defaults to \"What are you looking for?\") */\n  placeholder?: string;\n  /** Number of hits per page (optional, defaults to 8) */\n  hitsPerPage?: number;\n  /** Custom search button text (optional) */\n  buttonText?: string;\n  /** Custom search button props (optional) */\n  buttonProps?: React.ComponentProps<typeof SearchButton>;\n  /** Map which hit attributes to render (supports dotted paths) */\n  attributes: HitsAttributesMapping;\n  /** Additional Algolia search parameters (optional) - e.g., analytics, filters, distinct, etc. */\n  searchParameters?: Record<string, unknown>;\n  /** Enable Algolia Insights (optional, defaults to true) */\n  insights?: boolean;\n  /** Open hit URLs in a new tab (optional, defaults to true) */\n  openResultsInNewTab?: boolean;\n  /** Transform items before rendering (optional) - useful for proxying images or modifying hit data */\n  transformItems?: (items: Hit<BaseHit>[]) => Hit<BaseHit>[];\n}\n\n// =========================================================================\n// Attribute Mapping\n// =========================================================================\n\ntype HitsAttributesMapping = {\n  primaryText: string;\n  secondaryText?: string;\n  tertiaryText?: string;\n  url?: string;\n  image?: string;\n};\n\nfunction toAttributePath(attribute?: string): string | string[] | undefined {\n  if (!attribute) return undefined;\n  return attribute.includes(\".\") ? attribute.split(\".\") : attribute;\n}\n\nfunction getByPath<T = unknown>(obj: unknown, path?: string): T | undefined {\n  if (!obj || !path) return undefined;\n  const parts = path.split(\".\");\n  let current: unknown = obj;\n  for (const part of parts) {\n    if (current == null || typeof current !== \"object\") return undefined;\n    current = (current as Record<string, unknown>)[part];\n  }\n  return current as T | undefined;\n}\n\n// ============================================================================\n// Internal Components\n// ============================================================================\n\ninterface SearchButtonProps extends React.ComponentProps<typeof Button> {}\n\nexport const SearchButton: React.FC<SearchButtonProps> = ({\n  className,\n  ...buttonProps\n}) => {\n  const [modifierLabel, setModifierLabel] = useState(\"⌘\");\n  const [isModifierPressed, setIsModifierPressed] = useState(false);\n  const [isKPressed, setIsKPressed] = useState(false);\n\n  useEffect(() => {\n    if (typeof navigator === \"undefined\") return;\n    const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);\n    setModifierLabel(isMac ? \"⌘\" : \"Ctrl\");\n  }, []);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.metaKey || event.ctrlKey) {\n        setIsModifierPressed(true);\n      }\n      if (event.key.toLowerCase() === \"k\") {\n        setIsKPressed(true);\n      }\n    };\n\n    const handleKeyUp = (event: KeyboardEvent) => {\n      if (!event.metaKey && !event.ctrlKey) {\n        setIsModifierPressed(false);\n      }\n      if (event.key.toLowerCase() === \"k\") {\n        setIsKPressed(false);\n      }\n    };\n\n    const resetKeys = () => {\n      setIsModifierPressed(false);\n      setIsKPressed(false);\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    document.addEventListener(\"keyup\", handleKeyUp);\n    window.addEventListener(\"blur\", resetKeys);\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n      document.removeEventListener(\"keyup\", handleKeyUp);\n      window.removeEventListener(\"blur\", resetKeys);\n    };\n  }, []);\n\n  const baseClassName =\n    \"md:min-w-[200px] justify-between hover:shadow-md transition-transform duration-400 translate-y-0 py-3 h-auto cursor-pointer hover:bg-transparent hover:translate-y-[-2px] border shadow-none\";\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      className={cn(baseClassName, className)}\n      aria-label=\"Open search\"\n      {...buttonProps}\n    >\n      <span className=\"flex items-center gap-2 text-muted-foreground opacity-80\">\n        <SearchIcon size={24} color=\"currentColor\" />\n        <span className=\"hidden sm:inline\">Search</span>\n      </span>\n      <div className=\"hidden md:flex gap-0.5\">\n        <kbd\n          className={`h-5 min-w-5 rounded grid place-items-center bg-muted text-xs text-muted-foreground transition-all duration-200 ${\n            isModifierPressed\n              ? \"inset-shadow-sm inset-shadow-foreground/30\"\n              : \"shadow-none\"\n          }`}\n        >\n          {modifierLabel}\n        </kbd>\n        <kbd\n          className={`h-5 min-w-5 rounded grid place-items-center bg-muted text-xs text-muted-foreground transition-all duration-200 ${\n            isKPressed\n              ? \"inset-shadow-sm inset-shadow-foreground/30\"\n              : \"shadow-none\"\n          }`}\n        >\n          K\n        </kbd>\n      </div>\n    </Button>\n  );\n};\n\n// Logo Component\nconst AlgoliaLogo = ({ size = 150 }: { size?: number | string }) => (\n  <svg\n    width=\"80\"\n    height=\"24\"\n    aria-label=\"Algolia\"\n    role=\"img\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 2196.2 500\"\n    style={{ maxWidth: size }}\n  >\n    <defs>\n      {/* eslint-disable-nextLine @docusaurus/no-untranslated-text */}\n      <style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fillRule:evenodd}`}</style>\n    </defs>\n    <path\n      className=\"cls-2\"\n      d=\"M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z\"\n    />\n    <rect\n      className=\"cls-1\"\n      x=\"1845.88\"\n      y=\"104.73\"\n      width=\"62.58\"\n      height=\"277.9\"\n      rx=\"5.9\"\n      ry=\"5.9\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z\"\n    />\n    <path\n      className=\"cls-1\"\n      d=\"M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z\"\n    />\n  </svg>\n);\n\n// Modal Component\ninterface ModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  children: React.ReactNode;\n}\n\nconst Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {\n  useEffect(() => {\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        onClose();\n      }\n    };\n\n    if (isOpen) {\n      document.addEventListener(\"keydown\", handleEscape);\n      document.body.style.overflow = \"hidden\";\n    }\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleEscape);\n      document.body.style.overflow = \"unset\";\n    };\n  }, [isOpen, onClose]);\n\n  if (!isOpen) return null;\n\n  return createPortal(\n    <div\n      className=\"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-start justify-center md:pt-[10vh] dark:bg-black/60\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-background md:rounded-xl shadow-2xl w-full md:w-[90%] max-w-full md:max-w-[720px] h-full md:h-auto md:max-h-[80vh] overflow-hidden animate-in fade-in-0 zoom-in-95\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {children}\n      </div>\n    </div>,\n    document.body,\n  );\n};\n\n// HitsList Component\ninterface HitsListProps {\n  hits: Hit<BaseHit>[];\n  query: string;\n  selectedIndex: number;\n  attributes: HitsAttributesMapping;\n  onHoverIndex?: (index: number) => void;\n  hoverEnabled?: boolean;\n  sendEvent?: (\n    eventType: \"click\",\n    hit: Hit<BaseHit>,\n    eventName: string,\n  ) => void;\n  openResultsInNewTab?: boolean;\n}\n\nconst HitsList = memo(function HitsList({\n  hits,\n  selectedIndex,\n  attributes,\n  onHoverIndex,\n  hoverEnabled,\n  sendEvent,\n  openResultsInNewTab = true,\n}: HitsListProps) {\n  const [failedImages, setFailedImages] = useState<Record<string, boolean>>({});\n  const mapping = useMemo(\n    () => ({\n      primaryText: attributes.primaryText,\n      secondaryText: attributes.secondaryText,\n      tertiaryText: attributes.tertiaryText,\n      url: attributes.url,\n      image: attributes.image,\n    }),\n    [attributes],\n  );\n\n  if (!attributes || !mapping.primaryText) {\n    throw new Error(\"At least a primaryText is required to display results\");\n  }\n\n  return (\n    <>\n      {hits.map((hit: any, idx: number) => {\n        const isSel = selectedIndex === idx;\n        const imageUrl = getByPath<string>(hit, mapping.image);\n        const url = getByPath<string>(hit, mapping.url);\n        const hasImage = Boolean(imageUrl);\n        const isImageFailed = failedImages[hit.objectID] || !hasImage;\n        const primaryVal = getByPath<string>(hit, mapping.primaryText);\n        return (\n          <a\n            key={hit.objectID}\n            href={url ?? \"#\"}\n            target={openResultsInNewTab && url ? \"_blank\" : undefined}\n            rel={openResultsInNewTab && url ? \"noopener noreferrer\" : undefined}\n            className=\"flex flex-row items-center gap-4 cursor-pointer text-decoration-none text-foreground bg-background rounded-sm p-4 aria-selected:bg-blue-50 dark:aria-selected:bg-slate-900 animate-in fade-in-0 zoom-in-95\"\n            role=\"option\"\n            aria-selected={isSel}\n            onClick={() => {\n              sendEvent?.(\"click\", hit, \"Hit Clicked\");\n            }}\n            onMouseEnter={() => {\n              if (!hoverEnabled) return;\n              onHoverIndex?.(idx);\n            }}\n            onMouseMove={() => {\n              if (!hoverEnabled) return;\n              onHoverIndex?.(idx);\n            }}\n          >\n            {hasImage ? (\n              <div className=\"w-[100px] h-[100px] self-start flex-[0_0_100px] items-center justify-center overflow-hidden rounded-sm bg-muted\">\n                {!isImageFailed ? (\n                  <img\n                    src={imageUrl as string}\n                    alt={primaryVal || \"\"}\n                    className=\"w-full h-full object-contain rounded-sm\"\n                    onError={() =>\n                      setFailedImages((prev) => ({\n                        ...prev,\n                        [hit.objectID]: true,\n                      }))\n                    }\n                  />\n                ) : (\n                  <div\n                    className=\"flex items-center justify-center w-full h-full text-muted-foreground\"\n                    aria-hidden=\"true\"\n                  >\n                    <SearchIcon />\n                  </div>\n                )}\n              </div>\n            ) : null}\n            <div>\n              <p className=\"font-medium [&_mark]:bg-transparent [&_mark]:text-secondary-foreground [&_mark]:underline [&_mark]:underline-offset-4\">\n                <Highlight\n                  attribute={toAttributePath(mapping.primaryText) as any}\n                  hit={hit}\n                />\n              </p>\n              {mapping.secondaryText ? (\n                <p className=\"text-sm mt-2 text-muted-foreground\">\n                  {getByPath<string | number>(hit, mapping.secondaryText)}\n                </p>\n              ) : null}\n              {mapping.tertiaryText ? (\n                <p className=\"text-sm text-muted-foreground mt-2\">\n                  {getByPath<string | number>(hit, mapping.tertiaryText)}\n                </p>\n              ) : null}\n            </div>\n          </a>\n        );\n      })}\n    </>\n  );\n});\n\n// SearchInput Component\nexport interface SearchInputProps {\n  placeholder?: string;\n  className?: string;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  onClose: () => void;\n  onArrowDown?: () => void;\n  onEnter?: () => void;\n  onArrowUp?: () => void;\n}\n\nconst SearchInput = memo(function SearchInput(props: SearchInputProps) {\n  const { status } = useInstantSearch();\n  const { query, refine } = useSearchBox();\n\n  const isSearchStalled = status === \"stalled\";\n\n  function setQuery(newQuery: string) {\n    refine(newQuery);\n  }\n\n  const placeholder = props.placeholder;\n\n  return (\n    <search\n      className={props.className}\n      onSubmit={(event) => {\n        event.preventDefault();\n        event.stopPropagation();\n      }}\n      onReset={(event) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        setQuery(\"\");\n        if (props.inputRef.current) {\n          props.inputRef.current.focus();\n        }\n      }}\n    >\n      <div\n        role=\"button\"\n        tabIndex={-1}\n        className=\"p-2 rounded-full flex items-center justify-center transition-colors text-muted-foreground peer-focus:text-[#003dff]\"\n        aria-label=\"Search\"\n        title=\"Search\"\n      >\n        <SearchIcon color=\"currentColor\" strokeWidth={1.5} />\n      </div>\n      <input\n        ref={props.inputRef}\n        className=\"peer w-[90%] outline-none bg-transparent border-nonetext-foreground text-xl font-light peer [::-webkit-search-decoration]:appearance-none [::-webkit-search-cancel-button]:appearance-none [::-webkit-search-results-button]:appearance-none[::-webkit-search-results-decoration]:appearance-none\"\n        autoComplete=\"off\"\n        autoCorrect=\"off\"\n        autoCapitalize=\"off\"\n        placeholder={placeholder}\n        spellCheck={false}\n        inputMode=\"search\"\n        id=\"algolia-search-input\"\n        name=\"algolia-search-input\"\n        maxLength={512}\n        type=\"search\"\n        value={query}\n        onChange={(event) => {\n          setQuery(event.currentTarget.value);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\") {\n            e.preventDefault();\n            props.onArrowDown?.();\n            return;\n          }\n          if (e.key === \"ArrowUp\") {\n            e.preventDefault();\n            props.onArrowUp?.();\n            return;\n          }\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            props.onEnter?.();\n          }\n        }}\n        // biome-ignore lint/a11y/noAutofocus: expected\n        autoFocus\n      />\n      <div className=\"flex items-center gap-2 ml-auto\">\n        <Button\n          type=\"reset\"\n          variant=\"ghost\"\n          className=\"px-2 text-muted-foreground\"\n          hidden={!query || query.length === 0 || isSearchStalled}\n          onClick={() => {\n            setQuery(\"\");\n            if (props.inputRef.current) {\n              props.inputRef.current.focus();\n            }\n          }}\n        >\n          Clear\n        </Button>\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          className=\"px-2 text-muted-foreground\"\n          onClick={props.onClose}\n        >\n          esc\n        </Button>\n      </div>\n    </search>\n  );\n});\n\n// ============================================================================\n// Main Search Components\n// ============================================================================\n\ninterface SearchBoxProps {\n  query?: string;\n  className?: string;\n  placeholder?: string;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  refine: (query: string) => void;\n  onClose?: () => void;\n  onArrowDown?: () => void;\n  onArrowUp?: () => void;\n  onEnter?: () => void;\n}\n\nconst SearchBox = memo(function SearchBox(props: SearchBoxProps) {\n  return (\n    <SearchInput\n      className={props.className}\n      placeholder={props.placeholder}\n      inputRef={props.inputRef}\n      onClose={props.onClose || (() => {})}\n      onEnter={props.onEnter}\n      onArrowDown={props.onArrowDown}\n      onArrowUp={props.onArrowUp}\n    />\n  );\n});\n\ninterface NoResultsProps {\n  query: string;\n  onClear: () => void;\n}\n\nconst NoResults = memo(function NoResults({ query, onClear }: NoResultsProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-2 bg-muted p-4 h-[91vh] md:h-[50vh] text-foreground\">\n      <div className=\"flex items-center p-2 justify-center w-10 h-10 rounded-full border-muted-foreground border\">\n        <SearchIcon />\n      </div>\n      <p className=\"text-lg font-medium\">No results for &quot;{query}&quot;</p>\n      <p className=\"text-sm text-muted-foreground\">\n        Try a different query or ask AI to help.\n      </p>\n      <div className=\"flex items-center gap-2\">\n        <Button variant=\"outline\" onClick={onClear}>\n          Clear query\n        </Button>\n      </div>\n    </div>\n  );\n});\n\ninterface ResultsPanelProps {\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  query: string;\n  selectedIndex: number;\n  refine: (query: string) => void;\n  config: SearchConfig;\n  onHoverIndex?: (index: number) => void;\n  scrollOnSelectionChange?: boolean;\n  sendEvent?: (\n    eventType: \"click\",\n    hit: Hit<BaseHit>,\n    eventName: string,\n  ) => void;\n}\n\nconst ResultsPanel = memo(function ResultsPanel({\n  query,\n  selectedIndex,\n  config,\n  onHoverIndex,\n  scrollOnSelectionChange = true,\n  sendEvent,\n}: ResultsPanelProps) {\n  const { items } = useHits(\n    config.transformItems ? { transformItems: config.transformItems } : {},\n  );\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [hoverEnabled, setHoverEnabled] = useState(false);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n  useEffect(() => {\n    if (!scrollOnSelectionChange) return;\n    const container = containerRef.current;\n    if (!container) return;\n    const selectedEl = container.querySelector(\n      '[aria-selected=\"true\"]',\n    ) as HTMLElement | null;\n    if (!selectedEl) return;\n\n    const padding = 8;\n    const cRect = container.getBoundingClientRect();\n    const iRect = selectedEl.getBoundingClientRect();\n\n    if (iRect.top < cRect.top + padding) {\n      container.scrollTop -= cRect.top + padding - iRect.top;\n    } else if (iRect.bottom > cRect.bottom - padding) {\n      container.scrollTop += iRect.bottom - (cRect.bottom - padding);\n    }\n  }, [selectedIndex, items.length, scrollOnSelectionChange]);\n\n  // Enable hover selection only after the user moves the pointer inside the list\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n    setHoverEnabled(false);\n    const enable = () => setHoverEnabled(true);\n    container.addEventListener(\"pointermove\", enable, { once: true } as any);\n    return () => {\n      container.removeEventListener(\"pointermove\", enable as any);\n    };\n  }, []);\n\n  return (\n    <>\n      <div\n        ref={containerRef}\n        className=\"flex flex-col h-[91vh] md:h-[50vh] bg-muted gap-4 p-2 overflow-y-auto\"\n        role=\"listbox\"\n      >\n        <HitsList\n          hits={items}\n          query={query}\n          selectedIndex={selectedIndex}\n          attributes={config.attributes}\n          onHoverIndex={onHoverIndex}\n          hoverEnabled={hoverEnabled}\n          sendEvent={sendEvent}\n          openResultsInNewTab={config.openResultsInNewTab}\n        />\n      </div>\n    </>\n  );\n});\n\ninterface SearchModalProps {\n  onClose?: () => void;\n  config: SearchConfig;\n}\n\nexport function SearchModal({ onClose, config }: SearchModalProps) {\n  const { query, refine } = useSearchBox();\n  const inputRef = useRef<HTMLInputElement | null>(null);\n\n  const results = useInstantSearch();\n  const { items, sendEvent } = useHits(\n    config.transformItems ? { transformItems: config.transformItems } : {},\n  );\n\n  const noResults = results.results?.nbHits === 0;\n  const {\n    selectedIndex,\n    moveDown,\n    moveUp,\n    activateSelection,\n    hoverIndex,\n    selectionOrigin,\n  } = useKeyboardNavigation(items, query, config.openResultsInNewTab ?? true);\n\n  const handleActivateSelection = useCallback((): boolean => {\n    // Send click event for keyboard navigation before activating\n    if (selectedIndex >= 0 && selectedIndex < items.length) {\n      const hit = items[selectedIndex];\n      if (hit) {\n        sendEvent?.(\"click\", hit, \"Hit Clicked\");\n      }\n    }\n\n    if (activateSelection()) {\n      return true;\n    }\n    return false;\n  }, [activateSelection, selectedIndex, items, sendEvent]);\n\n  const showResultsPanel = !noResults && !!query;\n\n  return (\n    <>\n      <Configure\n        hitsPerPage={config.hitsPerPage || 8}\n        {...config.searchParameters}\n      />\n      <div className=\"flex flex-col\">\n        <SearchBox\n          query={query}\n          placeholder={config.placeholder || \"What are you looking for?\"}\n          className=\"flex flex-row items-center bg-background border-b border-muted rounded-t-sm p-2 placeholder:text-muted-foreground\"\n          refine={refine}\n          onClose={onClose}\n          onArrowDown={moveDown}\n          onArrowUp={moveUp}\n          inputRef={inputRef}\n          onEnter={handleActivateSelection}\n        />\n        {showResultsPanel && (\n          <ResultsPanel\n            inputRef={inputRef}\n            query={query}\n            selectedIndex={selectedIndex}\n            refine={refine}\n            config={config}\n            onHoverIndex={hoverIndex}\n            scrollOnSelectionChange={selectionOrigin !== \"pointer\"}\n            sendEvent={sendEvent}\n          />\n        )}\n        {noResults && query && (\n          <NoResults\n            query={query}\n            onClear={() => {\n              refine(\"\");\n              if (inputRef.current) {\n                inputRef.current.focus();\n              }\n            }}\n          />\n        )}\n      </div>\n      <Footer />\n    </>\n  );\n}\n\nconst Footer = memo(function Footer() {\n  const basePoweredByUrl =\n    \"https://www.algolia.com/developers?utm_medium=referral&utm_content=powered_by&utm_campaign=sitesearch\";\n  const poweredByHref =\n    typeof window !== \"undefined\"\n      ? `${basePoweredByUrl}&utm_source=${encodeURIComponent(window.location.hostname)}`\n      : basePoweredByUrl;\n  return (\n    <div className=\"flex items-center justify-between bg-background rounded-b-sm p-4\">\n      <div className=\"inline-flex items-center gap-4 text-sm\">\n        <div className=\"flex items-center gap-2\">\n          <kbd className=\"bg-muted rounded-sm h-6 flex items-center justify-center p-1 text-muted-foreground\">\n            <CornerDownLeft size={20} color=\"currentColor\" />\n          </kbd>\n          <span className=\"text-muted-foreground\">Open</span>\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <kbd className=\"bg-muted rounded-sm h-6 flex items-center justify-center p-1 text-muted-foreground\">\n            <ArrowUp size={20} color=\"currentColor\" />\n          </kbd>\n          <kbd className=\"bg-muted rounded-sm h-6 flex items-center justify-center p-1 text-muted-foreground\">\n            <ArrowDown size={20} color=\"currentColor\" />\n          </kbd>\n          <span className=\"text-muted-foreground\">Navigate</span>\n        </div>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        {/* 🚧 DO NOT REMOVE the logo if you are on a Free plan\n         * https://support.algolia.com/hc/en-us/articles/17226079853073-Is-displaying-the-Algolia-logo-required\n         */}\n        <a\n          className=\"flex items-center gap-2 text-muted-foreground text-sm no-underline transition-colors hover:text-primary\"\n          href={poweredByHref}\n          target=\"_blank\"\n          rel=\"noopener sitesearch\"\n        >\n          <span className=\"md:block hidden\">Powered by</span>\n          <AlgoliaLogo />\n        </a>\n      </div>\n    </div>\n  );\n});\n\nexport default function SearchExperience(config: SearchConfig) {\n  const searchClient = algoliasearch(config.applicationId, config.apiKey);\n  searchClient.addAlgoliaAgent(\"algolia-sitesearch\");\n\n  const [isModalOpen, setIsModalOpen] = useState(false);\n\n  const openModal = () => setIsModalOpen(true);\n  const closeModal = () => setIsModalOpen(false);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"k\") {\n        event.preventDefault();\n        setIsModalOpen(true);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, []);\n\n  const buttonProps = {\n    ...config.buttonProps,\n    onClick: openModal,\n  };\n\n  return (\n    <>\n      <SearchButton {...buttonProps}>{config.buttonText}</SearchButton>\n      <Modal isOpen={isModalOpen} onClose={closeModal}>\n        <InstantSearch\n          searchClient={searchClient}\n          indexName={config.indexName}\n          future={{ preserveSharedStateOnUnmount: true }}\n          insights={config.insights ?? true}\n        >\n          <SearchModal onClose={closeModal} config={config} />\n        </InstantSearch>\n      </Modal>\n    </>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/registry/experiences/search/hooks/use-keyboard-navigation.ts",
      "content": "import type { BaseHit, Hit } from \"instantsearch.js\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\ninterface UseKeyboardNavigationReturn {\n  selectedIndex: number;\n  moveDown: () => void;\n  moveUp: () => void;\n  activateSelection: () => boolean;\n  hoverIndex: (index: number) => void;\n  selectionOrigin: \"keyboard\" | \"pointer\" | \"init\";\n}\n\nexport function useKeyboardNavigation(\n  hits: Hit<BaseHit>[],\n  query: string,\n  openResultsInNewTab = true,\n): UseKeyboardNavigationReturn {\n  const [selectedIndex, setSelectedIndex] = useState<number>(0);\n  const [selectionOrigin, setSelectionOrigin] = useState<\n    \"keyboard\" | \"pointer\" | \"init\"\n  >(\"init\");\n\n  const totalItems = useMemo(() => hits.length, [hits.length]);\n\n  const moveDown = useCallback(() => {\n    setSelectedIndex((prev) => (prev + 1) % totalItems);\n    setSelectionOrigin(\"keyboard\");\n  }, [totalItems]);\n\n  const moveUp = useCallback(() => {\n    setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);\n    setSelectionOrigin(\"keyboard\");\n  }, [totalItems]);\n\n  const hoverIndex = useCallback(\n    (index: number) => {\n      if (index < 0 || index >= totalItems) return;\n      setSelectedIndex(index);\n      setSelectionOrigin(\"pointer\");\n    },\n    [totalItems],\n  );\n\n  const activateSelection = useCallback((): boolean => {\n    const hit = hits[selectedIndex];\n    const url = typeof hit?.url === \"string\" ? hit.url : undefined;\n    if (url) {\n      if (openResultsInNewTab) {\n        window.open(url, \"_blank\", \"noopener,noreferrer\");\n      } else {\n        window.location.assign(url);\n      }\n      return true;\n    }\n    return false;\n  }, [selectedIndex, hits, openResultsInNewTab]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n  useEffect(() => {\n    setSelectedIndex(0);\n    setSelectionOrigin(\"init\");\n  }, [query]);\n\n  return {\n    selectedIndex,\n    moveDown,\n    moveUp,\n    activateSelection,\n    hoverIndex,\n    selectionOrigin,\n  };\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "src/registry/experiences/search/page.tsx",
      "content": "\"use client\";\n\nimport Search from \"@/registry/experiences/search/components/search\";\n\nexport default function Page() {\n  return (\n    <div className=\"flex items-center justify-center min-h-[100px] md:min-h-[400px] relative p-4\">\n      <Search\n        applicationId=\"06YAZFOHSQ\"\n        apiKey=\"94b6afdc316917b6e6cdf2763fa561df\"\n        indexName=\"algolia_podcast_sample_dataset\"\n        attributes={{\n          primaryText: \"title\",\n          secondaryText: \"description\",\n          tertiaryText: \"itunesAuthor\",\n          image: \"imageUrl\",\n          url: \"url\",\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/search/page.tsx"
    }
  ],
  "css": {
    "@import \"tw-animate-css\"": {},
    "input[type=\"search\"]::-webkit-search-decoration,input[type=\"search\"]::-webkit-search-cancel-button,input[type=\"search\"]::-webkit-search-results-button,input[type=\"search\"]::-webkit-search-results-decoration": "-webkit-appearance: none;"
  },
  "categories": [
    "search"
  ]
}