{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropdown-search",
  "type": "registry:block",
  "title": "Algolia dropdown search experience",
  "description": "Inline autocomplete dropdown search experience, powered by Algolia's InstantSearch",
  "dependencies": [
    "algoliasearch@^5",
    "react-instantsearch@^7.16.2",
    "lucide-react",
    "@radix-ui/react-popover"
  ],
  "registryDependencies": [
    "input"
  ],
  "files": [
    {
      "path": "src/registry/experiences/dropdown-search/components/dropdown-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 * as Popover from \"@radix-ui/react-popover\";\nimport { liteClient as algoliasearch } from \"algoliasearch/lite\";\nimport type { BaseHit, Hit } from \"instantsearch.js\";\nimport { SearchIcon } from \"lucide-react\";\nimport type React from \"react\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  Configure,\n  Highlight,\n  InstantSearch,\n  useHits,\n  useSearchBox,\n} from \"react-instantsearch\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { useKeyboardNavigation } from \"@/registry/experiences/dropdown-search/hooks/use-keyboard-navigation\";\n\nexport interface DropdownSearchConfig {\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 \"Search...\") */\n  placeholder?: string;\n  /** Number of hits per page (optional, defaults to 5) */\n  hitsPerPage?: number;\n  /** Map which hit attributes to render (supports dotted paths) */\n  attributes?: HitsAttributesMapping;\n  /** Custom className for the root container */\n  className?: string;\n  /** Max height for dropdown (optional, defaults to \"300px\") */\n  maxHeight?: string;\n  /** Enable Algolia Insights (optional, defaults to true) */\n  insights?: boolean;\n  /** Additional Algolia search parameters (optional) - e.g., analytics, filters, distinct, etc. */\n  searchParameters?: Record<string, unknown>;\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 HitsListProps {\n  hits: Hit<BaseHit>[];\n  query: string;\n  selectedIndex: number;\n  attributes?: HitsAttributesMapping;\n  onItemClick?: () => void;\n  onHoverIndex?: (index: number) => void;\n  hoverEnabled?: boolean;\n  sendEvent?: (\n    eventType: \"click\",\n    hit: Hit<BaseHit>,\n    eventName: string,\n  ) => void;\n}\n\nconst HitsList = memo(function HitsList({\n  hits,\n  selectedIndex,\n  attributes,\n  onItemClick,\n  onHoverIndex,\n  hoverEnabled,\n  sendEvent,\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={url ? \"_blank\" : undefined}\n            rel=\"noopener noreferrer\"\n            onClick={() => {\n              sendEvent?.(\"click\", hit, \"Hit Clicked\");\n              onItemClick?.();\n            }}\n            className=\"flex flex-row items-center gap-3 cursor-pointer text-decoration-none text-foreground bg-background rounded-sm p-3 aria-selected:bg-accent transition-colors\"\n            role=\"option\"\n            aria-selected={isSel}\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-12 h-12 self-start flex-[0_0_48px] 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 size={16} />\n                  </div>\n                )}\n              </div>\n            ) : null}\n            <div className=\"flex-1 min-w-0\">\n              <p className=\"font-medium text-sm [&_mark]:bg-transparent [&_mark]:text-primary [&_mark]:font-semibold truncate\">\n                <Highlight\n                  attribute={toAttributePath(mapping.primaryText) as any}\n                  hit={hit}\n                />\n              </p>\n              {mapping.secondaryText ? (\n                <p className=\"text-xs mt-1 text-muted-foreground [&_mark]:bg-transparent [&_mark]:text-foreground [&_mark]:font-medium line-clamp-1\">\n                  <Highlight\n                    attribute={toAttributePath(mapping.secondaryText) as any}\n                    hit={hit}\n                  />\n                </p>\n              ) : null}\n              {mapping.tertiaryText ? (\n                <p className=\"text-xs text-muted-foreground [&_mark]:bg-transparent [&_mark]:text-foreground [&_mark]:font-medium mt-1 line-clamp-1\">\n                  <Highlight\n                    attribute={toAttributePath(mapping.tertiaryText) as any}\n                    hit={hit}\n                  />\n                </p>\n              ) : null}\n            </div>\n          </a>\n        );\n      })}\n    </>\n  );\n});\n\ninterface SearchInputProps {\n  placeholder?: string;\n  className?: string;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  onArrowDown?: () => void;\n  onEnter?: () => void;\n  onArrowUp?: () => void;\n}\n\nconst SearchInput = memo(function SearchInput(props: SearchInputProps) {\n  const { query, refine } = useSearchBox();\n  const [inputValue, setInputValue] = useState(query || \"\");\n\n  function setQuery(newQuery: string) {\n    setInputValue(newQuery);\n    refine(newQuery);\n  }\n\n  return (\n    <div className={cn(\"relative flex items-center\", props.className)}>\n      <SearchIcon\n        className=\"absolute left-3 h-4 w-4 text-muted-foreground pointer-events-none\"\n        strokeWidth={1.5}\n      />\n      <Input\n        ref={props.inputRef}\n        className=\"pl-9 pr-3\"\n        autoComplete=\"off\"\n        autoCorrect=\"off\"\n        autoCapitalize=\"off\"\n        placeholder={props.placeholder || \"Search...\"}\n        spellCheck={false}\n        inputMode=\"search\"\n        type=\"search\"\n        value={inputValue}\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      />\n    </div>\n  );\n});\n\ninterface DropdownContentProps {\n  query: string;\n  selectedIndex: number;\n  config: DropdownSearchConfig;\n  onItemClick?: () => void;\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 DropdownContent = memo(function DropdownContent({\n  query,\n  selectedIndex,\n  config,\n  onItemClick,\n  onHoverIndex,\n  scrollOnSelectionChange = true,\n  sendEvent,\n}: DropdownContentProps) {\n  const { items } = useHits();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const noResults = items.length === 0;\n  const [hoverEnabled, setHoverEnabled] = useState(false);\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  // Scroll selected item into view\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  const maxHeight = config.maxHeight || \"300px\";\n\n  if (noResults) {\n    return (\n      <div className=\"p-4 text-center text-sm text-muted-foreground\">\n        No results for &quot;{query}&quot;\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={containerRef}\n      className=\"overflow-y-auto\"\n      style={{ maxHeight }}\n      role=\"listbox\"\n    >\n      <HitsList\n        hits={items}\n        query={query}\n        selectedIndex={selectedIndex}\n        attributes={config.attributes}\n        onItemClick={onItemClick}\n        onHoverIndex={onHoverIndex}\n        hoverEnabled={hoverEnabled}\n        sendEvent={sendEvent}\n      />\n    </div>\n  );\n});\n\ninterface DropdownSearchInnerProps {\n  config: DropdownSearchConfig;\n}\n\nfunction DropdownSearchInner({ config }: DropdownSearchInnerProps) {\n  const { query, refine } = useSearchBox();\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const { items, sendEvent } = useHits();\n  const [open, setOpen] = useState(false);\n\n  const {\n    selectedIndex,\n    moveDown,\n    moveUp,\n    activateSelection,\n    hoverIndex,\n    selectionOrigin,\n  } = useKeyboardNavigation(items, query);\n\n  // Control popover open state based on query\n  useEffect(() => {\n    setOpen(!!query && query.length > 0);\n  }, [query]);\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      setOpen(false);\n      refine(\"\");\n      return true;\n    }\n    return false;\n  }, [activateSelection, refine, selectedIndex, items, sendEvent]);\n\n  const handleItemClick = useCallback(() => {\n    setOpen(false);\n    refine(\"\");\n  }, [refine]);\n\n  return (\n    <>\n      <Configure\n        hitsPerPage={config.hitsPerPage || 5}\n        {...(config.searchParameters || {})}\n      />\n      <Popover.Root open={open} onOpenChange={setOpen}>\n        <Popover.Trigger asChild>\n          <div className=\"w-full\">\n            <SearchInput\n              placeholder={config.placeholder}\n              className=\"w-full\"\n              inputRef={inputRef}\n              onArrowDown={moveDown}\n              onArrowUp={moveUp}\n              onEnter={handleActivateSelection}\n            />\n          </div>\n        </Popover.Trigger>\n        <Popover.Portal>\n          <Popover.Content\n            className=\"z-50 w-[var(--radix-popover-trigger-width)] mt-1 rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2\"\n            side=\"bottom\"\n            align=\"start\"\n            sideOffset={4}\n            onOpenAutoFocus={(e) => {\n              // Prevent auto-focusing the popover content, keep focus on input\n              e.preventDefault();\n            }}\n          >\n            {query ? (\n              <DropdownContent\n                query={query}\n                selectedIndex={selectedIndex}\n                config={config}\n                onItemClick={handleItemClick}\n                onHoverIndex={hoverIndex}\n                scrollOnSelectionChange={selectionOrigin !== \"pointer\"}\n                sendEvent={sendEvent}\n              />\n            ) : (\n              <div className=\"p-4 text-center text-sm text-muted-foreground\">\n                Start typing to search\n              </div>\n            )}\n            <div className=\"px-2 py-1 border-t border-text-muted-foreground text-right text-xs text-muted-foreground\">\n              Powered by Algolia\n            </div>\n          </Popover.Content>\n        </Popover.Portal>\n      </Popover.Root>\n    </>\n  );\n}\n\nexport default function DropdownSearchExperience(config: DropdownSearchConfig) {\n  const searchClient = algoliasearch(config.applicationId, config.apiKey);\n  searchClient.addAlgoliaAgent(\"algolia-sitesearch\");\n\n  return (\n    <div className={cn(\"relative w-full\", config.className)}>\n      <InstantSearch\n        searchClient={searchClient}\n        indexName={config.indexName}\n        future={{ preserveSharedStateOnUnmount: true }}\n        insights={config.insights ?? true}\n      >\n        <DropdownSearchInner config={config} />\n      </InstantSearch>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/registry/experiences/dropdown-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): 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      window.open(url, \"_blank\", \"noopener,noreferrer\");\n      return true;\n    }\n    return false;\n  }, [selectedIndex, hits]);\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/dropdown-search/page.tsx",
      "content": "\"use client\";\n\nimport DropdownSearch from \"@/registry/experiences/dropdown-search/components/dropdown-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      <div className=\"w-full max-w-md\">\n        <DropdownSearch\n          applicationId=\"06YAZFOHSQ\"\n          apiKey=\"94b6afdc316917b6e6cdf2763fa561df\"\n          indexName=\"algolia_podcast_sample_dataset\"\n          placeholder=\"Search podcasts...\"\n          hitsPerPage={5}\n          attributes={{\n            primaryText: \"title\",\n            secondaryText: \"description\",\n            tertiaryText: \"itunesAuthor\",\n            image: \"imageUrl\",\n            url: \"url\",\n          }}\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/dropdown-search/page.tsx"
    }
  ],
  "categories": [
    "search"
  ]
}