@srcroot/ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +151 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +640 -0
  4. package/package.json +43 -0
  5. package/registry/accordion.tsx +158 -0
  6. package/registry/alert-dialog.tsx +206 -0
  7. package/registry/alert.tsx +73 -0
  8. package/registry/aspect-ratio.tsx +44 -0
  9. package/registry/avatar.tsx +94 -0
  10. package/registry/badge.tsx +68 -0
  11. package/registry/breadcrumb.tsx +151 -0
  12. package/registry/button-group.tsx +84 -0
  13. package/registry/button.tsx +102 -0
  14. package/registry/calendar.tsx +238 -0
  15. package/registry/card.tsx +114 -0
  16. package/registry/carousel.tsx +169 -0
  17. package/registry/checkbox.tsx +79 -0
  18. package/registry/collapsible.tsx +110 -0
  19. package/registry/container.tsx +60 -0
  20. package/registry/dialog.tsx +264 -0
  21. package/registry/dropdown-menu.tsx +387 -0
  22. package/registry/image.tsx +144 -0
  23. package/registry/input.tsx +44 -0
  24. package/registry/label.tsx +34 -0
  25. package/registry/loading-spinner.tsx +108 -0
  26. package/registry/otp-input.tsx +152 -0
  27. package/registry/pagination.tsx +146 -0
  28. package/registry/popover.tsx +135 -0
  29. package/registry/progress.tsx +49 -0
  30. package/registry/radio.tsx +99 -0
  31. package/registry/search.tsx +146 -0
  32. package/registry/select.tsx +190 -0
  33. package/registry/separator.tsx +44 -0
  34. package/registry/sheet.tsx +180 -0
  35. package/registry/skeleton.tsx +26 -0
  36. package/registry/slider.tsx +115 -0
  37. package/registry/star-rating.tsx +131 -0
  38. package/registry/switch.tsx +70 -0
  39. package/registry/table.tsx +136 -0
  40. package/registry/tabs.tsx +122 -0
  41. package/registry/text.tsx +70 -0
  42. package/registry/textarea.tsx +39 -0
  43. package/registry/toast.tsx +95 -0
  44. package/registry/tooltip.tsx +122 -0
@@ -0,0 +1,108 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const spinnerVariants = cva(
6
+ "animate-spin",
7
+ {
8
+ variants: {
9
+ size: {
10
+ xs: "h-3 w-3",
11
+ sm: "h-4 w-4",
12
+ default: "h-6 w-6",
13
+ lg: "h-8 w-8",
14
+ xl: "h-12 w-12",
15
+ },
16
+ variant: {
17
+ default: "text-primary",
18
+ muted: "text-muted-foreground",
19
+ white: "text-white",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ size: "default",
24
+ variant: "default",
25
+ },
26
+ }
27
+ )
28
+
29
+ type SpinnerVariants = VariantProps<typeof spinnerVariants>
30
+
31
+ interface LoadingSpinnerProps extends SpinnerVariants {
32
+ className?: string
33
+ /** Accessible label for screen readers */
34
+ label?: string
35
+ }
36
+
37
+ /**
38
+ * Loading spinner with size and color variants
39
+ *
40
+ * @example
41
+ * <LoadingSpinner />
42
+ * <LoadingSpinner size="lg" variant="muted" />
43
+ * <LoadingSpinner size="sm" label="Submitting..." />
44
+ */
45
+ const LoadingSpinner = React.forwardRef<SVGSVGElement, LoadingSpinnerProps>(
46
+ ({ className, size, variant, label = "Loading" }, ref) => (
47
+ <svg
48
+ ref={ref}
49
+ className={cn(spinnerVariants({ size, variant, className }))}
50
+ xmlns="http://www.w3.org/2000/svg"
51
+ fill="none"
52
+ viewBox="0 0 24 24"
53
+ role="status"
54
+ aria-label={label}
55
+ >
56
+ <circle
57
+ className="opacity-25"
58
+ cx="12"
59
+ cy="12"
60
+ r="10"
61
+ stroke="currentColor"
62
+ strokeWidth="4"
63
+ />
64
+ <path
65
+ className="opacity-75"
66
+ fill="currentColor"
67
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
68
+ />
69
+ </svg>
70
+ )
71
+ )
72
+ LoadingSpinner.displayName = "LoadingSpinner"
73
+
74
+ interface LoadingOverlayProps extends SpinnerVariants {
75
+ className?: string
76
+ children?: React.ReactNode
77
+ /** Whether to show the loading state */
78
+ loading?: boolean
79
+ /** Text to display below spinner */
80
+ text?: string
81
+ }
82
+
83
+ /**
84
+ * Full overlay loading state
85
+ *
86
+ * @example
87
+ * <LoadingOverlay loading={isLoading}>
88
+ * <YourContent />
89
+ * </LoadingOverlay>
90
+ */
91
+ const LoadingOverlay = React.forwardRef<HTMLDivElement, LoadingOverlayProps>(
92
+ ({ className, children, loading = true, text, size = "lg", variant }, ref) => {
93
+ if (!loading) return <>{children}</>
94
+
95
+ return (
96
+ <div ref={ref} className={cn("relative", className)}>
97
+ {children && <div className="opacity-50 pointer-events-none">{children}</div>}
98
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-background/80">
99
+ <LoadingSpinner size={size} variant={variant} />
100
+ {text && <p className="mt-2 text-sm text-muted-foreground">{text}</p>}
101
+ </div>
102
+ </div>
103
+ )
104
+ }
105
+ )
106
+ LoadingOverlay.displayName = "LoadingOverlay"
107
+
108
+ export { LoadingSpinner, LoadingOverlay, spinnerVariants }
@@ -0,0 +1,152 @@
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ interface OtpInputProps {
5
+ /** Number of OTP digits */
6
+ length?: number
7
+ /** Callback when OTP is complete */
8
+ onComplete?: (otp: string) => void
9
+ /** Callback when value changes */
10
+ onChange?: (value: string) => void
11
+ /** Current value */
12
+ value?: string
13
+ /** Whether input is disabled */
14
+ disabled?: boolean
15
+ /** Auto focus first input */
16
+ autoFocus?: boolean
17
+ /** Input type (number or password for hidden) */
18
+ type?: "number" | "password"
19
+ className?: string
20
+ }
21
+
22
+ /**
23
+ * OTP Input component with auto-advance
24
+ *
25
+ * @example
26
+ * const [otp, setOtp] = useState("")
27
+ * <OtpInput
28
+ * length={6}
29
+ * value={otp}
30
+ * onChange={setOtp}
31
+ * onComplete={(code) => verifyOtp(code)}
32
+ * />
33
+ */
34
+ const OtpInput = React.forwardRef<HTMLDivElement, OtpInputProps>(
35
+ (
36
+ {
37
+ length = 6,
38
+ onComplete,
39
+ onChange,
40
+ value = "",
41
+ disabled,
42
+ autoFocus,
43
+ type = "number",
44
+ className,
45
+ },
46
+ ref
47
+ ) => {
48
+ const inputRefs = React.useRef<(HTMLInputElement | null)[]>([])
49
+ const [values, setValues] = React.useState<string[]>(
50
+ value.split("").concat(Array(length - value.length).fill(""))
51
+ )
52
+
53
+ React.useEffect(() => {
54
+ const newValues = value.split("").concat(Array(length - value.length).fill(""))
55
+ setValues(newValues.slice(0, length))
56
+ }, [value, length])
57
+
58
+ const focusInput = (index: number) => {
59
+ if (index >= 0 && index < length) {
60
+ inputRefs.current[index]?.focus()
61
+ }
62
+ }
63
+
64
+ const handleChange = (index: number, inputValue: string) => {
65
+ if (disabled) return
66
+
67
+ // Only allow single digit
68
+ const digit = inputValue.slice(-1)
69
+ if (type === "number" && digit && !/^\d$/.test(digit)) return
70
+
71
+ const newValues = [...values]
72
+ newValues[index] = digit
73
+ setValues(newValues)
74
+
75
+ const newOtp = newValues.join("")
76
+ onChange?.(newOtp)
77
+
78
+ // Auto-advance to next input
79
+ if (digit && index < length - 1) {
80
+ focusInput(index + 1)
81
+ }
82
+
83
+ // Check if complete
84
+ if (newOtp.length === length && !newOtp.includes("")) {
85
+ onComplete?.(newOtp)
86
+ }
87
+ }
88
+
89
+ const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
90
+ if (e.key === "Backspace") {
91
+ if (!values[index] && index > 0) {
92
+ focusInput(index - 1)
93
+ }
94
+ } else if (e.key === "ArrowLeft") {
95
+ e.preventDefault()
96
+ focusInput(index - 1)
97
+ } else if (e.key === "ArrowRight") {
98
+ e.preventDefault()
99
+ focusInput(index + 1)
100
+ }
101
+ }
102
+
103
+ const handlePaste = (e: React.ClipboardEvent) => {
104
+ e.preventDefault()
105
+ const pastedData = e.clipboardData.getData("text").slice(0, length)
106
+
107
+ if (type === "number" && !/^\d+$/.test(pastedData)) return
108
+
109
+ const newValues = pastedData.split("").concat(Array(length - pastedData.length).fill(""))
110
+ setValues(newValues.slice(0, length))
111
+
112
+ const newOtp = newValues.slice(0, length).join("")
113
+ onChange?.(newOtp)
114
+
115
+ if (newOtp.length === length) {
116
+ onComplete?.(newOtp)
117
+ }
118
+
119
+ focusInput(Math.min(pastedData.length, length - 1))
120
+ }
121
+
122
+ return (
123
+ <div ref={ref} className={cn("flex gap-2", className)}>
124
+ {Array.from({ length }).map((_, index) => (
125
+ <input
126
+ key={index}
127
+ ref={(el) => { inputRefs.current[index] = el }}
128
+ type={type === "password" ? "password" : "text"}
129
+ inputMode="numeric"
130
+ maxLength={1}
131
+ value={values[index] || ""}
132
+ onChange={(e) => handleChange(index, e.target.value)}
133
+ onKeyDown={(e) => handleKeyDown(index, e)}
134
+ onPaste={handlePaste}
135
+ onFocus={(e) => e.target.select()}
136
+ disabled={disabled}
137
+ autoFocus={autoFocus && index === 0}
138
+ className={cn(
139
+ "h-12 w-12 rounded-md border border-input bg-transparent text-center text-lg font-semibold shadow-sm transition-colors",
140
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
141
+ "disabled:cursor-not-allowed disabled:opacity-50"
142
+ )}
143
+ aria-label={`Digit ${index + 1} of ${length}`}
144
+ />
145
+ ))}
146
+ </div>
147
+ )
148
+ }
149
+ )
150
+ OtpInput.displayName = "OtpInput"
151
+
152
+ export { OtpInput }
@@ -0,0 +1,146 @@
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+ import { buttonVariants } from "./button"
4
+
5
+ interface PaginationProps extends React.HTMLAttributes<HTMLElement> { }
6
+
7
+ /**
8
+ * Pagination component
9
+ *
10
+ * @example
11
+ * <Pagination>
12
+ * <PaginationContent>
13
+ * <PaginationItem>
14
+ * <PaginationPrevious href="#" />
15
+ * </PaginationItem>
16
+ * <PaginationItem>
17
+ * <PaginationLink href="#">1</PaginationLink>
18
+ * </PaginationItem>
19
+ * <PaginationItem>
20
+ * <PaginationLink href="#" isActive>2</PaginationLink>
21
+ * </PaginationItem>
22
+ * <PaginationItem>
23
+ * <PaginationNext href="#" />
24
+ * </PaginationItem>
25
+ * </PaginationContent>
26
+ * </Pagination>
27
+ */
28
+ const Pagination = ({ className, ...props }: PaginationProps) => (
29
+ <nav
30
+ role="navigation"
31
+ aria-label="pagination"
32
+ className={cn("mx-auto flex w-full justify-center", className)}
33
+ {...props}
34
+ />
35
+ )
36
+ Pagination.displayName = "Pagination"
37
+
38
+ const PaginationContent = React.forwardRef<
39
+ HTMLUListElement,
40
+ React.HTMLAttributes<HTMLUListElement>
41
+ >(({ className, ...props }, ref) => (
42
+ <ul
43
+ ref={ref}
44
+ className={cn("flex flex-row items-center gap-1", className)}
45
+ {...props}
46
+ />
47
+ ))
48
+ PaginationContent.displayName = "PaginationContent"
49
+
50
+ const PaginationItem = React.forwardRef<
51
+ HTMLLIElement,
52
+ React.LiHTMLAttributes<HTMLLIElement>
53
+ >(({ className, ...props }, ref) => (
54
+ <li ref={ref} className={cn("", className)} {...props} />
55
+ ))
56
+ PaginationItem.displayName = "PaginationItem"
57
+
58
+ interface PaginationLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
59
+ isActive?: boolean
60
+ size?: "default" | "sm" | "lg" | "icon"
61
+ }
62
+
63
+ const PaginationLink = ({
64
+ className,
65
+ isActive,
66
+ size = "icon",
67
+ ...props
68
+ }: PaginationLinkProps) => (
69
+ <a
70
+ aria-current={isActive ? "page" : undefined}
71
+ className={cn(
72
+ buttonVariants({
73
+ variant: isActive ? "outline" : "ghost",
74
+ size,
75
+ }),
76
+ className
77
+ )}
78
+ {...props}
79
+ />
80
+ )
81
+ PaginationLink.displayName = "PaginationLink"
82
+
83
+ const PaginationPrevious = ({
84
+ className,
85
+ ...props
86
+ }: React.ComponentProps<typeof PaginationLink>) => (
87
+ <PaginationLink
88
+ aria-label="Go to previous page"
89
+ size="default"
90
+ className={cn("gap-1 pl-2.5", className)}
91
+ {...props}
92
+ >
93
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
94
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
95
+ </svg>
96
+ <span>Previous</span>
97
+ </PaginationLink>
98
+ )
99
+ PaginationPrevious.displayName = "PaginationPrevious"
100
+
101
+ const PaginationNext = ({
102
+ className,
103
+ ...props
104
+ }: React.ComponentProps<typeof PaginationLink>) => (
105
+ <PaginationLink
106
+ aria-label="Go to next page"
107
+ size="default"
108
+ className={cn("gap-1 pr-2.5", className)}
109
+ {...props}
110
+ >
111
+ <span>Next</span>
112
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
113
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
114
+ </svg>
115
+ </PaginationLink>
116
+ )
117
+ PaginationNext.displayName = "PaginationNext"
118
+
119
+ const PaginationEllipsis = ({
120
+ className,
121
+ ...props
122
+ }: React.HTMLAttributes<HTMLSpanElement>) => (
123
+ <span
124
+ aria-hidden
125
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
126
+ {...props}
127
+ >
128
+ <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
129
+ <circle cx="12" cy="12" r="1" />
130
+ <circle cx="19" cy="12" r="1" />
131
+ <circle cx="5" cy="12" r="1" />
132
+ </svg>
133
+ <span className="sr-only">More pages</span>
134
+ </span>
135
+ )
136
+ PaginationEllipsis.displayName = "PaginationEllipsis"
137
+
138
+ export {
139
+ Pagination,
140
+ PaginationContent,
141
+ PaginationLink,
142
+ PaginationItem,
143
+ PaginationPrevious,
144
+ PaginationNext,
145
+ PaginationEllipsis,
146
+ }
@@ -0,0 +1,135 @@
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ interface PopoverContextValue {
5
+ open: boolean
6
+ onOpenChange: (open: boolean) => void
7
+ }
8
+
9
+ const PopoverContext = React.createContext<PopoverContextValue | null>(null)
10
+
11
+ interface PopoverProps {
12
+ children: React.ReactNode
13
+ open?: boolean
14
+ onOpenChange?: (open: boolean) => void
15
+ defaultOpen?: boolean
16
+ }
17
+
18
+ /**
19
+ * Popover component for floating content
20
+ *
21
+ * @example
22
+ * <Popover>
23
+ * <PopoverTrigger asChild>
24
+ * <Button>Open Popover</Button>
25
+ * </PopoverTrigger>
26
+ * <PopoverContent>
27
+ * Popover content here
28
+ * </PopoverContent>
29
+ * </Popover>
30
+ */
31
+ function Popover({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: PopoverProps) {
32
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
33
+
34
+ const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
35
+ const setOpen = onOpenChange || setUncontrolledOpen
36
+
37
+ return (
38
+ <PopoverContext.Provider value={{ open, onOpenChange: setOpen }}>
39
+ <div className="relative inline-block">
40
+ {children}
41
+ </div>
42
+ </PopoverContext.Provider>
43
+ )
44
+ }
45
+
46
+ interface PopoverTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
47
+ asChild?: boolean
48
+ }
49
+
50
+ const PopoverTrigger = React.forwardRef<HTMLButtonElement, PopoverTriggerProps>(
51
+ ({ onClick, asChild, children, ...props }, ref) => {
52
+ const context = React.useContext(PopoverContext)
53
+ if (!context) throw new Error("PopoverTrigger must be used within Popover")
54
+
55
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
56
+ onClick?.(e)
57
+ context.onOpenChange(!context.open)
58
+ }
59
+
60
+ if (asChild && React.isValidElement(children)) {
61
+ return React.cloneElement(children as React.ReactElement<any>, {
62
+ onClick: handleClick,
63
+ "aria-expanded": context.open,
64
+ "aria-haspopup": true,
65
+ ref,
66
+ })
67
+ }
68
+
69
+ return (
70
+ <button
71
+ ref={ref}
72
+ aria-expanded={context.open}
73
+ aria-haspopup={true}
74
+ onClick={handleClick}
75
+ {...props}
76
+ >
77
+ {children}
78
+ </button>
79
+ )
80
+ }
81
+ )
82
+ PopoverTrigger.displayName = "PopoverTrigger"
83
+
84
+ const PopoverContent = React.forwardRef<
85
+ HTMLDivElement,
86
+ React.HTMLAttributes<HTMLDivElement>
87
+ >(({ className, children, ...props }, ref) => {
88
+ const context = React.useContext(PopoverContext)
89
+ if (!context) throw new Error("PopoverContent must be used within Popover")
90
+
91
+ React.useEffect(() => {
92
+ const handleClickOutside = () => {
93
+ if (context.open) {
94
+ context.onOpenChange(false)
95
+ }
96
+ }
97
+
98
+ const handleEscape = (e: KeyboardEvent) => {
99
+ if (e.key === "Escape" && context.open) {
100
+ context.onOpenChange(false)
101
+ }
102
+ }
103
+
104
+ // Delay adding the listener to prevent immediate close
105
+ const timer = setTimeout(() => {
106
+ document.addEventListener("click", handleClickOutside)
107
+ }, 0)
108
+ document.addEventListener("keydown", handleEscape)
109
+
110
+ return () => {
111
+ clearTimeout(timer)
112
+ document.removeEventListener("click", handleClickOutside)
113
+ document.removeEventListener("keydown", handleEscape)
114
+ }
115
+ }, [context.open, context])
116
+
117
+ if (!context.open) return null
118
+
119
+ return (
120
+ <div
121
+ ref={ref}
122
+ className={cn(
123
+ "absolute z-50 mt-2 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
124
+ className
125
+ )}
126
+ onClick={(e) => e.stopPropagation()}
127
+ {...props}
128
+ >
129
+ {children}
130
+ </div>
131
+ )
132
+ })
133
+ PopoverContent.displayName = "PopoverContent"
134
+
135
+ export { Popover, PopoverTrigger, PopoverContent }
@@ -0,0 +1,49 @@
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ /**
6
+ * Progress value from 0 to 100
7
+ */
8
+ value?: number
9
+ /**
10
+ * Maximum value
11
+ * @default 100
12
+ */
13
+ max?: number
14
+ }
15
+
16
+ /**
17
+ * Progress bar component
18
+ *
19
+ * @example
20
+ * <Progress value={60} />
21
+ */
22
+ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
23
+ ({ className, value = 0, max = 100, ...props }, ref) => {
24
+ const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
25
+
26
+ return (
27
+ <div
28
+ ref={ref}
29
+ role="progressbar"
30
+ aria-valuenow={value}
31
+ aria-valuemin={0}
32
+ aria-valuemax={max}
33
+ className={cn(
34
+ "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
35
+ className
36
+ )}
37
+ {...props}
38
+ >
39
+ <div
40
+ className="h-full w-full flex-1 bg-primary transition-all"
41
+ style={{ transform: `translateX(-${100 - percentage}%)` }}
42
+ />
43
+ </div>
44
+ )
45
+ }
46
+ )
47
+ Progress.displayName = "Progress"
48
+
49
+ export { Progress }
@@ -0,0 +1,99 @@
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ interface RadioGroupContextValue {
5
+ value: string
6
+ onValueChange: (value: string) => void
7
+ name: string
8
+ }
9
+
10
+ const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null)
11
+
12
+ interface RadioGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
13
+ value?: string
14
+ onValueChange?: (value: string) => void
15
+ defaultValue?: string
16
+ name?: string
17
+ }
18
+
19
+ /**
20
+ * RadioGroup component
21
+ *
22
+ * @example
23
+ * <RadioGroup value={value} onValueChange={setValue}>
24
+ * <div className="flex items-center space-x-2">
25
+ * <RadioGroupItem value="option1" id="option1" />
26
+ * <Label htmlFor="option1">Option 1</Label>
27
+ * </div>
28
+ * <div className="flex items-center space-x-2">
29
+ * <RadioGroupItem value="option2" id="option2" />
30
+ * <Label htmlFor="option2">Option 2</Label>
31
+ * </div>
32
+ * </RadioGroup>
33
+ */
34
+ const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
35
+ ({ className, value: controlledValue, onValueChange, defaultValue = "", name = "radio-group", children, ...props }, ref) => {
36
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
37
+
38
+ const value = controlledValue !== undefined ? controlledValue : uncontrolledValue
39
+ const setValue = onValueChange || setUncontrolledValue
40
+
41
+ return (
42
+ <RadioGroupContext.Provider value={{ value, onValueChange: setValue, name }}>
43
+ <div ref={ref} role="radiogroup" className={cn("grid gap-2", className)} {...props}>
44
+ {children}
45
+ </div>
46
+ </RadioGroupContext.Provider>
47
+ )
48
+ }
49
+ )
50
+ RadioGroup.displayName = "RadioGroup"
51
+
52
+ interface RadioGroupItemProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
53
+ value: string
54
+ }
55
+
56
+ const RadioGroupItem = React.forwardRef<HTMLButtonElement, RadioGroupItemProps>(
57
+ ({ className, value, ...props }, ref) => {
58
+ const context = React.useContext(RadioGroupContext)
59
+ if (!context) throw new Error("RadioGroupItem must be used within RadioGroup")
60
+
61
+ const isSelected = context.value === value
62
+
63
+ const handleClick = () => {
64
+ context.onValueChange(value)
65
+ }
66
+
67
+ const handleKeyDown = (e: React.KeyboardEvent) => {
68
+ if (e.key === " " || e.key === "Enter") {
69
+ e.preventDefault()
70
+ handleClick()
71
+ }
72
+ }
73
+
74
+ return (
75
+ <button
76
+ ref={ref}
77
+ type="button"
78
+ role="radio"
79
+ aria-checked={isSelected}
80
+ className={cn(
81
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
82
+ className
83
+ )}
84
+ onClick={handleClick}
85
+ onKeyDown={handleKeyDown}
86
+ {...props}
87
+ >
88
+ {isSelected && (
89
+ <span className="flex items-center justify-center">
90
+ <span className="h-2 w-2 rounded-full bg-primary" />
91
+ </span>
92
+ )}
93
+ </button>
94
+ )
95
+ }
96
+ )
97
+ RadioGroupItem.displayName = "RadioGroupItem"
98
+
99
+ export { RadioGroup, RadioGroupItem }