{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "Searchable combobox for Tile UI.",
  "dependencies": [
    "react-dom"
  ],
  "devDependencies": [
    "sass"
  ],
  "registryDependencies": [
    "@tile-ui/core",
    "@tile-ui/portal",
    "@tile-ui/styles"
  ],
  "files": [
    {
      "path": "packages/react/src/components/combobox/combobox.tsx",
      "type": "registry:ui",
      "target": "components/ui/combobox/combobox.tsx",
      "content": "import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { comboboxStyleKeys, filterComboboxItems, getSelectState, moveComboboxIndex } from '../lib/core';\nimport type { ComboboxBaseProps, ComboboxItem } from '../lib/core';\nimport { usePortalContainer, type PortalContainer } from '../portal';\nimport styles from './combobox.module.scss';\n\nfunction ComboboxCheckIcon() {\n\treturn (\n\t\t<svg\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\twidth=\"16\"\n\t\t\theight=\"16\"\n\t\t\tviewBox=\"0 0 24 24\"\n\t\t\tfill=\"none\"\n\t\t\tstroke=\"currentColor\"\n\t\t\tstrokeWidth=\"2\"\n\t\t\tstrokeLinecap=\"round\"\n\t\t\tstrokeLinejoin=\"round\"\n\t\t\taria-hidden=\"true\">\n\t\t\t<path d=\"M20 6 9 17l-5-5\" />\n\t\t</svg>\n\t);\n}\n\nfunction ComboboxChevronIcon() {\n\treturn (\n\t\t<svg\n\t\t\tclassName={styles[comboboxStyleKeys.triggerIcon]}\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t\twidth=\"16\"\n\t\t\theight=\"16\"\n\t\t\tviewBox=\"0 0 24 24\"\n\t\t\tfill=\"none\"\n\t\t\tstroke=\"currentColor\"\n\t\t\tstrokeWidth=\"2\"\n\t\t\tstrokeLinecap=\"round\"\n\t\t\tstrokeLinejoin=\"round\"\n\t\t\taria-hidden=\"true\">\n\t\t\t<path d=\"m6 9 6 6 6-6\" />\n\t\t</svg>\n\t);\n}\n\nexport interface ComboboxProps extends React.HTMLAttributes<HTMLDivElement>, ComboboxBaseProps {\n\tcontainer?: PortalContainer;\n}\n\nconst Combobox = React.forwardRef<HTMLDivElement, ComboboxProps>(\n\t(\n\t\t{\n\t\t\tclassName = '',\n\t\t\titems,\n\t\t\tvalue,\n\t\t\tdefaultValue,\n\t\t\tonValueChange,\n\t\t\tplaceholder = 'Select...',\n\t\t\tsearchPlaceholder = 'Search...',\n\t\t\temptyText = 'No items.',\n\t\t\tnotFoundText = 'No results found.',\n\t\t\tmaxItems,\n\t\t\tdisabled = false,\n\t\t\tfilter,\n\t\t\tcontainer,\n\t\t\t...props\n\t\t},\n\t\tref,\n\t) => {\n\t\tconst portalContainer = usePortalContainer(container);\n\t\tconst [open, setOpen] = useState(false);\n\t\tconst [query, setQuery] = useState('');\n\t\tconst [activeValue, setActiveValue] = useState<string | null>(null);\n\t\tconst [internalValue, setInternalValue] = useState(defaultValue);\n\t\tconst triggerRef = useRef<HTMLButtonElement | null>(null);\n\t\tconst contentRef = useRef<HTMLDivElement | null>(null);\n\t\tconst contentId = useId();\n\n\t\tconst currentValue = value !== undefined ? value : internalValue;\n\n\t\tconst filteredItems = useMemo(() => filterComboboxItems(items, query, maxItems, filter), [items, query, maxItems, filter]);\n\n\t\tconst selectedItem = useMemo(() => items.find((item) => item.value === currentValue), [items, currentValue]);\n\n\t\tfunction setOpenState(next: boolean) {\n\t\t\tsetOpen(next);\n\t\t\tif (!next) {\n\t\t\t\tsetQuery('');\n\t\t\t\tsetActiveValue(null);\n\t\t\t}\n\t\t}\n\n\t\tconst handleTriggerClick = useCallback(() => {\n\t\t\tif (disabled) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetOpenState(!open);\n\t\t}, [disabled, open]);\n\n\t\tconst handleSelect = useCallback(\n\t\t\t(item: ComboboxItem) => {\n\t\t\t\tif (item.disabled) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (value === undefined) {\n\t\t\t\t\tsetInternalValue(item.value);\n\t\t\t\t}\n\t\t\t\tonValueChange?.(item.value);\n\t\t\t\tsetOpenState(false);\n\t\t\t},\n\t\t\t[value, onValueChange],\n\t\t);\n\n\t\tfunction handleTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {\n\t\t\tif (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tif (disabled) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetOpenState(true);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleContentKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n\t\t\tconst enabledItems = filteredItems.filter((item) => !item.disabled);\n\t\t\tif (enabledItems.length === 0) {\n\t\t\t\tif (event.key === 'Escape') {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tsetOpenState(false);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentIndex = enabledItems.findIndex((item) => item.value === activeValue);\n\n\t\t\tswitch (event.key) {\n\t\t\t\tcase 'ArrowDown':\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tsetActiveValue(enabledItems[moveComboboxIndex(currentIndex, 1, enabledItems.length)].value);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'ArrowUp':\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tsetActiveValue(enabledItems[moveComboboxIndex(currentIndex, -1, enabledItems.length)].value);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'Home':\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tsetActiveValue(enabledItems[0].value);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'End':\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tsetActiveValue(enabledItems[enabledItems.length - 1].value);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'Enter':\n\t\t\t\tcase ' ':\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\thandleSelect(enabledItems[currentIndex >= 0 ? currentIndex : 0]);\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'Escape':\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tsetOpenState(false);\n\t\t\t\t\ttriggerRef.current?.focus();\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tuseLayoutEffect(() => {\n\t\t\tif (!open) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tfunction updatePosition() {\n\t\t\t\tconst trigger = triggerRef.current;\n\t\t\t\tconst content = contentRef.current;\n\t\t\t\tif (!trigger || !content) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst triggerRect = trigger.getBoundingClientRect();\n\t\t\t\tconst contentSize = { width: content.offsetWidth, height: content.offsetHeight };\n\t\t\t\tconst viewport = { width: window.innerWidth, height: window.innerHeight };\n\t\t\t\tconst margin = 8;\n\t\t\t\tconst left = Math.min(Math.max(triggerRect.left, margin), Math.max(margin, viewport.width - contentSize.width - margin));\n\t\t\t\tconst top = Math.min(Math.max(triggerRect.bottom + 4, margin), Math.max(margin, viewport.height - contentSize.height - margin));\n\t\t\t\tcontent.style.top = `${top}px`;\n\t\t\t\tcontent.style.left = `${left}px`;\n\t\t\t}\n\n\t\t\tupdatePosition();\n\t\t\twindow.addEventListener('resize', updatePosition);\n\t\t\tdocument.addEventListener('scroll', updatePosition, true);\n\n\t\t\treturn () => {\n\t\t\t\twindow.removeEventListener('resize', updatePosition);\n\t\t\t\tdocument.removeEventListener('scroll', updatePosition, true);\n\t\t\t};\n\t\t}, [open]);\n\n\t\tuseEffect(() => {\n\t\t\tif (!open) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tfunction handlePointerDown(event: PointerEvent) {\n\t\t\t\tconst target = event.target as Node | null;\n\t\t\t\tif (!target) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst content = contentRef.current;\n\t\t\t\tconst trigger = triggerRef.current;\n\t\t\t\tif (content && content.contains(target)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (trigger && trigger.contains(target)) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetOpenState(false);\n\t\t\t}\n\n\t\t\tdocument.addEventListener('pointerdown', handlePointerDown);\n\t\t\treturn () => document.removeEventListener('pointerdown', handlePointerDown);\n\t\t}, [open]);\n\n\t\tconst state = getSelectState(open);\n\t\tconst showEmpty = filteredItems.length === 0;\n\t\tconst emptyMessage = query ? notFoundText : emptyText;\n\n\t\tconst content = open ? (\n\t\t\t<div ref={contentRef} id={contentId} role=\"listbox\" tabIndex={-1} data-state={state} className={styles[comboboxStyleKeys.content]} onKeyDown={handleContentKeyDown}>\n\t\t\t\t<div className={styles[comboboxStyleKeys.search]}>\n\t\t\t\t\t<input\n\t\t\t\t\t\trole=\"combobox\"\n\t\t\t\t\t\taria-expanded={open}\n\t\t\t\t\t\taria-controls={contentId}\n\t\t\t\t\t\taria-autocomplete=\"list\"\n\t\t\t\t\t\tclassName={styles[comboboxStyleKeys.searchInput]}\n\t\t\t\t\t\tvalue={query}\n\t\t\t\t\t\tplaceholder={searchPlaceholder}\n\t\t\t\t\t\tautoFocus\n\t\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\t\tsetQuery(event.target.value);\n\t\t\t\t\t\t\tsetActiveValue(null);\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t</div>\n\t\t\t\t<div className={styles[comboboxStyleKeys.list]}>\n\t\t\t\t\t{showEmpty ? (\n\t\t\t\t\t\t<div className={styles[comboboxStyleKeys.empty]}>{emptyMessage}</div>\n\t\t\t\t\t) : (\n\t\t\t\t\t\tfilteredItems.map((item) => {\n\t\t\t\t\t\t\tconst isActive = activeValue === item.value;\n\t\t\t\t\t\t\tconst isSelected = item.value === currentValue;\n\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={item.value}\n\t\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\t\t\t\t\taria-selected={isSelected}\n\t\t\t\t\t\t\t\t\tdata-highlighted={isActive}\n\t\t\t\t\t\t\t\t\tdata-disabled={item.disabled}\n\t\t\t\t\t\t\t\t\tclassName={styles[comboboxStyleKeys.item]}\n\t\t\t\t\t\t\t\t\tonMouseEnter={() => {\n\t\t\t\t\t\t\t\t\t\tif (!item.disabled) {\n\t\t\t\t\t\t\t\t\t\t\tsetActiveValue(item.value);\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\tonClick={() => handleSelect(item)}>\n\t\t\t\t\t\t\t\t\t<span className={styles[comboboxStyleKeys.itemIndicator]}>{isSelected && <ComboboxCheckIcon />}</span>\n\t\t\t\t\t\t\t\t\t{item.label}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t})\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t) : null;\n\n\t\treturn (\n\t\t\t<div ref={ref} className={`${styles[comboboxStyleKeys.root]} ${className}`} {...props}>\n\t\t\t\t<button\n\t\t\t\t\tref={triggerRef}\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t\t\taria-expanded={open}\n\t\t\t\t\taria-controls={contentId}\n\t\t\t\t\tdata-disabled={disabled}\n\t\t\t\t\tclassName={styles[comboboxStyleKeys.trigger]}\n\t\t\t\t\tonClick={handleTriggerClick}\n\t\t\t\t\tonKeyDown={handleTriggerKeyDown}>\n\t\t\t\t\t<span data-placeholder={!selectedItem} className={styles[comboboxStyleKeys.triggerValue]}>\n\t\t\t\t\t\t{selectedItem?.label ?? placeholder}\n\t\t\t\t\t</span>\n\t\t\t\t\t<ComboboxChevronIcon />\n\t\t\t\t</button>\n\t\t\t\t{open && portalContainer ? createPortal(content, portalContainer) : null}\n\t\t\t</div>\n\t\t);\n\t},\n);\nCombobox.displayName = 'Combobox';\n\nexport { Combobox };\nexport default Combobox;\n"
    },
    {
      "path": "packages/styles/scss/components/combobox.module.scss",
      "type": "registry:file",
      "target": "components/ui/combobox/combobox.module.scss",
      "content": "@use '../../../styles/variables/colors' as *;\n\n// 根容器\n.root {\n\tposition: relative;\n\tdisplay: inline-flex;\n\twidth: 100%;\n}\n\n// 触发器按钮\n.trigger {\n\tdisplay: inline-flex;\n\twidth: 100%;\n\tmin-height: var(--tile-combobox-trigger-min-height, auto);\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: $spacing-2;\n\tborder-radius: var(--tile-select-trigger-radius, $radius);\n\tborder: 1px solid var(--tile-select-trigger-border, $input);\n\tbackground-color: var(--tile-select-trigger-background, transparent);\n\tpadding: var(--tile-select-trigger-padding, $spacing-2 $spacing-3);\n\tfont-size: var(--tile-select-font-size, $text-sm);\n\twhite-space: nowrap;\n\tcolor: var(--tile-select-foreground, $foreground);\n\tbox-shadow: var(--tile-select-trigger-shadow, $shadow-sm);\n\ttransition:\n\t\tcolor $transition-fast,\n\t\tbox-shadow $transition-fast;\n\toutline: none;\n\tcursor: pointer;\n\n\t&:focus-visible {\n\t\tborder-color: var(--tile-select-focus-border, $ring);\n\t\tbox-shadow: var(--tile-select-focus-shadow, 0 0 0 3px color-mix(in srgb, $ring 50%, transparent));\n\t}\n\n\t&[data-disabled='true'] {\n\t\tcursor: not-allowed;\n\t\topacity: 0.5;\n\t}\n}\n\n// 触发器展示文本\n.triggerValue {\n\tdisplay: flex;\n\tflex: 1;\n\talign-items: center;\n\tgap: $spacing-2;\n\toverflow: hidden;\n\ttext-align: left;\n\n\t&[data-placeholder='true'] {\n\t\tcolor: var(--tile-select-placeholder, $muted-foreground);\n\t}\n}\n\n// 触发器箭头\n.triggerIcon {\n\tpointer-events: none;\n\tflex-shrink: 0;\n\twidth: 1rem;\n\theight: 1rem;\n\tcolor: var(--tile-select-subtle, $muted-foreground);\n}\n\n// 弹出内容面板\n.content {\n\tposition: fixed;\n\tz-index: $z-dropdown;\n\twidth: max-content;\n\tmin-width: 8rem;\n\tmax-height: 24rem;\n\toverflow: hidden;\n\tborder-radius: var(--tile-select-panel-radius, $radius);\n\tborder: 1px solid var(--tile-select-panel-border, $border);\n\tbackground-color: var(--tile-select-panel-background, $popover);\n\tcolor: var(--tile-select-panel-foreground, $popover-foreground);\n\tbox-shadow: var(--tile-select-panel-shadow, $shadow-md);\n\toutline: none;\n\ttransition:\n\t\topacity $transition-fast,\n\t\tvisibility $transition-fast,\n\t\ttransform $transition-fast;\n\n\t&[data-state='closed'] {\n\t\topacity: 0;\n\t\tvisibility: hidden;\n\t\tpointer-events: none;\n\t\ttransform: translateY(-0.25rem) scale(0.95);\n\t}\n\n\t&[data-state='open'] {\n\t\topacity: 1;\n\t\tvisibility: visible;\n\t\tpointer-events: auto;\n\t\ttransform: translate(0, 0) scale(1);\n\t}\n}\n\n// 搜索框容器\n.search {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: $spacing-2;\n\tborder-bottom: 1px solid var(--tile-select-separator, $border);\n\tpadding: var(--tile-combobox-search-padding, $spacing-2);\n}\n\n// 搜索输入框\n.searchInput {\n\twidth: 100%;\n\tborder: none;\n\tbackground: transparent;\n\tpadding: 0;\n\tfont-size: var(--tile-select-font-size, $text-sm);\n\tcolor: var(--tile-select-foreground, $foreground);\n\toutline: none;\n\n\t&::placeholder {\n\t\tcolor: var(--tile-select-placeholder, $muted-foreground);\n\t}\n}\n\n// 候选项列表\n.list {\n\tmax-height: 15rem;\n\toverflow-x: hidden;\n\toverflow-y: auto;\n\tpadding: var(--tile-combobox-list-padding, $spacing-1);\n}\n\n// 候选项\n.item {\n\tposition: relative;\n\tdisplay: flex;\n\twidth: 100%;\n\talign-items: center;\n\tgap: $spacing-2;\n\tborder-radius: var(--tile-select-item-radius, $radius-sm);\n\tpadding: var(--tile-select-item-padding, $spacing-1_5 $spacing-2 $spacing-1_5 $spacing-8);\n\tfont-size: var(--tile-select-font-size, $text-sm);\n\tline-height: $leading-normal;\n\tcolor: var(--tile-select-panel-foreground, $popover-foreground);\n\toutline: none;\n\tcursor: default;\n\tuser-select: none;\n\n\t&[data-highlighted='true'] {\n\t\tbackground-color: var(--tile-select-highlight-background, $accent);\n\t\tcolor: var(--tile-select-highlight-foreground, $accent-foreground);\n\t}\n\n\t&[data-disabled='true'] {\n\t\tpointer-events: none;\n\t\topacity: 0.5;\n\t}\n\n\t&:focus-visible {\n\t\tbox-shadow: var(--tile-select-item-focus-shadow, none);\n\t}\n}\n\n// 选中指示器\n.itemIndicator {\n\tposition: absolute;\n\tleft: var(--tile-select-indicator-left, $spacing-2);\n\tright: var(--tile-select-indicator-right, auto);\n\tdisplay: inline-flex;\n\twidth: 0.875rem;\n\theight: 0.875rem;\n\talign-items: center;\n\tjustify-content: center;\n\n\t& > svg {\n\t\twidth: 1rem;\n\t\theight: 1rem;\n\t}\n}\n\n// 空状态提示\n.empty {\n\tdisplay: flex;\n\twidth: 100%;\n\talign-items: center;\n\tjustify-content: center;\n\tpadding: $spacing-6;\n\ttext-align: center;\n\tfont-size: var(--tile-select-font-size, $text-sm);\n\tcolor: var(--tile-select-subtle, $muted-foreground);\n}\n\n// 分组容器\n.group {\n\tdisplay: block;\n}\n\n// 分组标题\n.label {\n\tpadding: var(--tile-select-label-padding, $spacing-1_5 $spacing-2);\n\tfont-size: var(--tile-select-label-font-size, $text-xs);\n\tfont-weight: var(--tile-select-label-font-weight, $font-medium);\n\tcolor: var(--tile-select-subtle, $muted-foreground);\n}\n\n// 分隔线\n.separator {\n\tmargin: $spacing-1 (-$spacing-1);\n\theight: 1px;\n\tbackground-color: var(--tile-select-separator, $border);\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t.content {\n\t\ttransition: none;\n\t}\n}\n"
    },
    {
      "path": "packages/react/src/components/combobox/index.ts",
      "type": "registry:ui",
      "target": "components/ui/combobox/index.ts",
      "content": "export { Combobox, type ComboboxProps } from './combobox';\n"
    }
  ]
}
