@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.
- package/README.md +151 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +640 -0
- package/package.json +43 -0
- package/registry/accordion.tsx +158 -0
- package/registry/alert-dialog.tsx +206 -0
- package/registry/alert.tsx +73 -0
- package/registry/aspect-ratio.tsx +44 -0
- package/registry/avatar.tsx +94 -0
- package/registry/badge.tsx +68 -0
- package/registry/breadcrumb.tsx +151 -0
- package/registry/button-group.tsx +84 -0
- package/registry/button.tsx +102 -0
- package/registry/calendar.tsx +238 -0
- package/registry/card.tsx +114 -0
- package/registry/carousel.tsx +169 -0
- package/registry/checkbox.tsx +79 -0
- package/registry/collapsible.tsx +110 -0
- package/registry/container.tsx +60 -0
- package/registry/dialog.tsx +264 -0
- package/registry/dropdown-menu.tsx +387 -0
- package/registry/image.tsx +144 -0
- package/registry/input.tsx +44 -0
- package/registry/label.tsx +34 -0
- package/registry/loading-spinner.tsx +108 -0
- package/registry/otp-input.tsx +152 -0
- package/registry/pagination.tsx +146 -0
- package/registry/popover.tsx +135 -0
- package/registry/progress.tsx +49 -0
- package/registry/radio.tsx +99 -0
- package/registry/search.tsx +146 -0
- package/registry/select.tsx +190 -0
- package/registry/separator.tsx +44 -0
- package/registry/sheet.tsx +180 -0
- package/registry/skeleton.tsx +26 -0
- package/registry/slider.tsx +115 -0
- package/registry/star-rating.tsx +131 -0
- package/registry/switch.tsx +70 -0
- package/registry/table.tsx +136 -0
- package/registry/tabs.tsx +122 -0
- package/registry/text.tsx +70 -0
- package/registry/textarea.tsx +39 -0
- package/registry/toast.tsx +95 -0
- 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 }
|