{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "کامبوباکس",
  "dependencies": [
    "@kashi-ui/persian@^1.7.0"
  ],
  "registryDependencies": [
    "https://kashi.sepantapouya.com/r/cx.json",
    "https://kashi.sepantapouya.com/r/field.json",
    "https://kashi.sepantapouya.com/r/kashi-provider.json",
    "https://kashi.sepantapouya.com/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/kashi/Combobox.tsx",
      "type": "registry:ui",
      "target": "components/kashi/Combobox.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport { normalizePersian, toFaDigits } from \"@kashi-ui/persian\";\nimport { cx } from \"./cx\";\nimport { Field, describedBy } from \"./Field\";\nimport { mergeRefs } from \"./utils\";\nimport { useKashi } from \"./KashiProvider\";\n\nexport interface ComboboxOption {\n  value: string;\n  label: string;\n  description?: string;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** نام گروه برای دسته‌بندی گزینه‌ها */\n  group?: string;\n  /** کلمه‌های اضافه برای جستجو، مثل نام انگلیسی */\n  keywords?: string[];\n}\n\n/** متن یکدست برای جستجو: ي/ی، ك/ک، ارقام، حروف کوچک */\nexport const searchText = (s: string) => normalizePersian(s, { digits: \"latin\" }).toLowerCase();\n\nexport function defaultFilter(option: ComboboxOption, query: string): boolean {\n  const q = searchText(query.trim());\n  if (!q) return true;\n  return [option.label, option.description ?? \"\", ...(option.keywords ?? [])].some((t) => searchText(t).includes(q));\n}\n\n/** بخش منطبق با جستجو را با <mark> برجسته می‌کند */\nexport function highlight(text: string, query: string): ReactNode {\n  const q = searchText(query.trim());\n  if (!q) return text;\n  const i = searchText(text).indexOf(q);\n  if (i < 0) return text;\n  return (\n    <>\n      {text.slice(0, i)}\n      <mark>{text.slice(i, i + q.length)}</mark>\n      {text.slice(i + q.length)}\n    </>\n  );\n}\n\nconst Check = () => (\n  <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"m5 10.5 3.2 3L15 7\" /></svg>\n);\n\nexport interface ComboboxProps {\n  label?: ReactNode;\n  hint?: ReactNode;\n  error?: ReactNode;\n  options: ComboboxOption[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onChange?: (value: string | null) => void;\n  /** وقتی متن جستجو عوض می‌شود؛ برای جستجوی سمت سرور */\n  onInputChange?: (query: string) => void;\n  placeholder?: string;\n  /** جستجوی سفارشی؛ پیش‌فرض با یکدست‌سازی فارسی */\n  filter?: (option: ComboboxOption, query: string) => boolean;\n  /** @default \"نتیجه‌ای پیدا نشد.\" */\n  emptyMessage?: ReactNode;\n  /** نمایش اسپینر، مثلاً هنگام جستجوی سمت سرور */\n  loading?: boolean;\n  required?: boolean;\n  disabled?: boolean;\n  name?: string;\n  id?: string;\n  className?: string;\n  /** @default (n) => «n نتیجه» */\n  resultsLabel?: (count: number) => string;\n}\n\n/**\n * جستجو و انتخاب با الگوی Combobox در WAI-ARIA:\n * تایپ فهرست را فیلتر می‌کند، ↑↓ حرکت، Enter انتخاب، Esc بستن (و در حالت بسته، پاک کردن).\n * جستجو «علي» و «علی» را یکی می‌داند.\n */\nexport const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(function Combobox(\n  {\n    label, hint, error, options, value, defaultValue = null, onChange, onInputChange, placeholder, filter = defaultFilter,\n    emptyMessage, loading, required, disabled, name, id, className,\n    resultsLabel,\n  },\n  ref,\n) {\n  const { messages: M, n: N } = useKashi();\n  emptyMessage ??= M.common.noResults;\n  resultsLabel ??= (x: number) => M.common.results(N(x));\n  const autoId = useId();\n  const inputId = id ?? autoId;\n  const listId = `${inputId}-list`;\n  const [inner, setInner] = useState<string | null>(defaultValue);\n  const selected = value !== undefined ? value : inner;\n  const selectedOption = options.find((o) => o.value === selected) ?? null;\n  const [query, setQuery] = useState(selectedOption?.label ?? \"\");\n  const [open, setOpen] = useState(false);\n  const [typing, setTyping] = useState(false);\n  const [active, setActive] = useState(-1);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const wrapRef = useRef<HTMLDivElement>(null);\n\n  // وقتی مقدار از بیرون عوض شد، متن فیلد هم هماهنگ شود\n  useEffect(() => {\n    if (!typing) setQuery(selectedOption?.label ?? \"\");\n  }, [selectedOption?.label]);\n\n  const filtered = useMemo(() => (typing ? options.filter((o) => filter(o, query)) : options), [options, query, typing, filter]);\n  const enabled = filtered.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);\n\n  const commit = (o: ComboboxOption | null) => {\n    if (value === undefined) setInner(o?.value ?? null);\n    onChange?.(o?.value ?? null);\n    setQuery(o?.label ?? \"\");\n    setTyping(false);\n    setOpen(false);\n    setActive(-1);\n  };\n\n  const openList = (at: \"first\" | \"last\" | \"selected\" = \"selected\") => {\n    if (disabled) return;\n    setOpen(true);\n    const sel = filtered.findIndex((o) => o.value === selected);\n    setActive(at === \"last\" ? enabled[enabled.length - 1] ?? -1 : at === \"first\" || sel < 0 ? enabled[0] ?? -1 : sel);\n  };\n\n  const move = (delta: number) => {\n    if (!enabled.length) return;\n    const pos = enabled.indexOf(active);\n    setActive(enabled[(pos + delta + enabled.length) % enabled.length]);\n  };\n\n  // گزینهٔ فعال همیشه دیده شود\n  useEffect(() => {\n    if (active < 0) return;\n    document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: \"nearest\" });\n  }, [active, listId]);\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      open ? move(1) : openList(e.altKey ? \"selected\" : \"first\");\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      open ? move(-1) : openList(\"last\");\n    } else if (e.key === \"Enter\" && open && active >= 0) {\n      e.preventDefault();\n      commit(filtered[active]);\n    } else if (e.key === \"Escape\") {\n      if (open) {\n        e.preventDefault();\n        setOpen(false);\n        setTyping(false);\n        setQuery(selectedOption?.label ?? \"\");\n      } else if (query) {\n        e.preventDefault();\n        commit(null);\n      }\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      if (!open) return;\n      e.preventDefault();\n      setActive(e.key === \"Home\" ? enabled[0] : enabled[enabled.length - 1]);\n    }\n  };\n\n  let lastGroup: string | undefined;\n\n  return (\n    <Field id={inputId} label={label} hint={hint} error={error} required={required} className={className}>\n      <div\n        ref={wrapRef}\n        className=\"k-combobox\"\n        data-open={open ? \"\" : undefined}\n        onBlur={(e) => {\n          if (wrapRef.current?.contains(e.relatedTarget as Node)) return;\n          setOpen(false);\n          // متن نیمه‌کاره به گزینهٔ انتخاب‌شده برمی‌گردد\n          if (typing) {\n            setTyping(false);\n            setQuery(selectedOption?.label ?? \"\");\n          }\n        }}\n      >\n        <div className={cx(\"k-input\")} data-state={error ? \"invalid\" : undefined}>\n          <input\n            ref={mergeRefs(ref, inputRef)}\n            id={inputId}\n            className=\"k-input__control\"\n            type=\"text\"\n            role=\"combobox\"\n            aria-autocomplete=\"list\"\n            aria-expanded={open}\n            aria-controls={listId}\n            aria-activedescendant={open && active >= 0 ? `${listId}-${active}` : undefined}\n            aria-invalid={error ? true : undefined}\n            aria-describedby={describedBy(inputId, hint, error)}\n            autoComplete=\"off\"\n            placeholder={placeholder}\n            value={query}\n            required={required}\n            disabled={disabled}\n            onChange={(e) => {\n              setQuery(e.target.value);\n              setTyping(true);\n              setOpen(true);\n              setActive(-1);\n              onInputChange?.(e.target.value);\n            }}\n            onKeyDown={onKeyDown}\n            onClick={() => !open && openList()}\n          />\n          {loading && <span className=\"k-input__addon\"><span className=\"k-spinner k-spinner--sm\" aria-hidden=\"true\" /></span>}\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            className=\"k-input__action k-combobox__toggle\"\n            aria-label={M.combobox.toggle}\n            disabled={disabled}\n            onClick={() => {\n              if (open) setOpen(false);\n              else {\n                setTyping(false);\n                openList();\n                inputRef.current?.focus();\n              }\n            }}\n          >\n            <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.7} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 8 4 4 4-4\" /></svg>\n          </button>\n        </div>\n        <ul id={listId} role=\"listbox\" aria-labelledby={label ? undefined : inputId} aria-label={typeof label === \"string\" ? label : undefined} className=\"k-listbox\" hidden={!open} style={{ insetBlockStart: \"calc(100% + 6px)\" }}>\n          {filtered.length === 0 && <li className=\"k-listbox__empty\" role=\"presentation\">{loading ? M.common.searching : emptyMessage}</li>}\n          {filtered.map((o, i) => {\n            const header = o.group && o.group !== lastGroup ? o.group : null;\n            lastGroup = o.group;\n            return [\n              header && <li key={`g-${header}`} role=\"presentation\" className=\"k-listbox__group\">{header}</li>,\n              <li\n                key={o.value}\n                id={`${listId}-${i}`}\n                role=\"option\"\n                aria-selected={o.value === selected}\n                aria-disabled={o.disabled || undefined}\n                data-active={i === active ? \"\" : undefined}\n                className=\"k-option\"\n                onPointerDown={(e) => e.preventDefault() /* فوکوس در فیلد بماند */}\n                onClick={() => !o.disabled && commit(o)}\n                onPointerMove={() => !o.disabled && active !== i && setActive(i)}\n              >\n                {o.icon}\n                <span className=\"k-option__body\">\n                  <span>{highlight(o.label, typing ? query : \"\")}</span>\n                  {o.description && <span className=\"k-option__desc\">{o.description}</span>}\n                </span>\n                {o.value === selected && <span className=\"k-option__check\"><Check /></span>}\n              </li>,\n            ];\n          })}\n        </ul>\n        {/* تعداد نتایج برای صفحه‌خوان */}\n        <span className=\"k-sr-only\" aria-live=\"polite\">{open && typing ? resultsLabel(filtered.length) : \"\"}</span>\n        {name && <input type=\"hidden\" name={name} value={selected ?? \"\"} />}\n      </div>\n    </Field>\n  );\n});\n"
    }
  ]
}