{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-suggestions",
  "title": "Search Suggestions",
  "dependencies": [
    "@phosphor-icons/react"
  ],
  "registryDependencies": [
    "https://ui.hotfix.jobs/r/button.json",
    "https://ui.hotfix.jobs/r/input.json",
    "https://ui.hotfix.jobs/r/popover.json",
    "https://ui.hotfix.jobs/r/recipes.json",
    "https://ui.hotfix.jobs/r/spinner.json",
    "https://ui.hotfix.jobs/r/tokens.json",
    "https://ui.hotfix.jobs/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/blocks/search-suggestions/index.tsx",
      "content": "\"use client\";\n\nimport { MagnifyingGlass, X } from \"@phosphor-icons/react/dist/ssr\";\nimport { useId, useMemo, useRef, useState } from \"react\";\nimport type * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { itemGroupLabel, itemRow, selectionFocus } from \"@/lib/recipes\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n  MOBILE_MEDIA_QUERY,\n  useMediaQuery,\n} from \"../../hooks/use-media-query\";\n\nexport interface SearchSuggestion {\n  id: string;\n  label: React.ReactNode;\n  value: string;\n  description?: React.ReactNode;\n  icon?: React.ReactNode;\n  suffix?: React.ReactNode;\n  disabled?: boolean;\n  /**\n   * When set, the row renders a trailing remove control for dismissable\n   * entries. Rendered as a sibling of the option button, so the option\n   * stays a single valid control, and always visible for touch.\n   */\n  onRemove?: () => void;\n  /** Accessible label for the remove control, e.g. `Remove \"<entry>\"`. */\n  removeLabel?: string;\n  onAction?: () => void;\n  actionLabel?: string;\n  actionIcon?: React.ReactNode;\n}\n\nexport interface SearchSuggestionSection {\n  id: string;\n  label?: React.ReactNode;\n  suggestions: SearchSuggestion[];\n}\n\nexport interface SearchSuggestionsField {\n  id: string;\n  label: string;\n  placeholder?: string;\n  value: string;\n  icon?: React.ReactNode;\n  sections: SearchSuggestionSection[];\n  loading?: boolean;\n  emptyContent?: React.ReactNode;\n}\n\nexport interface SearchSuggestionsProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"onSubmit\"> {\n  fields: SearchSuggestionsField[];\n  activeField?: string;\n  defaultActiveField?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  onActiveFieldChange?: (fieldId: string) => void;\n  onFieldValueChange: (fieldId: string, value: string) => void;\n  onSuggestionSelect: (\n    fieldId: string,\n    suggestion: SearchSuggestion,\n  ) => void;\n  onSubmit: (values: Record<string, string>) => void;\n  submitLabel?: string;\n}\n\n/** Coordinated multi-field search with an anchored desktop panel and centered mobile panel. */\nexport function SearchSuggestions({\n  fields,\n  activeField: controlledActiveField,\n  defaultActiveField,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  onActiveFieldChange,\n  onFieldValueChange,\n  onSuggestionSelect,\n  onSubmit,\n  submitLabel = \"Search\",\n  className,\n  ...props\n}: SearchSuggestionsProps): React.ReactElement {\n  const generatedId = useId();\n  const anchorRef = useRef<HTMLDivElement>(null);\n  const isMobile = useMediaQuery(MOBILE_MEDIA_QUERY);\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);\n  const [uncontrolledActiveField, setUncontrolledActiveField] = useState(\n    defaultActiveField ?? fields[0]?.id ?? \"\",\n  );\n  const [activeIndex, setActiveIndex] = useState(-1);\n\n  const open = controlledOpen ?? uncontrolledOpen;\n  const activeFieldId =\n    controlledActiveField ?? uncontrolledActiveField ?? fields[0]?.id ?? \"\";\n  const activeField =\n    fields.find((field) => field.id === activeFieldId) ?? fields[0];\n  const suggestions = useMemo(\n    () => activeField?.sections.flatMap((section) => section.suggestions) ?? [],\n    [activeField],\n  );\n  const listboxId = `${generatedId}-listbox`;\n  const triggerId = `${generatedId}-trigger`;\n\n  const setOpen = (next: boolean) => {\n    if (controlledOpen === undefined) setUncontrolledOpen(next);\n    onOpenChange?.(next);\n    if (!next) setActiveIndex(-1);\n  };\n\n  const activateField = (fieldId: string) => {\n    if (controlledActiveField === undefined) setUncontrolledActiveField(fieldId);\n    onActiveFieldChange?.(fieldId);\n    setActiveIndex(-1);\n    setOpen(true);\n  };\n\n  const selectSuggestion = (suggestion: SearchSuggestion) => {\n    if (!activeField || suggestion.disabled) return;\n    onSuggestionSelect(activeField.id, suggestion);\n    setOpen(false);\n  };\n\n  const moveActive = (direction: 1 | -1) => {\n    if (suggestions.length === 0) return;\n    let next = activeIndex;\n    for (let attempts = 0; attempts < suggestions.length; attempts += 1) {\n      next = (next + direction + suggestions.length) % suggestions.length;\n      if (!suggestions[next]?.disabled) {\n        setActiveIndex(next);\n        return;\n      }\n    }\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!open) setOpen(true);\n      moveActive(event.key === \"ArrowDown\" ? 1 : -1);\n      return;\n    }\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      const suggestion = suggestions[activeIndex];\n      if (suggestion) selectSuggestion(suggestion);\n      else onSubmit(Object.fromEntries(fields.map((field) => [field.id, field.value])));\n      return;\n    }\n    if (event.key === \"Escape\" && open) {\n      event.preventDefault();\n      setOpen(false);\n    }\n  };\n\n  if (!activeField) {\n    return <div className={className} {...props} />;\n  }\n\n  return (\n      <Popover\n        open={open}\n        triggerId={triggerId}\n        onOpenChange={(next, details) => {\n          if (details.reason === \"trigger-press\") return;\n          const target = details.event.target;\n          if (\n            !next &&\n            target instanceof Node &&\n            anchorRef.current?.contains(target)\n          ) {\n            return;\n          }\n          setOpen(next);\n        }}\n      >\n        <div\n          ref={anchorRef}\n          data-slot=\"search-suggestions\"\n          data-open={open || undefined}\n          className={cn(\"relative w-full\", className)}\n          {...props}\n        >\n          <div className=\"relative flex flex-col gap-1 rounded-[var(--radius-12)] bg-layer-1 p-1 sm:flex-row sm:items-center\">\n            {fields.map((field) => {\n              const active = field.id === activeField.id;\n              return (\n                <div\n                  key={field.id}\n                  data-slot=\"search-suggestions-field\"\n                  className=\"group relative min-w-0 flex-1\"\n                >\n                  {active && (\n                    <div className=\"absolute inset-0 rounded-[var(--radius-8)] bg-fill-1 outline-none group-has-[:focus-visible]:[outline-style:solid] group-has-[:focus-visible]:outline-[length:var(--focus-ring-width)] group-has-[:focus-visible]:outline-[var(--focus-ring-color)] group-has-[:focus-visible]:outline-offset-0\" />\n                  )}\n                  <div className=\"relative block min-w-0\">\n                    <span className=\"sr-only\">{field.label}</span>\n                    <Input\n                      variant=\"unstyled\"\n                      size=\"lg\"\n                      type=\"search\"\n                      value={field.value}\n                      placeholder={field.placeholder}\n                      prefix={field.icon}\n                      aria-label={field.label}\n                      autoComplete=\"off\"\n                      aria-autocomplete=\"list\"\n                      aria-controls={listboxId}\n                      aria-expanded={open && active}\n                      aria-activedescendant={\n                        active && activeIndex >= 0\n                          ? `${generatedId}-option-${activeIndex}`\n                          : undefined\n                      }\n                      onFocus={() => {\n                        if (!isMobile) activateField(field.id);\n                      }}\n                      onClick={() => activateField(field.id)}\n                      onChange={(event) => {\n                        onFieldValueChange(field.id, event.target.value);\n                        if (!active) activateField(field.id);\n                        else if (!open) setOpen(true);\n                      }}\n                      onKeyDown={handleKeyDown}\n                      suffix={\n                        field.value ? (\n                          <button\n                            type=\"button\"\n                            aria-label={`Clear ${field.label}`}\n                            className={cn(\n                              \"inline-flex size-6 items-center justify-center rounded-[var(--radius-8)] text-ink-muted hover:bg-layer-hover hover:text-ink active:bg-layer-hover\",\n                              selectionFocus,\n                            )}\n                            onClick={() => {\n                              onFieldValueChange(field.id, \"\");\n                              activateField(field.id);\n                            }}\n                          >\n                            <X aria-hidden className=\"size-3.5\" />\n                          </button>\n                        ) : undefined\n                      }\n                      className=\"relative\"\n                    />\n                  </div>\n                </div>\n              );\n            })}\n            <PopoverTrigger\n              id={triggerId}\n              render={\n                <Button\n                  type=\"button\"\n                  size=\"lg\"\n                  className=\"shrink-0\"\n                  icon={<MagnifyingGlass aria-hidden />}\n                  onClick={() =>\n                    onSubmit(\n                      Object.fromEntries(\n                        fields.map((field) => [field.id, field.value]),\n                      ),\n                    )\n                  }\n                />\n              }\n            >\n              {submitLabel}\n            </PopoverTrigger>\n          </div>\n        </div>\n\n        <PopoverContent\n          anchor={anchorRef}\n          align=\"start\"\n          sideOffset={8}\n          initialFocus={isMobile}\n          finalFocus={false}\n          className=\"overflow-hidden p-0 md:w-[var(--anchor-width)] md:min-w-[420px]\"\n        >\n          {isMobile && (\n            <div className=\"p-1\">\n              <Input\n                size=\"lg\"\n                type=\"search\"\n                value={activeField.value}\n                placeholder={activeField.placeholder}\n                prefix={activeField.icon}\n                aria-label={activeField.label}\n                autoComplete=\"off\"\n                aria-autocomplete=\"list\"\n                aria-controls={listboxId}\n                aria-expanded\n                aria-activedescendant={\n                  activeIndex >= 0 ? `${generatedId}-option-${activeIndex}` : undefined\n                }\n                onChange={(event) =>\n                  onFieldValueChange(activeField.id, event.target.value)\n                }\n                onKeyDown={handleKeyDown}\n              />\n            </div>\n          )}\n          <SearchSuggestionsResults\n            field={activeField}\n            listboxId={listboxId}\n            generatedId={generatedId}\n            activeIndex={activeIndex}\n            onActiveIndexChange={setActiveIndex}\n            onSelect={selectSuggestion}\n            onClose={() => setOpen(false)}\n          />\n        </PopoverContent>\n      </Popover>\n  );\n}\n\nfunction SearchSuggestionsResults({\n  field,\n  listboxId,\n  generatedId,\n  activeIndex,\n  onActiveIndexChange,\n  onSelect,\n  onClose,\n}: {\n  field: SearchSuggestionsField;\n  listboxId: string;\n  generatedId: string;\n  activeIndex: number;\n  onActiveIndexChange: (index: number) => void;\n  onSelect: (suggestion: SearchSuggestion) => void;\n  onClose: () => void;\n}): React.ReactElement {\n  const hasSuggestions = field.sections.some(\n    (section) => section.suggestions.length > 0,\n  );\n\n  return (\n      <div\n        key={field.id}\n        className=\"min-h-0 overflow-y-auto p-1\"\n        style={{ maxHeight: \"min(360px, 55dvh)\" }}\n      >\n        {field.loading && !hasSuggestions ? (\n          <div className=\"flex items-center justify-center gap-2 px-4 py-8 text-small text-ink-muted\">\n            <Spinner size=\"sm\" />\n            <span>Searching</span>\n          </div>\n        ) : hasSuggestions ? (\n          <div id={listboxId} role=\"listbox\" aria-label={field.label}>\n            {field.sections.map((section, sectionIndex) => (\n              <div key={section.id} data-slot=\"search-suggestions-section\">\n                {section.label && (\n                  <div className={cn(itemGroupLabel.base, itemGroupLabel.comfortable)}>\n                    {section.label}\n                  </div>\n                )}\n                {section.suggestions.map((suggestion, suggestionIndex) => {\n                  const index =\n                    field.sections\n                      .slice(0, sectionIndex)\n                      .reduce(\n                        (count, current) => count + current.suggestions.length,\n                        0,\n                      ) + suggestionIndex;\n                  const active = index === activeIndex;\n                  const onRemove = suggestion.onRemove;\n                  const onAction = suggestion.onAction;\n                  const option = (\n                    <button\n                      key={suggestion.id}\n                      id={`${generatedId}-option-${index}`}\n                      type=\"button\"\n                      role=\"option\"\n                      aria-selected={active}\n                      disabled={suggestion.disabled}\n                      data-active={active || undefined}\n                      className={cn(\n                        itemRow.base,\n                        itemRow.comfortable,\n                        \"w-full gap-3 text-start\",\n                        (onRemove || onAction) && \"pe-11\",\n                        onRemove && onAction && \"pe-20\",\n                      )}\n                      onMouseEnter={() => onActiveIndexChange(index)}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onClick={() => onSelect(suggestion)}\n                    >\n                      {suggestion.icon && (\n                        <span className=\"shrink-0 text-ink-muted [&_svg]:size-4\">\n                          {suggestion.icon}\n                        </span>\n                      )}\n                      <span className=\"min-w-0 flex-1\">\n                        <span className=\"block truncate text-small text-ink\">\n                          {suggestion.label}\n                        </span>\n                        {suggestion.description && (\n                          <span className=\"block truncate text-mini text-ink-muted\">\n                            {suggestion.description}\n                          </span>\n                        )}\n                      </span>\n                      {suggestion.suffix && (\n                        <span className=\"shrink-0 text-mini text-ink-muted\">\n                          {suggestion.suffix}\n                        </span>\n                      )}\n                    </button>\n                  );\n\n                  if (!onRemove && !onAction) return option;\n\n                  return (\n                    <div key={suggestion.id} className=\"relative\">\n                      {option}\n                      {onAction && (\n                        <button\n                          type=\"button\"\n                          aria-label={suggestion.actionLabel ?? \"Use action\"}\n                          className={cn(\n                            \"absolute top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-[var(--radius-8)] text-ink-tertiary hover:bg-layer-hover hover:text-ink active:bg-layer-hover\",\n                            onRemove ? \"end-10\" : \"end-1.5\",\n                            selectionFocus,\n                          )}\n                          onMouseDown={(event) => event.stopPropagation()}\n                          onClick={(event) => {\n                            event.stopPropagation();\n                            onAction();\n                            onClose();\n                          }}\n                        >\n                          {suggestion.actionIcon}\n                        </button>\n                      )}\n                      {onRemove && (\n                        <button\n                          type=\"button\"\n                          aria-label={suggestion.removeLabel ?? \"Remove\"}\n                          className={cn(\n                            \"absolute end-1.5 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-[var(--radius-8)] text-ink-tertiary hover:bg-layer-hover hover:text-ink active:bg-layer-hover\",\n                            selectionFocus,\n                          )}\n                          onMouseDown={(event) => event.stopPropagation()}\n                          onClick={(event) => {\n                            event.stopPropagation();\n                            onRemove();\n                          }}\n                        >\n                          <X aria-hidden className=\"size-3.5\" />\n                        </button>\n                      )}\n                    </div>\n                  );\n                })}\n              </div>\n            ))}\n          </div>\n        ) : (\n          <div className=\"px-4 py-8 text-center text-small text-ink-muted\">\n            {field.emptyContent ?? \"No suggestions found.\"}\n          </div>\n        )}\n      </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/blocks/search-suggestions/index.tsx"
    }
  ],
  "type": "registry:block"
}