{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "combobox", "title": "Combobox", "description": "A searchable hand-drawn select with filtered dropdown and keyboard navigation.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "utils", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json" ], "files": [ { "path": "registry/new-york/ui/combobox.tsx", "content": "\"use client\";\n\nimport {\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type ChangeEvent,\n} from \"react\";\nimport rough from \"roughjs\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport {\n CrumbleContext,\n getRoughOptions,\n randomSeed,\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\nexport interface ComboboxOption {\n disabled?: boolean;\n label: string;\n value: string;\n}\n\nexport interface ComboboxProps extends CrumbleColorProps {\n className?: string;\n defaultValue?: string;\n disabled?: boolean;\n error?: string;\n id?: string;\n label?: string;\n onBlur?: () => void;\n onChange?: (value: string) => void;\n onFocus?: () => void;\n options: ComboboxOption[];\n placeholder?: string;\n theme?: CrumbleTheme;\n value?: string;\n}\n\nconst TRIGGER_HEIGHT = 40;\nconst OPTION_HEIGHT = 36;\n\nexport function Combobox({\n className,\n defaultValue = \"\",\n disabled = false,\n error,\n fill,\n id,\n label,\n onBlur,\n onChange,\n onFocus,\n options,\n placeholder = \"Search...\",\n stroke,\n strokeMuted,\n theme: themeProp,\n value: controlledValue,\n}: ComboboxProps) {\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [internalValue, setInternalValue] = useState(defaultValue);\n const [focused, setFocused] = useState(false);\n\n const value = controlledValue ?? internalValue;\n const selectedOption = options.find((option) => option.value === value);\n const wrapperRef = useRef(null);\n const inputRef = useRef(null);\n const triggerSvgRef = useRef(null);\n const optionSvgRefs = useRef>(new Map());\n const dropdownSvgRef = useRef(null);\n const comboId =\n id ?? `combobox-${label?.toLowerCase().replace(/\\s+/g, \"-\") ?? \"field\"}`;\n const filtered = query.trim()\n ? options.filter((option) =>\n option.label.toLowerCase().includes(query.toLowerCase()),\n )\n : options;\n const { theme: contextTheme } = useContext(CrumbleContext);\n const theme = themeProp ?? contextTheme;\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n const { drawRect: drawTriggerRect } = useRough({\n stableId: comboId,\n svgRef: triggerSvgRef,\n theme: themeProp,\n variant: \"border\",\n });\n const { drawRect: drawDropdownRect } = useRough({\n stableId: `${comboId}-dropdown`,\n svgRef: dropdownSvgRef,\n theme: themeProp,\n variant: \"border\",\n });\n\n const drawTrigger = useCallback(\n (reseed = false) => {\n const svg = triggerSvgRef.current;\n const wrapper = wrapperRef.current;\n if (!svg || !wrapper) return;\n\n svg.replaceChildren();\n const width = wrapper.offsetWidth;\n svg.setAttribute(\"width\", String(width));\n svg.setAttribute(\"height\", String(TRIGGER_HEIGHT));\n svg.setAttribute(\"viewBox\", `0 0 ${width} ${TRIGGER_HEIGHT}`);\n\n const currentStroke = error\n ? \"var(--cr-stroke-error)\"\n : focused || open\n ? \"var(--cr-stroke)\"\n : \"var(--cr-stroke-muted)\";\n\n const rect = drawTriggerRect(1, 1, width - 2, TRIGGER_HEIGHT - 2, {\n fill: \"none\",\n seed: reseed ? randomSeed() : undefined,\n stroke: currentStroke,\n });\n if (rect) svg.appendChild(rect);\n },\n [drawTriggerRect, error, focused, open],\n );\n\n const drawDropdown = useCallback(() => {\n const svg = dropdownSvgRef.current;\n if (!svg) return;\n\n const width = svg.parentElement?.offsetWidth ?? 200;\n const height = Math.max(filtered.length * OPTION_HEIGHT, OPTION_HEIGHT);\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(width));\n svg.setAttribute(\"height\", String(height));\n svg.setAttribute(\"viewBox\", `0 0 ${width} ${height}`);\n\n const rect = drawDropdownRect(1, 1, width - 2, height - 2, {\n fill: \"none\",\n stroke: \"var(--cr-stroke)\",\n });\n if (rect) svg.appendChild(rect);\n }, [drawDropdownRect, filtered.length]);\n\n const drawOption = useCallback(\n (svg: SVGSVGElement, width: number, highlighted: boolean) => {\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(width));\n svg.setAttribute(\"height\", String(OPTION_HEIGHT));\n svg.setAttribute(\"viewBox\", `0 0 ${width} ${OPTION_HEIGHT}`);\n if (!highlighted) return;\n\n const renderer = rough.svg(svg);\n svg.appendChild(\n renderer.rectangle(\n 2,\n 2,\n width - 4,\n OPTION_HEIGHT - 4,\n getRoughOptions(theme, \"fill\", {\n fill: \"currentColor\",\n fillStyle: \"hachure\",\n seed: stableSeed(\"opt-hl\"),\n stroke: \"none\",\n strokeWidth: 0,\n }),\n ),\n );\n },\n [theme],\n );\n\n useEffect(() => {\n drawTrigger();\n }, [drawTrigger]);\n\n useEffect(() => {\n if (!open) return;\n const id = requestAnimationFrame(() => drawDropdown());\n return () => cancelAnimationFrame(id);\n }, [drawDropdown, open]);\n\n useEffect(() => {\n const wrapper = wrapperRef.current;\n if (!wrapper) return;\n const observer = new ResizeObserver(() => {\n drawTrigger();\n if (open) drawDropdown();\n });\n observer.observe(wrapper);\n return () => observer.disconnect();\n }, [drawDropdown, drawTrigger, open]);\n\n useEffect(() => {\n if (!open) return;\n const handler = (event: MouseEvent) => {\n if (\n wrapperRef.current &&\n !wrapperRef.current.contains(event.target as Node)\n ) {\n setOpen(false);\n setFocused(false);\n setQuery(\"\");\n onBlur?.();\n }\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [onBlur, open]);\n\n useEffect(() => {\n if (!open) return;\n const handler = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n setOpen(false);\n inputRef.current?.blur();\n }\n };\n document.addEventListener(\"keydown\", handler);\n return () => document.removeEventListener(\"keydown\", handler);\n }, [open]);\n\n const handleSelect = (option: ComboboxOption) => {\n if (option.disabled) return;\n setInternalValue(option.value);\n onChange?.(option.value);\n setOpen(false);\n setQuery(\"\");\n setFocused(false);\n onBlur?.();\n };\n\n const handleInputChange = (event: ChangeEvent) => {\n setQuery(event.target.value);\n if (!open) setOpen(true);\n };\n\n return (\n
\n {label ? (\n \n {label}\n \n ) : null}\n\n
\n
\n \n {\n setFocused(true);\n setOpen(true);\n onFocus?.();\n }}\n onBlur={() => {\n if (!open) {\n setFocused(false);\n onBlur?.();\n }\n }}\n onChange={handleInputChange}\n className={cn(\n \"absolute inset-0 h-full w-full border-none bg-transparent px-3 pr-9 text-sm text-foreground outline-none\",\n disabled && \"cursor-not-allowed opacity-40\",\n )}\n />\n \n \n \n \n
\n\n {open ? (\n \n \n {filtered.length === 0 ? (\n \n No results\n
\n ) : (\n filtered.map((option) => {\n const isSelected = option.value === value;\n return (\n handleSelect(option)}\n onMouseEnter={(event) => {\n const svg = optionSvgRefs.current.get(option.value);\n if (svg) {\n drawOption(svg, event.currentTarget.offsetWidth, true);\n }\n }}\n onMouseLeave={(event) => {\n const svg = optionSvgRefs.current.get(option.value);\n if (svg) {\n drawOption(svg, event.currentTarget.offsetWidth, false);\n }\n }}\n >\n {\n if (element) {\n optionSvgRefs.current.set(option.value, element);\n } else {\n optionSvgRefs.current.delete(option.value);\n }\n }}\n />\n {option.label}\n
\n );\n })\n )}\n \n ) : null}\n \n\n {error ? {error} : null}\n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/combobox.tsx" } ], "type": "registry:ui" }