{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "select", "title": "Select", "description": "A hand-drawn select dropdown with a rough chevron indicator.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json", "utils" ], "files": [ { "path": "registry/new-york/ui/select.tsx", "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport {\n randomSeed,\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SelectOption {\n disabled?: boolean;\n label: string;\n value: string;\n}\n\nexport interface SelectProps 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: SelectOption[];\n placeholder?: string;\n theme?: CrumbleTheme;\n value?: string;\n}\n\nconst TRIGGER_HEIGHT = 40;\nconst OPTION_HEIGHT = 36;\n\ninterface SelectOptionRowProps {\n isSelected: boolean;\n onSelect: () => void;\n option: SelectOption;\n}\n\nfunction SelectOptionRow({\n isSelected,\n onSelect,\n option,\n}: SelectOptionRowProps) {\n const [hovered, setHovered] = useState(false);\n const containerRef = useRef(null);\n const externalSvgRef = useRef(null);\n const { drawRect, svgRef } = useRough({\n variant: \"fill\",\n stableId: option.value,\n svgRef: externalSvgRef,\n });\n\n const draw = useCallback(() => {\n const container = containerRef.current;\n const svg = svgRef.current;\n if (!container || !svg) return;\n\n const width = container.offsetWidth;\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\n if (!hovered && !isSelected) return;\n\n const highlight = drawRect(2, 2, width - 4, OPTION_HEIGHT - 4, {\n fill: hovered ? \"currentColor\" : \"none\",\n fillStyle: \"hachure\",\n fillWeight: isSelected ? 1.5 : 1,\n stroke: hovered ? \"currentColor\" : \"none\",\n strokeWidth: 0,\n });\n if (highlight) svg.appendChild(highlight);\n }, [drawRect, hovered, isSelected, svgRef]);\n\n useEffect(() => {\n draw();\n }, [draw]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n const observer = new ResizeObserver(() => draw());\n observer.observe(container);\n return () => observer.disconnect();\n }, [draw]);\n\n return (\n setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n >\n \n {option.label}\n \n );\n}\n\nexport function Select({\n className,\n defaultValue,\n disabled,\n error,\n fill,\n id,\n label,\n onBlur,\n onChange,\n onFocus,\n options,\n placeholder,\n stroke,\n strokeMuted,\n theme: themeProp,\n value: controlledValue,\n}: SelectProps) {\n const [open, setOpen] = useState(false);\n const [focused, setFocused] = useState(false);\n const [internalValue, setInternalValue] = useState(defaultValue ?? \"\");\n const value = controlledValue ?? internalValue;\n const selectedOption = options.find((option) => option.value === value);\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n\n const wrapperRef = useRef(null);\n const triggerRef = useRef(null);\n const svgRef = useRef(null);\n const arrowRef = useRef(null);\n const dropdownSvgRef = useRef(null);\n\n const selectId =\n id ?? `select-${label?.toLowerCase().replace(/\\s+/g, \"-\") ?? \"field\"}`;\n\n const { drawLine, drawRect, animateOnHover, theme } = useRough({\n stableId: selectId,\n svgRef,\n theme: themeProp,\n variant: \"border\",\n });\n const { drawRect: drawDropdownRect } = useRough({\n stableId: `${selectId}-dropdown`,\n svgRef: dropdownSvgRef,\n theme: themeProp,\n variant: \"border\",\n });\n\n const drawTrigger = useCallback(\n (reseed = false) => {\n const svg = svgRef.current;\n const arrow = arrowRef.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 ? \"color-mix(in srgb, currentColor 10%, red)\"\n : focused\n ? \"currentColor\"\n : \"color-mix(in srgb, currentColor 40%, transparent)\";\n\n const box = drawRect(1, 1, width - 2, TRIGGER_HEIGHT - 2, {\n fill: \"none\",\n seed: reseed ? randomSeed() : stableSeed(selectId),\n stroke: currentStroke,\n });\n if (box) svg.appendChild(box);\n\n if (!arrow) return;\n arrow.replaceChildren();\n const chevronStroke = disabled ? \"var(--cr-stroke-muted)\" : currentStroke;\n\n const leftLine = drawLine(2, open ? 10 : 4, 8, open ? 4 : 10, {\n stroke: chevronStroke,\n strokeWidth: 1.5,\n });\n const rightLine = drawLine(8, open ? 4 : 10, 14, open ? 10 : 4, {\n stroke: chevronStroke,\n strokeWidth: 1.5,\n });\n\n if (leftLine) arrow.appendChild(leftLine);\n if (rightLine) arrow.appendChild(rightLine);\n },\n [disabled, drawLine, drawRect, error, focused, open, selectId],\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 = options.length * 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, options.length]);\n\n useEffect(() => {\n drawTrigger();\n }, [drawTrigger, open, theme]);\n\n useEffect(() => {\n if (open) {\n const id = requestAnimationFrame(() => drawDropdown());\n return () => cancelAnimationFrame(id);\n }\n }, [drawDropdown, open]);\n\n useEffect(() => {\n if (!open) return;\n\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 onBlur?.();\n }\n };\n\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [onBlur, open]);\n\n useEffect(() => {\n if (!open) return;\n\n const handler = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n setOpen(false);\n triggerRef.current?.focus();\n }\n };\n\n document.addEventListener(\"keydown\", handler);\n return () => document.removeEventListener(\"keydown\", handler);\n }, [open]);\n\n useEffect(() => {\n const wrapper = wrapperRef.current;\n if (!wrapper) return;\n\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 const handleSelect = (option: SelectOption) => {\n if (option.disabled) return;\n setInternalValue(option.value);\n onChange?.(option.value);\n setOpen(false);\n setFocused(false);\n onBlur?.();\n };\n\n return (\n
\n {label ? (\n \n {label}\n \n ) : null}\n\n
\n {\n if (disabled) return;\n setOpen((current) => !current);\n setFocused(true);\n onFocus?.();\n }}\n onFocus={() => {\n if (!disabled) {\n setFocused(true);\n onFocus?.();\n }\n }}\n onBlur={() => {\n if (!open) {\n setFocused(false);\n onBlur?.();\n }\n }}\n onKeyDown={(event) => {\n if (disabled) return;\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n setOpen((current) => !current);\n }\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setOpen(true);\n }\n }}\n onMouseEnter={() => {\n if (!disabled && animateOnHover) drawTrigger(true);\n }}\n onMouseLeave={() => {\n if (!disabled && animateOnHover) drawTrigger(false);\n }}\n >\n \n \n {selectedOption?.label ?? placeholder ?? \"\"}\n \n \n
\n\n {open ? (\n \n \n\n {options.map((option) => {\n const isSelected = option.value === value;\n return (\n handleSelect(option)}\n />\n );\n })}\n
\n ) : null}\n \n\n {error ? {error} : null}\n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/select.tsx" } ], "type": "registry:ui" }