{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "input-group", "title": "Input Group", "description": "Composable input surface with inline or stacked addons, compact actions, text inputs, textareas, and validation states. Includes the legacy InputFieldGroup for multi-field forms.", "dependencies": [ "@base-ui/react", "class-variance-authority" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "utils", "https://zeron-ui.vercel.app/r/shape-context.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/use-proximity-hover.json", "https://zeron-ui.vercel.app/r/button.json", "https://zeron-ui.vercel.app/r/input.json" ], "files": [ { "path": "src/components/ui/input-group.tsx", "content": "\"use client\";\n\nimport {\n useRef,\n useState,\n useEffect,\n useMemo,\n createContext,\n useContext,\n forwardRef,\n type ReactNode,\n type HTMLAttributes,\n type InputHTMLAttributes,\n type MouseEventHandler,\n type TextareaHTMLAttributes,\n} from \"react\";\nimport { Field } from \"@base-ui/react/field\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type { IconComponent } from \"@/lib/icon-context\";\nimport { cn } from \"@/lib/utils\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { useProximityHover } from \"@/hooks/use-proximity-hover\";\nimport { Button, type ButtonProps } from \"@/components/ui/button\";\nimport { Input, type InputProps } from \"@/components/ui/input\";\n\n// ── Compound input group ─────────────────────────────────\n\ntype InputGroupProps = HTMLAttributes;\n\nconst InputGroup = forwardRef(\n ({ className, ...props }, ref) => {\n const shape = useShape();\n\n return (\n \n );\n }\n);\n\nInputGroup.displayName = \"InputGroup\";\n\nconst inputGroupAddonVariants = cva(\n \"flex cursor-text select-none items-center justify-center gap-2 px-3 text-body text-fg-muted [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n {\n variants: {\n align: {\n \"inline-start\": \"order-first\",\n \"inline-end\": \"order-last\",\n \"block-start\": \"order-first w-full justify-start py-2\",\n \"block-end\": \"order-last w-full justify-start py-2\",\n },\n },\n defaultVariants: {\n align: \"inline-start\",\n },\n }\n);\n\ninterface InputGroupAddonProps\n extends HTMLAttributes,\n VariantProps {}\n\nconst InputGroupAddon = forwardRef(\n ({ className, align = \"inline-start\", onClick, ...props }, ref) => {\n const handleClick: MouseEventHandler = (event) => {\n onClick?.(event);\n if (event.defaultPrevented || (event.target as HTMLElement).closest(\"button\")) {\n return;\n }\n\n event.currentTarget.parentElement\n ?.querySelector(\"input, textarea\")\n ?.focus();\n };\n\n return (\n \n );\n }\n);\n\nInputGroupAddon.displayName = \"InputGroupAddon\";\n\nconst inputGroupButtonVariants = cva(\"flex items-center shadow-none\", {\n variants: {\n size: {\n xs: \"h-6 px-2 text-label\",\n sm: \"h-control-xs px-2.5 text-label\",\n \"icon-xs\": \"size-6 p-0\",\n \"icon-sm\": \"size-control-xs p-0\",\n },\n },\n defaultVariants: {\n size: \"xs\",\n },\n});\n\ninterface InputGroupButtonProps\n extends Omit,\n VariantProps {}\n\nconst InputGroupButton = forwardRef(\n (\n {\n className,\n size = \"xs\",\n type = \"button\",\n variant = \"ghost\",\n ...props\n },\n ref\n ) => {\n const buttonSize = size === \"icon-xs\" || size === \"icon-sm\" ? \"icon-sm\" : \"sm\";\n\n return (\n \n );\n }\n);\n\nInputGroupButton.displayName = \"InputGroupButton\";\n\ntype InputGroupTextProps = HTMLAttributes;\n\nconst InputGroupText = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\n\nInputGroupText.displayName = \"InputGroupText\";\n\ntype InputGroupInputProps = InputProps;\n\nconst InputGroupInput = forwardRef(\n ({ className, ...props }, ref) => (\n \n )\n);\n\nInputGroupInput.displayName = \"InputGroupInput\";\n\ntype InputGroupTextareaProps = TextareaHTMLAttributes;\n\nconst InputGroupTextarea = forwardRef<\n HTMLTextAreaElement,\n InputGroupTextareaProps\n>(({ className, ...props }, ref) => (\n \n));\n\nInputGroupTextarea.displayName = \"InputGroupTextarea\";\n\n// ── Legacy multi-field group ────────────────────────────\n// Kept under an explicit name so existing field-group compositions can migrate\n// without competing with the shadcn-style compound InputGroup above.\n\ninterface InputGroupContextValue {\n registerItem: (index: number, element: HTMLElement | null) => void;\n activeIndex: number | null;\n}\n\nconst InputGroupContext = createContext(null);\n\nfunction useInputGroup() {\n const ctx = useContext(InputGroupContext);\n if (!ctx)\n throw new Error(\"InputField must be used within an InputFieldGroup\");\n return ctx;\n}\n\ninterface InputFieldGroupProps extends HTMLAttributes {\n children: ReactNode;\n}\n\nconst InputFieldGroup = forwardRef(\n ({ children, className, ...props }, ref) => {\n const containerRef = useRef(null);\n\n const { activeIndex, handlers, registerItem, measureItems } =\n useProximityHover(containerRef);\n\n useEffect(() => {\n measureItems();\n }, [measureItems, children]);\n\n const contextValue = useMemo(\n () => ({ registerItem, activeIndex }),\n [registerItem, activeIndex]\n );\n\n return (\n \n {\n (containerRef as React.MutableRefObject).current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.MutableRefObject).current = node;\n }}\n onMouseEnter={handlers.onMouseEnter}\n onMouseMove={handlers.onMouseMove}\n onMouseLeave={handlers.onMouseLeave}\n // `relative` makes this div the fields' offsetParent — the proximity\n // hook measures items via offsetTop and compares against\n // container-relative mouse coords, so the two coordinate spaces must\n // share this origin (same as every other proximity consumer).\n className={cn(\"relative flex flex-col gap-3 w-72 max-w-full\", className)}\n {...props}\n >\n {children}\n \n \n );\n }\n);\n\nInputFieldGroup.displayName = \"InputFieldGroup\";\n\ninterface InputFieldProps\n extends Omit, \"onChange\" | \"index\"> {\n label: string;\n placeholder?: string;\n icon?: IconComponent;\n index: number;\n value: string;\n onChange: (value: string) => void;\n error?: string;\n disabled?: boolean;\n className?: string;\n}\n\nconst InputField = forwardRef(\n (\n {\n label,\n placeholder,\n icon: Icon,\n index,\n value,\n onChange,\n error,\n disabled,\n className,\n ...props\n },\n ref\n ) => {\n const internalRef = useRef(null);\n const inputRef = useRef(null);\n const { registerItem, activeIndex } = useInputGroup();\n const [isFocused, setIsFocused] = useState(false);\n const shape = useShape();\n\n useEffect(() => {\n registerItem(index, internalRef.current);\n return () => registerItem(index, null);\n }, [index, registerItem]);\n\n const isActive = activeIndex === index;\n const labelActive = isActive || isFocused;\n\n const handleFocus = () => {\n setIsFocused(true);\n };\n\n const handleBlur = () => {\n setIsFocused(false);\n };\n\n // Input container classes\n let bgClass: string;\n let ringClass: string;\n\n if (disabled) {\n bgClass = \"bg-transparent\";\n ringClass = \"ring-border\";\n } else if (error) {\n bgClass = isFocused ? \"bg-surface-raised\" : isActive ? \"bg-danger-surface\" : \"bg-transparent\";\n ringClass = isFocused || isActive ? \"ring-danger-border/50\" : \"ring-transparent\";\n } else if (isFocused) {\n bgClass = \"bg-surface-raised\";\n ringClass = \"ring-border\";\n } else if (isActive) {\n bgClass = \"bg-muted/50\";\n ringClass = \"ring-border\";\n } else {\n bgClass = \"bg-transparent\";\n ringClass = \"ring-transparent\";\n }\n\n return (\n // Base UI Field wires the accessibility plumbing: Field.Label's htmlFor\n // targets the control, Field.Error's generated id lands in the control's\n // aria-describedby, and `invalid` drives aria-invalid / data-invalid.\n {\n (internalRef as React.MutableRefObject).current = node;\n if (typeof ref === \"function\") ref(node);\n else if (ref) (ref as React.MutableRefObject).current = node;\n }}\n invalid={!!error}\n disabled={disabled}\n className={cn(\n \"flex flex-col gap-1 cursor-text\",\n disabled && \"opacity-50 pointer-events-none\",\n className\n )}\n >\n {/* Label */}\n \n \n {label}\n \n \n {label}\n \n \n\n {/* Input container */}\n {\n // The old wrapper was one big