@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,169 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
interface CarouselContextValue {
|
|
5
|
+
currentIndex: number
|
|
6
|
+
setCurrentIndex: (index: number) => void
|
|
7
|
+
itemsCount: number
|
|
8
|
+
setItemsCount: (count: number) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CarouselContext = React.createContext<CarouselContextValue | null>(null)
|
|
12
|
+
|
|
13
|
+
interface CarouselProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
/** Auto-play interval in ms (0 to disable) */
|
|
15
|
+
autoPlay?: number
|
|
16
|
+
/** Loop back to start */
|
|
17
|
+
loop?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Carousel/Slider component
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* <Carousel>
|
|
25
|
+
* <CarouselContent>
|
|
26
|
+
* <CarouselItem>Slide 1</CarouselItem>
|
|
27
|
+
* <CarouselItem>Slide 2</CarouselItem>
|
|
28
|
+
* </CarouselContent>
|
|
29
|
+
* <CarouselPrevious />
|
|
30
|
+
* <CarouselNext />
|
|
31
|
+
* </Carousel>
|
|
32
|
+
*/
|
|
33
|
+
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
|
34
|
+
({ className, children, autoPlay = 0, loop = true, ...props }, ref) => {
|
|
35
|
+
const [currentIndex, setCurrentIndex] = React.useState(0)
|
|
36
|
+
const [itemsCount, setItemsCount] = React.useState(0)
|
|
37
|
+
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
if (autoPlay > 0 && itemsCount > 0) {
|
|
40
|
+
const interval = setInterval(() => {
|
|
41
|
+
setCurrentIndex((prev) => {
|
|
42
|
+
if (prev >= itemsCount - 1) {
|
|
43
|
+
return loop ? 0 : prev
|
|
44
|
+
}
|
|
45
|
+
return prev + 1
|
|
46
|
+
})
|
|
47
|
+
}, autoPlay)
|
|
48
|
+
return () => clearInterval(interval)
|
|
49
|
+
}
|
|
50
|
+
}, [autoPlay, itemsCount, loop])
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<CarouselContext.Provider value={{ currentIndex, setCurrentIndex, itemsCount, setItemsCount }}>
|
|
54
|
+
<div
|
|
55
|
+
ref={ref}
|
|
56
|
+
className={cn("relative", className)}
|
|
57
|
+
role="region"
|
|
58
|
+
aria-roledescription="carousel"
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</CarouselContext.Provider>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
Carousel.displayName = "Carousel"
|
|
68
|
+
|
|
69
|
+
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
70
|
+
({ className, children, ...props }, ref) => {
|
|
71
|
+
const context = React.useContext(CarouselContext)
|
|
72
|
+
if (!context) throw new Error("CarouselContent must be used within Carousel")
|
|
73
|
+
|
|
74
|
+
const childrenArray = React.Children.toArray(children)
|
|
75
|
+
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
context.setItemsCount(childrenArray.length)
|
|
78
|
+
}, [childrenArray.length, context])
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div ref={ref} className={cn("overflow-hidden", className)} {...props}>
|
|
82
|
+
<div
|
|
83
|
+
className="flex transition-transform duration-300 ease-in-out"
|
|
84
|
+
style={{ transform: `translateX(-${context.currentIndex * 100}%)` }}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
CarouselContent.displayName = "CarouselContent"
|
|
93
|
+
|
|
94
|
+
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
95
|
+
({ className, ...props }, ref) => (
|
|
96
|
+
<div
|
|
97
|
+
ref={ref}
|
|
98
|
+
role="group"
|
|
99
|
+
aria-roledescription="slide"
|
|
100
|
+
className={cn("min-w-0 shrink-0 grow-0 basis-full", className)}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
CarouselItem.displayName = "CarouselItem"
|
|
106
|
+
|
|
107
|
+
const CarouselPrevious = React.forwardRef<
|
|
108
|
+
HTMLButtonElement,
|
|
109
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
110
|
+
>(({ className, ...props }, ref) => {
|
|
111
|
+
const context = React.useContext(CarouselContext)
|
|
112
|
+
if (!context) throw new Error("CarouselPrevious must be used within Carousel")
|
|
113
|
+
|
|
114
|
+
const canGoPrev = context.currentIndex > 0
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<button
|
|
118
|
+
ref={ref}
|
|
119
|
+
type="button"
|
|
120
|
+
className={cn(
|
|
121
|
+
"absolute left-4 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border bg-background shadow-md flex items-center justify-center",
|
|
122
|
+
"hover:bg-accent disabled:opacity-50",
|
|
123
|
+
className
|
|
124
|
+
)}
|
|
125
|
+
disabled={!canGoPrev}
|
|
126
|
+
onClick={() => context.setCurrentIndex(context.currentIndex - 1)}
|
|
127
|
+
aria-label="Previous slide"
|
|
128
|
+
{...props}
|
|
129
|
+
>
|
|
130
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
131
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
132
|
+
</svg>
|
|
133
|
+
</button>
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
CarouselPrevious.displayName = "CarouselPrevious"
|
|
137
|
+
|
|
138
|
+
const CarouselNext = React.forwardRef<
|
|
139
|
+
HTMLButtonElement,
|
|
140
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
141
|
+
>(({ className, ...props }, ref) => {
|
|
142
|
+
const context = React.useContext(CarouselContext)
|
|
143
|
+
if (!context) throw new Error("CarouselNext must be used within Carousel")
|
|
144
|
+
|
|
145
|
+
const canGoNext = context.currentIndex < context.itemsCount - 1
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<button
|
|
149
|
+
ref={ref}
|
|
150
|
+
type="button"
|
|
151
|
+
className={cn(
|
|
152
|
+
"absolute right-4 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border bg-background shadow-md flex items-center justify-center",
|
|
153
|
+
"hover:bg-accent disabled:opacity-50",
|
|
154
|
+
className
|
|
155
|
+
)}
|
|
156
|
+
disabled={!canGoNext}
|
|
157
|
+
onClick={() => context.setCurrentIndex(context.currentIndex + 1)}
|
|
158
|
+
aria-label="Next slide"
|
|
159
|
+
{...props}
|
|
160
|
+
>
|
|
161
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
162
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
163
|
+
</svg>
|
|
164
|
+
</button>
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
CarouselNext.displayName = "CarouselNext"
|
|
168
|
+
|
|
169
|
+
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the checkbox is checked
|
|
7
|
+
*/
|
|
8
|
+
checked?: boolean
|
|
9
|
+
/**
|
|
10
|
+
* Callback when the checked state changes
|
|
11
|
+
*/
|
|
12
|
+
onCheckedChange?: (checked: boolean) => void
|
|
13
|
+
/**
|
|
14
|
+
* Whether the checkbox is disabled
|
|
15
|
+
*/
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checkbox component with keyboard accessibility
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const [checked, setChecked] = useState(false)
|
|
24
|
+
* <Checkbox checked={checked} onCheckedChange={setChecked} />
|
|
25
|
+
*/
|
|
26
|
+
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
|
27
|
+
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
|
|
28
|
+
const handleClick = () => {
|
|
29
|
+
if (!disabled) {
|
|
30
|
+
onCheckedChange?.(!checked)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
35
|
+
if (e.key === " " || e.key === "Enter") {
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
handleClick()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
role="checkbox"
|
|
45
|
+
aria-checked={checked}
|
|
46
|
+
aria-disabled={disabled}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={cn(
|
|
50
|
+
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
51
|
+
checked && "bg-primary text-primary-foreground",
|
|
52
|
+
className
|
|
53
|
+
)}
|
|
54
|
+
onClick={handleClick}
|
|
55
|
+
onKeyDown={handleKeyDown}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
{checked && (
|
|
59
|
+
<svg
|
|
60
|
+
className="h-full w-full"
|
|
61
|
+
fill="none"
|
|
62
|
+
viewBox="0 0 24 24"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeWidth={3}
|
|
65
|
+
>
|
|
66
|
+
<path
|
|
67
|
+
strokeLinecap="round"
|
|
68
|
+
strokeLinejoin="round"
|
|
69
|
+
d="M5 13l4 4L19 7"
|
|
70
|
+
/>
|
|
71
|
+
</svg>
|
|
72
|
+
)}
|
|
73
|
+
</button>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
Checkbox.displayName = "Checkbox"
|
|
78
|
+
|
|
79
|
+
export { Checkbox }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
interface CollapsibleContextValue {
|
|
5
|
+
open: boolean
|
|
6
|
+
onOpenChange: (open: boolean) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CollapsibleContext = React.createContext<CollapsibleContextValue | null>(null)
|
|
10
|
+
|
|
11
|
+
interface CollapsibleProps {
|
|
12
|
+
children: React.ReactNode
|
|
13
|
+
open?: boolean
|
|
14
|
+
onOpenChange?: (open: boolean) => void
|
|
15
|
+
defaultOpen?: boolean
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collapsible component for expandable content
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* <Collapsible>
|
|
24
|
+
* <CollapsibleTrigger>Toggle</CollapsibleTrigger>
|
|
25
|
+
* <CollapsibleContent>
|
|
26
|
+
* Hidden content here
|
|
27
|
+
* </CollapsibleContent>
|
|
28
|
+
* </Collapsible>
|
|
29
|
+
*/
|
|
30
|
+
const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
|
|
31
|
+
({ children, open: controlledOpen, onOpenChange, defaultOpen = false, className }, ref) => {
|
|
32
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
|
|
33
|
+
|
|
34
|
+
const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
|
|
35
|
+
const setOpen = onOpenChange || setUncontrolledOpen
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<CollapsibleContext.Provider value={{ open, onOpenChange: setOpen }}>
|
|
39
|
+
<div ref={ref} className={className} data-state={open ? "open" : "closed"}>
|
|
40
|
+
{children}
|
|
41
|
+
</div>
|
|
42
|
+
</CollapsibleContext.Provider>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
Collapsible.displayName = "Collapsible"
|
|
47
|
+
|
|
48
|
+
interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
49
|
+
asChild?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
|
|
53
|
+
({ className, children, asChild, onClick, ...props }, ref) => {
|
|
54
|
+
const context = React.useContext(CollapsibleContext)
|
|
55
|
+
if (!context) throw new Error("CollapsibleTrigger must be used within Collapsible")
|
|
56
|
+
|
|
57
|
+
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
58
|
+
onClick?.(e)
|
|
59
|
+
context.onOpenChange(!context.open)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (asChild && React.isValidElement(children)) {
|
|
63
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
64
|
+
onClick: handleClick,
|
|
65
|
+
"aria-expanded": context.open,
|
|
66
|
+
ref,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
ref={ref}
|
|
73
|
+
type="button"
|
|
74
|
+
aria-expanded={context.open}
|
|
75
|
+
className={className}
|
|
76
|
+
onClick={handleClick}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</button>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
CollapsibleTrigger.displayName = "CollapsibleTrigger"
|
|
85
|
+
|
|
86
|
+
const CollapsibleContent = React.forwardRef<
|
|
87
|
+
HTMLDivElement,
|
|
88
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
89
|
+
>(({ className, children, ...props }, ref) => {
|
|
90
|
+
const context = React.useContext(CollapsibleContext)
|
|
91
|
+
if (!context) throw new Error("CollapsibleContent must be used within Collapsible")
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
ref={ref}
|
|
96
|
+
className={cn(
|
|
97
|
+
"overflow-hidden transition-all",
|
|
98
|
+
context.open ? "animate-collapsible-down" : "animate-collapsible-up hidden",
|
|
99
|
+
className
|
|
100
|
+
)}
|
|
101
|
+
data-state={context.open ? "open" : "closed"}
|
|
102
|
+
{...props}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
CollapsibleContent.displayName = "CollapsibleContent"
|
|
109
|
+
|
|
110
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
const containerVariants = cva("mx-auto w-full px-4", {
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: "max-w-screen-sm",
|
|
9
|
+
md: "max-w-screen-md",
|
|
10
|
+
lg: "max-w-screen-lg",
|
|
11
|
+
xl: "max-w-screen-xl",
|
|
12
|
+
"2xl": "max-w-screen-2xl",
|
|
13
|
+
full: "max-w-full",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
size: "xl",
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
type ContainerVariants = VariantProps<typeof containerVariants>
|
|
22
|
+
|
|
23
|
+
interface ContainerBaseProps extends ContainerVariants {
|
|
24
|
+
className?: string
|
|
25
|
+
children?: React.ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Polymorphic Container for max-width layouts
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* <Container size="lg">Content</Container>
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <Container as="section" size="md">Section content</Container>
|
|
36
|
+
*/
|
|
37
|
+
const Container = React.forwardRef(
|
|
38
|
+
<T extends React.ElementType = "div">(
|
|
39
|
+
{
|
|
40
|
+
as,
|
|
41
|
+
className,
|
|
42
|
+
size,
|
|
43
|
+
...props
|
|
44
|
+
}: ContainerBaseProps & { as?: T } & Omit<React.ComponentPropsWithoutRef<T>, keyof ContainerBaseProps | "as">,
|
|
45
|
+
ref: React.ForwardedRef<React.ElementRef<T>>
|
|
46
|
+
) => {
|
|
47
|
+
const Comp = as || "div"
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Comp
|
|
51
|
+
ref={ref as any}
|
|
52
|
+
className={cn(containerVariants({ size, className }))}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
Container.displayName = "Container"
|
|
59
|
+
|
|
60
|
+
export { Container, containerVariants }
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "@/lib/utils"
|
|
3
|
+
|
|
4
|
+
interface DialogContextValue {
|
|
5
|
+
open: boolean
|
|
6
|
+
onOpenChange: (open: boolean) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DialogContext = React.createContext<DialogContextValue | null>(null)
|
|
10
|
+
|
|
11
|
+
function useDialogContext() {
|
|
12
|
+
const context = React.useContext(DialogContext)
|
|
13
|
+
if (!context) {
|
|
14
|
+
throw new Error("Dialog components must be used within a Dialog")
|
|
15
|
+
}
|
|
16
|
+
return context
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DialogProps {
|
|
20
|
+
children: React.ReactNode
|
|
21
|
+
open?: boolean
|
|
22
|
+
onOpenChange?: (open: boolean) => void
|
|
23
|
+
defaultOpen?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dialog component with focus trap and keyboard handling
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const [open, setOpen] = useState(false)
|
|
31
|
+
*
|
|
32
|
+
* <Dialog open={open} onOpenChange={setOpen}>
|
|
33
|
+
* <DialogTrigger asChild>
|
|
34
|
+
* <Button>Open Dialog</Button>
|
|
35
|
+
* </DialogTrigger>
|
|
36
|
+
* <DialogContent>
|
|
37
|
+
* <DialogHeader>
|
|
38
|
+
* <DialogTitle>Title</DialogTitle>
|
|
39
|
+
* <DialogDescription>Description</DialogDescription>
|
|
40
|
+
* </DialogHeader>
|
|
41
|
+
* <div>Content</div>
|
|
42
|
+
* <DialogFooter>
|
|
43
|
+
* <Button>Save</Button>
|
|
44
|
+
* </DialogFooter>
|
|
45
|
+
* </DialogContent>
|
|
46
|
+
* </Dialog>
|
|
47
|
+
*/
|
|
48
|
+
function Dialog({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: DialogProps) {
|
|
49
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
|
|
50
|
+
|
|
51
|
+
const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
|
|
52
|
+
const setOpen = onOpenChange || setUncontrolledOpen
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<DialogContext.Provider value={{ open, onOpenChange: setOpen }}>
|
|
56
|
+
{children}
|
|
57
|
+
</DialogContext.Provider>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
62
|
+
asChild?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
|
|
66
|
+
({ onClick, asChild, children, ...props }, ref) => {
|
|
67
|
+
const { onOpenChange } = useDialogContext()
|
|
68
|
+
|
|
69
|
+
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
70
|
+
onClick?.(e)
|
|
71
|
+
onOpenChange(true)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (asChild && React.isValidElement(children)) {
|
|
75
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
76
|
+
onClick: handleClick,
|
|
77
|
+
ref,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<button ref={ref} onClick={handleClick} {...props}>
|
|
83
|
+
{children}
|
|
84
|
+
</button>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
DialogTrigger.displayName = "DialogTrigger"
|
|
89
|
+
|
|
90
|
+
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
|
|
91
|
+
const { open } = useDialogContext()
|
|
92
|
+
|
|
93
|
+
if (!open) return null
|
|
94
|
+
|
|
95
|
+
return <>{children}</>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DialogOverlay = React.forwardRef<
|
|
99
|
+
HTMLDivElement,
|
|
100
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
101
|
+
>(({ className, ...props }, ref) => (
|
|
102
|
+
<div
|
|
103
|
+
ref={ref}
|
|
104
|
+
className={cn(
|
|
105
|
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
106
|
+
className
|
|
107
|
+
)}
|
|
108
|
+
{...props}
|
|
109
|
+
/>
|
|
110
|
+
))
|
|
111
|
+
DialogOverlay.displayName = "DialogOverlay"
|
|
112
|
+
|
|
113
|
+
const DialogContent = React.forwardRef<
|
|
114
|
+
HTMLDivElement,
|
|
115
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
116
|
+
>(({ className, children, ...props }, ref) => {
|
|
117
|
+
const { open, onOpenChange } = useDialogContext()
|
|
118
|
+
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
119
|
+
|
|
120
|
+
// Handle escape key
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
123
|
+
if (e.key === "Escape") {
|
|
124
|
+
onOpenChange(false)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (open) {
|
|
129
|
+
document.addEventListener("keydown", handleEscape)
|
|
130
|
+
document.body.style.overflow = "hidden"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
document.removeEventListener("keydown", handleEscape)
|
|
135
|
+
document.body.style.overflow = ""
|
|
136
|
+
}
|
|
137
|
+
}, [open, onOpenChange])
|
|
138
|
+
|
|
139
|
+
// Focus trap
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
if (open && contentRef.current) {
|
|
142
|
+
const focusableElements = contentRef.current.querySelectorAll(
|
|
143
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
144
|
+
)
|
|
145
|
+
const firstElement = focusableElements[0] as HTMLElement
|
|
146
|
+
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement
|
|
147
|
+
|
|
148
|
+
const handleTab = (e: KeyboardEvent) => {
|
|
149
|
+
if (e.key === "Tab") {
|
|
150
|
+
if (e.shiftKey && document.activeElement === firstElement) {
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
lastElement?.focus()
|
|
153
|
+
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
firstElement?.focus()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
document.addEventListener("keydown", handleTab)
|
|
161
|
+
firstElement?.focus()
|
|
162
|
+
|
|
163
|
+
return () => document.removeEventListener("keydown", handleTab)
|
|
164
|
+
}
|
|
165
|
+
}, [open])
|
|
166
|
+
|
|
167
|
+
if (!open) return null
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<DialogPortal>
|
|
171
|
+
<DialogOverlay onClick={() => onOpenChange(false)} />
|
|
172
|
+
<div
|
|
173
|
+
ref={(node) => {
|
|
174
|
+
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
175
|
+
if (typeof ref === "function") ref(node)
|
|
176
|
+
else if (ref) ref.current = node
|
|
177
|
+
}}
|
|
178
|
+
role="dialog"
|
|
179
|
+
aria-modal="true"
|
|
180
|
+
className={cn(
|
|
181
|
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
|
|
182
|
+
className
|
|
183
|
+
)}
|
|
184
|
+
{...props}
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
<button
|
|
188
|
+
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
|
189
|
+
onClick={() => onOpenChange(false)}
|
|
190
|
+
>
|
|
191
|
+
<svg
|
|
192
|
+
className="h-4 w-4"
|
|
193
|
+
fill="none"
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
stroke="currentColor"
|
|
196
|
+
strokeWidth={2}
|
|
197
|
+
>
|
|
198
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
199
|
+
</svg>
|
|
200
|
+
<span className="sr-only">Close</span>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</DialogPortal>
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
DialogContent.displayName = "DialogContent"
|
|
207
|
+
|
|
208
|
+
const DialogHeader = ({
|
|
209
|
+
className,
|
|
210
|
+
...props
|
|
211
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
212
|
+
<div
|
|
213
|
+
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
|
214
|
+
{...props}
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
217
|
+
DialogHeader.displayName = "DialogHeader"
|
|
218
|
+
|
|
219
|
+
const DialogFooter = ({
|
|
220
|
+
className,
|
|
221
|
+
...props
|
|
222
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
223
|
+
<div
|
|
224
|
+
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
|
225
|
+
{...props}
|
|
226
|
+
/>
|
|
227
|
+
)
|
|
228
|
+
DialogFooter.displayName = "DialogFooter"
|
|
229
|
+
|
|
230
|
+
const DialogTitle = React.forwardRef<
|
|
231
|
+
HTMLHeadingElement,
|
|
232
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
233
|
+
>(({ className, ...props }, ref) => (
|
|
234
|
+
<h2
|
|
235
|
+
ref={ref}
|
|
236
|
+
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
|
237
|
+
{...props}
|
|
238
|
+
/>
|
|
239
|
+
))
|
|
240
|
+
DialogTitle.displayName = "DialogTitle"
|
|
241
|
+
|
|
242
|
+
const DialogDescription = React.forwardRef<
|
|
243
|
+
HTMLParagraphElement,
|
|
244
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
245
|
+
>(({ className, ...props }, ref) => (
|
|
246
|
+
<p
|
|
247
|
+
ref={ref}
|
|
248
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
249
|
+
{...props}
|
|
250
|
+
/>
|
|
251
|
+
))
|
|
252
|
+
DialogDescription.displayName = "DialogDescription"
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
Dialog,
|
|
256
|
+
DialogPortal,
|
|
257
|
+
DialogOverlay,
|
|
258
|
+
DialogTrigger,
|
|
259
|
+
DialogContent,
|
|
260
|
+
DialogHeader,
|
|
261
|
+
DialogFooter,
|
|
262
|
+
DialogTitle,
|
|
263
|
+
DialogDescription,
|
|
264
|
+
}
|