{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "title": "Combobox",
  "description": "Type to filter and pick one. Arrow keys move, Enter picks, Escape closes.",
  "registryDependencies": [
    "https://creance.pages.dev/r/creance-base.json",
    "https://creance.pages.dev/r/input.json"
  ],
  "files": [
    {
      "path": "combobox/Combobox.css",
      "content": "/* Shared by Combobox and MultiSelect. */\n.cr-combobox {\n  position: relative;\n  width: 100%;\n  font-family: var(--cr-font-family-sans);\n}\n\n.cr-combobox__field {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 6px;\n  padding-inline-end: 6px;\n  cursor: text;\n}\n\n.cr-combobox__field:focus-within {\n  border-color: var(--cr-color-accent-bg);\n  box-shadow: 0 0 0 4px var(--cr-color-accent-subtle);\n}\n\n.cr-combobox__field[aria-invalid='true'] {\n  border-color: var(--cr-color-danger-text);\n}\n\n.cr-combobox__field[data-disabled] {\n  cursor: not-allowed;\n  opacity: 0.55;\n}\n\n.cr-combobox__input {\n  flex: 1 1 100px;\n  min-width: 100px;\n  min-height: calc(var(--cr-control-height-md) - 2px);\n  padding: 0;\n  border: 0;\n  outline: none;\n  background: transparent;\n  color: inherit;\n  font: inherit;\n}\n\n.cr-combobox__input::placeholder {\n  color: var(--cr-color-text-muted);\n}\n\n.cr-combobox__chevron {\n  display: flex;\n  padding-inline: var(--cr-space-2);\n  color: var(--cr-color-text-muted);\n}\n\n.cr-combobox__list {\n  position: absolute;\n  top: calc(100% + 6px);\n  right: 0;\n  left: 0;\n  z-index: 50;\n  box-sizing: border-box;\n  max-height: 280px;\n  margin: 0;\n  padding: 6px;\n  overflow-y: auto;\n  border-radius: var(--cr-radius-lg);\n  list-style: none;\n  color: var(--cr-color-text-default);\n  font-size: var(--cr-font-size-md);\n}\n\n.cr-combobox__list[hidden] {\n  display: none;\n}\n\n.cr-combobox__option {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  min-height: 38px;\n  padding: 0 var(--cr-space-3);\n  border-radius: 10px;\n  cursor: pointer;\n}\n\n.cr-combobox__option[data-active] {\n  background: var(--cr-color-bg-subtle);\n}\n\n.cr-combobox__option[aria-disabled] {\n  cursor: not-allowed;\n  opacity: 0.5;\n}\n\n.cr-combobox__option[data-create] {\n  margin-top: 6px;\n  border-top: 1px solid var(--cr-color-border-default);\n  border-radius: 0 0 10px 10px;\n  color: var(--cr-color-text-link);\n  font-weight: var(--cr-font-weight-semibold);\n}\n\n.cr-combobox__label {\n  flex: 1;\n  min-width: 0;\n}\n\n.cr-combobox__label strong {\n  font-weight: var(--cr-font-weight-bold);\n}\n\n.cr-combobox__meta {\n  color: var(--cr-color-text-muted);\n  font-size: var(--cr-font-size-xs);\n}\n\n.cr-combobox__check {\n  width: 16px;\n  flex: none;\n}\n\n.cr-combobox__empty {\n  padding: var(--cr-space-3);\n  color: var(--cr-color-text-muted);\n}\n\n.cr-combobox__chip {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  padding: 3px 4px 3px 10px;\n  border-radius: var(--cr-radius-full);\n  background: var(--cr-color-ink-subtle);\n  font-size: var(--cr-font-size-sm);\n  font-weight: var(--cr-font-weight-semibold);\n}\n\n.cr-combobox__chip-remove {\n  display: grid;\n  place-items: center;\n  width: 20px;\n  height: 20px;\n  padding: 0;\n  border: 0;\n  border-radius: var(--cr-radius-full);\n  background: transparent;\n  color: var(--cr-color-text-muted);\n  font-size: 10px;\n  cursor: pointer;\n}\n\n.cr-combobox__chip-remove:hover {\n  background: var(--cr-color-bg-subtle);\n  color: var(--cr-color-text-default);\n}\n\n.cr-combobox__chip-remove:focus-visible {\n  outline: var(--cr-focus-width) solid var(--cr-color-focus);\n  outline-offset: 1px;\n}\n\n.cr-combobox__field[data-multiple] {\n  min-height: 48px;\n  padding: 6px 8px;\n}\n\n.cr-combobox__field[data-multiple] .cr-combobox__input {\n  min-height: 30px;\n  padding-inline-start: 4px;\n}\n",
      "type": "registry:file",
      "target": "@components/creance/combobox/Combobox.css"
    },
    {
      "path": "combobox/Combobox.tsx",
      "content": "'use client';\n\nimport { useMemo, useRef, useState, type ReactNode } from 'react';\nimport { cx } from '@/lib/creance/cx';\nimport { ChevronDownIcon, PlusIcon } from '@/lib/creance/icons';\nimport { defaultFilter, splitMatch, useListbox } from './useListbox';\nimport '@/components/creance/input/Input.css';\nimport './Combobox.css';\n\nexport interface ComboboxOption {\n  value: string;\n  /** Plain text: it is what the field shows and what typing matches. */\n  label: string;\n  /** Shown at the start of the row, such as an Avatar. */\n  leading?: ReactNode;\n  /** Shown at the end of the row, such as a status. */\n  meta?: ReactNode;\n  disabled?: boolean;\n}\n\n/** Shared by Combobox and MultiSelect. */\nexport interface ComboboxBaseProps {\n  options: ComboboxOption[];\n  placeholder?: string;\n  /** Shown when nothing matches and there is no create option. Default \"No matches\". */\n  emptyMessage?: ReactNode;\n  filter?: (option: ComboboxOption, query: string) => boolean;\n  /** Offer to create what was typed when nothing matches it exactly. */\n  onCreate?: (query: string) => void;\n  createLabel?: (query: string) => ReactNode;\n  disabled?: boolean;\n  id?: string;\n  className?: string;\n  'aria-label'?: string;\n  'aria-labelledby'?: string;\n  'aria-describedby'?: string;\n  'aria-invalid'?: boolean | 'true' | 'false';\n}\n\nexport interface ComboboxProps extends ComboboxBaseProps {\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n}\n\nexport interface Item {\n  key: string;\n  option?: ComboboxOption;\n  create?: string;\n  disabled?: boolean;\n}\n\nexport function useItems(props: ComboboxBaseProps, query: string, filtering: boolean) {\n  const { options, filter, onCreate } = props;\n  return useMemo<Item[]>(() => {\n    const q = filtering ? query.trim() : '';\n    const shown = q\n      ? options.filter((o) => (filter ? filter(o, q) : defaultFilter(o.label, q)))\n      : options;\n    const items: Item[] = shown.map((o) => ({ key: o.value, option: o, disabled: o.disabled }));\n    if (onCreate && q && !options.some((o) => o.label.toLowerCase() === q.toLowerCase())) {\n      items.push({ key: `create:${q}`, create: q });\n    }\n    return items;\n  }, [options, filter, onCreate, query, filtering]);\n}\n\nexport function Highlight({ label, query }: { label: string; query: string }) {\n  const [a, b, c] = splitMatch(label, query.trim());\n  return (\n    <span className=\"cr-combobox__label\">\n      {a}\n      {b && <strong>{b}</strong>}\n      {c}\n    </span>\n  );\n}\n\n/**\n * Type to filter a list and pick one. Focus stays in the field: arrow keys move, Enter picks,\n * Escape closes. Wrap it in `Field` for a label.\n */\nexport function Combobox(props: ComboboxProps) {\n  const { value, defaultValue = null, onValueChange, placeholder, emptyMessage = 'No matches', onCreate, createLabel, disabled, id, className } = props;\n  const [own, setOwn] = useState<string | null>(defaultValue);\n  const selected = value === undefined ? own : value;\n  const selectedLabel = props.options.find((o) => o.value === selected)?.label ?? '';\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(selectedLabel);\n  const [typed, setTyped] = useState(false);\n  const input = useRef<HTMLInputElement>(null);\n\n  const items = useItems(props, query, typed);\n  const close = () => {\n    setOpen(false);\n    setTyped(false);\n  };\n  const pick = (item: Item) => {\n    if (item.create !== undefined) {\n      onCreate?.(item.create);\n      close();\n      return;\n    }\n    const next = item.option!.value;\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n    setQuery(item.option!.label);\n    close();\n  };\n  const list = useListbox(items, open, pick);\n\n  return (\n    <div\n      className={cx('cr-combobox', className)}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget)) {\n          close();\n          setQuery(selectedLabel);\n        }\n      }}\n    >\n      <div className=\"cr-combobox__field cr-input\" data-disabled={disabled || undefined} aria-invalid={props['aria-invalid']}>\n        <input\n          ref={input}\n          id={id}\n          role=\"combobox\"\n          aria-expanded={open}\n          aria-controls={list.listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={list.activeDescendant}\n          aria-label={props['aria-label']}\n          aria-labelledby={props['aria-labelledby']}\n          aria-describedby={props['aria-describedby']}\n          aria-invalid={props['aria-invalid']}\n          className=\"cr-combobox__input\"\n          autoComplete=\"off\"\n          placeholder={placeholder}\n          disabled={disabled}\n          value={open || typed ? query : selectedLabel}\n          onChange={(e) => {\n            setQuery(e.currentTarget.value);\n            setTyped(true);\n            setOpen(true);\n          }}\n          onClick={() => setOpen(true)}\n          onKeyDown={(e) => {\n            if (list.onKeyDown(e, () => setOpen(true))) return;\n            if (e.key === 'Escape' && open) {\n              e.preventDefault();\n              close();\n              setQuery(selectedLabel);\n            }\n          }}\n        />\n        <span className=\"cr-combobox__chevron\" aria-hidden=\"true\">\n          <ChevronDownIcon />\n        </span>\n      </div>\n      <ul\n        id={list.listId}\n        role=\"listbox\"\n        aria-label={props['aria-label']}\n        aria-labelledby={props['aria-labelledby']}\n        className=\"cr-combobox__list cr-popover\"\n        hidden={!open}\n        onMouseDown={(e) => e.preventDefault()}\n      >\n        {items.map((item, i) => (\n          // Options are reached from the keyboard through the input (aria-activedescendant).\n          // eslint-disable-next-line jsx-a11y/click-events-have-key-events\n          <li\n            key={item.key}\n            id={list.optionId(i)}\n            role=\"option\"\n            aria-selected={item.option ? item.option.value === selected : false}\n            aria-disabled={item.disabled || undefined}\n            className=\"cr-combobox__option\"\n            data-active={i === list.active || undefined}\n            data-create={item.create !== undefined || undefined}\n            onMouseMove={() => !item.disabled && list.setActive(i)}\n            onClick={() => !item.disabled && pick(item)}\n          >\n            {item.option ? (\n              <>\n                {item.option.leading}\n                <Highlight label={item.option.label} query={typed ? query : ''} />\n                {item.option.meta && <span className=\"cr-combobox__meta\">{item.option.meta}</span>}\n              </>\n            ) : (\n              <>\n                <PlusIcon />\n                {createLabel ? createLabel(item.create!) : `Create “${item.create}”`}\n              </>\n            )}\n          </li>\n        ))}\n        {items.length === 0 && (\n          <li role=\"presentation\" className=\"cr-combobox__empty\">\n            {emptyMessage}\n          </li>\n        )}\n      </ul>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/creance/combobox/Combobox.tsx"
    },
    {
      "path": "combobox/useListbox.ts",
      "content": "'use client';\n\nimport { useEffect, useId, useState, type KeyboardEvent } from 'react';\n\nexport interface ListItem {\n  key: string;\n  disabled?: boolean;\n}\n\n/**\n * Keyboard model for an ARIA 1.2 combobox: focus stays in the input while arrow keys move a\n * highlighted option, exposed through aria-activedescendant.\n */\nexport function useListbox<T extends ListItem>(items: T[], open: boolean, onPick: (item: T) => void) {\n  const listId = useId();\n  // The highlight resets to the first usable item whenever the list opens or what it shows\n  // changes. Keyed on content, not identity, so a fresh options array each render is fine.\n  const key = `${open}\\n${items.map((i) => i.key).join('\\n')}`;\n  const [chosen, setChosen] = useState({ key: '', index: -1 });\n  const active = chosen.key === key ? chosen.index : open ? items.findIndex((i) => !i.disabled) : -1;\n  const setActive = (index: number) => setChosen({ key, index });\n  const optionId = (i: number) => `${listId}-${i}`;\n\n  useEffect(() => {\n    if (active < 0) return;\n    document.getElementById(optionId(active))?.scrollIntoView?.({ block: 'nearest' });\n  });\n\n  const move = (from: number, step: 1 | -1) => {\n    for (let n = 1; n <= items.length; n++) {\n      const i = (from + step * n + items.length) % items.length;\n      if (!items[i]?.disabled) return i;\n    }\n    return -1;\n  };\n\n  /** Returns true when the key was handled. */\n  const onKeyDown = (e: KeyboardEvent, openList: () => void) => {\n    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {\n      e.preventDefault();\n      if (!open) return openList(), true;\n      setActive(move(active < 0 ? (e.key === 'ArrowDown' ? -1 : 0) : active, e.key === 'ArrowDown' ? 1 : -1));\n      return true;\n    }\n    if (!open) return false;\n    if (e.key === 'Home' || e.key === 'End') {\n      e.preventDefault();\n      setActive(e.key === 'Home' ? move(-1, 1) : move(0, -1));\n      return true;\n    }\n    if (e.key === 'Enter' && active >= 0 && items[active]) {\n      e.preventDefault();\n      onPick(items[active]);\n      return true;\n    }\n    return false;\n  };\n\n  return {\n    listId,\n    active,\n    setActive,\n    optionId,\n    onKeyDown,\n    activeDescendant: open && active >= 0 ? optionId(active) : undefined,\n  };\n}\n\n/** The label with the first case-insensitive match of the query in bold. */\nexport function splitMatch(label: string, query: string): [string, string, string] {\n  const i = query ? label.toLowerCase().indexOf(query.toLowerCase()) : -1;\n  return i < 0 ? [label, '', ''] : [label.slice(0, i), label.slice(i, i + query.length), label.slice(i + query.length)];\n}\n\nexport const defaultFilter = (label: string, query: string) => label.toLowerCase().includes(query.trim().toLowerCase());\n",
      "type": "registry:ui",
      "target": "@components/creance/combobox/useListbox.ts"
    }
  ],
  "categories": [
    "molecule",
    "forms"
  ],
  "type": "registry:ui"
}