@srcroot/ui 0.0.54 → 0.0.56
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 -151
- package/dist/index.d.ts +0 -0
- package/dist/index.js +55 -1
- package/package.json +7 -2
- package/src/registry/analytics/google-analytics.tsx +36 -39
- package/src/registry/analytics/google-tag-manager.tsx +62 -65
- package/src/registry/analytics/meta-pixel.tsx +44 -47
- package/src/registry/analytics/microsoft-clarity.tsx +31 -34
- package/src/registry/analytics/tiktok-pixel.tsx +34 -37
- package/src/registry/lib/utils.ts +0 -0
- package/src/registry/themes/v3/blue.css +157 -157
- package/src/registry/themes/v3/glass.css +153 -153
- package/src/registry/themes/v3/gray.css +157 -157
- package/src/registry/themes/v3/green.css +157 -157
- package/src/registry/themes/v3/neutral.css +157 -157
- package/src/registry/themes/v3/orange.css +157 -157
- package/src/registry/themes/v3/rose.css +157 -157
- package/src/registry/themes/v3/slate.css +157 -157
- package/src/registry/themes/v3/stone.css +157 -157
- package/src/registry/themes/v3/violet.css +186 -186
- package/src/registry/themes/v3/zinc.css +157 -157
- package/src/registry/themes/v4/blue.css +184 -184
- package/src/registry/themes/v4/glass.css +180 -180
- package/src/registry/themes/v4/gray.css +184 -184
- package/src/registry/themes/v4/green.css +184 -184
- package/src/registry/themes/v4/neutral.css +184 -184
- package/src/registry/themes/v4/orange.css +184 -184
- package/src/registry/themes/v4/rose.css +184 -184
- package/src/registry/themes/v4/slate.css +184 -184
- package/src/registry/themes/v4/stone.css +184 -184
- package/src/registry/themes/v4/violet.css +184 -184
- package/src/registry/themes/v4/zinc.css +184 -184
- package/src/registry/ui/accordion.tsx +164 -165
- package/src/registry/ui/alert-dialog.tsx +213 -214
- package/src/registry/ui/alert.tsx +73 -76
- package/src/registry/ui/aspect-ratio.tsx +44 -47
- package/src/registry/ui/avatar.tsx +96 -97
- package/src/registry/ui/badge.tsx +52 -55
- package/src/registry/ui/breadcrumb.tsx +147 -150
- package/src/registry/ui/button-group.tsx +64 -67
- package/src/registry/ui/button.tsx +71 -72
- package/src/registry/ui/calendar.tsx +514 -515
- package/src/registry/ui/card.tsx +88 -91
- package/src/registry/ui/carousel.tsx +214 -214
- package/src/registry/ui/chart.tsx +373 -373
- package/src/registry/ui/chatbot.tsx +86 -13
- package/src/registry/ui/checkbox.tsx +93 -94
- package/src/registry/ui/collapsible.tsx +107 -108
- package/src/registry/ui/combobox.tsx +171 -171
- package/src/registry/ui/command.tsx +300 -300
- package/src/registry/ui/container.tsx +44 -47
- package/src/registry/ui/context-menu.tsx +221 -221
- package/src/registry/ui/date-picker.tsx +228 -228
- package/src/registry/ui/dialog.tsx +269 -270
- package/src/registry/ui/drawer.tsx +10 -4
- package/src/registry/ui/dropdown-menu.tsx +529 -530
- package/src/registry/ui/empty-state.tsx +0 -2
- package/src/registry/ui/file-upload.tsx +0 -0
- package/src/registry/ui/floating-dock.tsx +0 -0
- package/src/registry/ui/form-field.tsx +91 -94
- package/src/registry/ui/google-analytics.tsx +38 -0
- package/src/registry/ui/google-tag-manager.tsx +64 -0
- package/src/registry/ui/hover-card.tsx +223 -223
- package/src/registry/ui/image.tsx +144 -147
- package/src/registry/ui/input-group.tsx +82 -85
- package/src/registry/ui/input.tsx +125 -125
- package/src/registry/ui/kbd.tsx +60 -63
- package/src/registry/ui/label.tsx +36 -37
- package/src/registry/ui/loading-spinner.tsx +108 -111
- package/src/registry/ui/map.tsx +0 -0
- package/src/registry/ui/marquee.tsx +2 -0
- package/src/registry/ui/menubar.tsx +246 -246
- package/src/registry/ui/meta-pixel.tsx +46 -0
- package/src/registry/ui/microsoft-clarity.tsx +33 -0
- package/src/registry/ui/native-select.tsx +49 -52
- package/src/registry/ui/otp-input.tsx +152 -155
- package/src/registry/ui/pagination.tsx +149 -152
- package/src/registry/ui/patterns.tsx +28 -0
- package/src/registry/ui/popover.tsx +226 -227
- package/src/registry/ui/progress.tsx +51 -52
- package/src/registry/ui/radio.tsx +99 -102
- package/src/registry/ui/resizable.tsx +314 -314
- package/src/registry/ui/scroll-animation.tsx +45 -0
- package/src/registry/ui/scroll-area.tsx +121 -122
- package/src/registry/ui/scroll-to-top.tsx +0 -0
- package/src/registry/ui/search.tsx +147 -150
- package/src/registry/ui/select.tsx +292 -293
- package/src/registry/ui/separator.tsx +46 -47
- package/src/registry/ui/sheet.tsx +6 -3
- package/src/registry/ui/sidebar.tsx +628 -628
- package/src/registry/ui/skeleton.tsx +26 -29
- package/src/registry/ui/slider.tsx +196 -197
- package/src/registry/ui/slot.tsx +69 -72
- package/src/registry/ui/star-rating.tsx +131 -134
- package/src/registry/ui/switch.tsx +72 -73
- package/src/registry/ui/table-of-contents.tsx +96 -96
- package/src/registry/ui/table.tsx +138 -139
- package/src/registry/ui/tabs.tsx +124 -125
- package/src/registry/ui/text.tsx +61 -64
- package/src/registry/ui/textarea.tsx +41 -42
- package/src/registry/ui/theme-switcher.tsx +66 -66
- package/src/registry/ui/tiktok-pixel.tsx +36 -0
- package/src/registry/ui/toast.tsx +97 -98
- package/src/registry/ui/toggle-group.tsx +129 -129
- package/src/registry/ui/toggle.tsx +72 -72
- package/src/registry/ui/tooltip.tsx +143 -144
- package/src/registry/ui/whatsapp.tsx +0 -0
|
@@ -1,293 +1,292 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import * as React from "react"
|
|
4
|
-
import { createPortal } from "react-dom"
|
|
5
|
-
import { cn } from "@/lib/utils"
|
|
6
|
-
|
|
7
|
-
interface SelectContextValue {
|
|
8
|
-
value: string
|
|
9
|
-
onValueChange: (value: string) => void
|
|
10
|
-
open: boolean
|
|
11
|
-
setOpen: (open: boolean) => void
|
|
12
|
-
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const SelectContext = React.createContext<SelectContextValue | null>(null)
|
|
16
|
-
|
|
17
|
-
interface SelectProps {
|
|
18
|
-
children: React.ReactNode
|
|
19
|
-
value?: string
|
|
20
|
-
onValueChange?: (value: string) => void
|
|
21
|
-
defaultValue?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Custom Select dropdown with keyboard navigation
|
|
26
|
-
*
|
|
27
|
-
* @example
|
|
28
|
-
* <Select value={value} onValueChange={setValue}>
|
|
29
|
-
* <SelectTrigger>
|
|
30
|
-
* <SelectValue placeholder="Select option" />
|
|
31
|
-
* </SelectTrigger>
|
|
32
|
-
* <SelectContent>
|
|
33
|
-
* <SelectItem value="option1">Option 1</SelectItem>
|
|
34
|
-
* <SelectItem value="option2">Option 2</SelectItem>
|
|
35
|
-
* </SelectContent>
|
|
36
|
-
* </Select>
|
|
37
|
-
*/
|
|
38
|
-
function Select({ children, value: controlledValue, onValueChange, defaultValue = "" }: SelectProps) {
|
|
39
|
-
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
|
|
40
|
-
const [open, setOpen] = React.useState(false)
|
|
41
|
-
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
42
|
-
|
|
43
|
-
const value = controlledValue !== undefined ? controlledValue : uncontrolledValue
|
|
44
|
-
const setValue = onValueChange || setUncontrolledValue
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<SelectContext.Provider value={{ value, onValueChange: setValue, open, setOpen, triggerRef }}>
|
|
48
|
-
<div className="relative">
|
|
49
|
-
{children}
|
|
50
|
-
</div>
|
|
51
|
-
</SelectContext.Provider>
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const SelectTrigger = React.forwardRef<
|
|
56
|
-
HTMLButtonElement,
|
|
57
|
-
React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
58
|
-
>(({ className, children, ...props }, ref) => {
|
|
59
|
-
const context = React.useContext(SelectContext)
|
|
60
|
-
if (!context) throw new Error("SelectTrigger must be used within Select")
|
|
61
|
-
|
|
62
|
-
// Combine refs
|
|
63
|
-
const combinedRef = (node: HTMLButtonElement | null) => {
|
|
64
|
-
(context.triggerRef as any).current = node
|
|
65
|
-
if (typeof ref === 'function') ref(node)
|
|
66
|
-
else if (ref) ref.current = node
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<button
|
|
71
|
-
ref={combinedRef}
|
|
72
|
-
type="button"
|
|
73
|
-
role="combobox"
|
|
74
|
-
aria-expanded={context.open}
|
|
75
|
-
aria-haspopup="listbox"
|
|
76
|
-
className={cn(
|
|
77
|
-
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
78
|
-
className
|
|
79
|
-
)}
|
|
80
|
-
onClick={() => context.setOpen(!context.open)}
|
|
81
|
-
{...props}
|
|
82
|
-
>
|
|
83
|
-
{children}
|
|
84
|
-
<svg
|
|
85
|
-
className={cn("h-4 w-4 opacity-50 transition-transform", context.open && "rotate-180")}
|
|
86
|
-
fill="none"
|
|
87
|
-
viewBox="0 0 24 24"
|
|
88
|
-
stroke="currentColor"
|
|
89
|
-
strokeWidth={2}
|
|
90
|
-
>
|
|
91
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
92
|
-
</svg>
|
|
93
|
-
</button>
|
|
94
|
-
)
|
|
95
|
-
})
|
|
96
|
-
SelectTrigger.displayName = "SelectTrigger"
|
|
97
|
-
|
|
98
|
-
interface SelectValueProps {
|
|
99
|
-
placeholder?: string
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function SelectValue({ placeholder }: SelectValueProps) {
|
|
103
|
-
const context = React.useContext(SelectContext)
|
|
104
|
-
if (!context) throw new Error("SelectValue must be used within Select")
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<span className={cn(!context.value && "text-muted-foreground")}>
|
|
108
|
-
{context.value || placeholder}
|
|
109
|
-
</span>
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
114
|
-
sideOffset?: number
|
|
115
|
-
portal?: boolean
|
|
116
|
-
align?: "start" | "center" | "end"
|
|
117
|
-
side?: "top" | "bottom"
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
|
|
121
|
-
({ className, children, sideOffset = 4, portal = true, align = "start", side = "bottom", ...props }, ref) => {
|
|
122
|
-
const context = React.useContext(SelectContext)
|
|
123
|
-
if (!context) throw new Error("SelectContent must be used within Select")
|
|
124
|
-
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
125
|
-
const [position, setPosition] = React.useState({ top: 0, left: 0 })
|
|
126
|
-
const [mounted, setMounted] = React.useState(false)
|
|
127
|
-
|
|
128
|
-
React.useEffect(() => {
|
|
129
|
-
setMounted(true)
|
|
130
|
-
}, [])
|
|
131
|
-
|
|
132
|
-
React.useEffect(() => {
|
|
133
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
134
|
-
if (context.open) {
|
|
135
|
-
const target = e.target as Node
|
|
136
|
-
const content = contentRef.current
|
|
137
|
-
const trigger = context.triggerRef.current
|
|
138
|
-
|
|
139
|
-
if (content?.contains(target) || (trigger && trigger.contains(target))) {
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
context.setOpen(false)
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
147
|
-
if (e.key === "Escape" && context.open) {
|
|
148
|
-
context.setOpen(false)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const checkPosition = () => {
|
|
153
|
-
if (context.open && contentRef.current && context.triggerRef.current) {
|
|
154
|
-
const triggerRect = context.triggerRef.current.getBoundingClientRect()
|
|
155
|
-
const contentRect = contentRef.current.getBoundingClientRect()
|
|
156
|
-
const viewportHeight = window.innerHeight
|
|
157
|
-
const viewportWidth = window.innerWidth
|
|
158
|
-
|
|
159
|
-
let top = 0
|
|
160
|
-
let left = 0
|
|
161
|
-
|
|
162
|
-
// Vertical
|
|
163
|
-
const spaceBelow = viewportHeight - triggerRect.bottom
|
|
164
|
-
const spaceAbove = triggerRect.top
|
|
165
|
-
const neededHeight = contentRect.height + sideOffset
|
|
166
|
-
const onBottom = spaceBelow >= neededHeight || spaceBelow > spaceAbove
|
|
167
|
-
|
|
168
|
-
if (onBottom) {
|
|
169
|
-
top = triggerRect.bottom + sideOffset
|
|
170
|
-
} else {
|
|
171
|
-
top = triggerRect.top - contentRect.height - sideOffset
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Horizontal - Select usually matches trigger width or starts aligned
|
|
175
|
-
left = triggerRect.left
|
|
176
|
-
|
|
177
|
-
// Clamping
|
|
178
|
-
if (left < 4) left = 4
|
|
179
|
-
if (left + contentRect.width > viewportWidth - 4) {
|
|
180
|
-
left = viewportWidth - contentRect.width - 4
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Match width if it fits, or at least min-width of trigger
|
|
184
|
-
// For now keeping simple positioning
|
|
185
|
-
|
|
186
|
-
setPosition({ top, left })
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (context.open) {
|
|
191
|
-
requestAnimationFrame(checkPosition)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const timer = setTimeout(() => {
|
|
195
|
-
document.addEventListener("mousedown", handleClickOutside)
|
|
196
|
-
}, 0)
|
|
197
|
-
document.addEventListener("keydown", handleEscape)
|
|
198
|
-
window.addEventListener("resize", checkPosition)
|
|
199
|
-
window.addEventListener("scroll", checkPosition, true)
|
|
200
|
-
|
|
201
|
-
return () => {
|
|
202
|
-
clearTimeout(timer)
|
|
203
|
-
document.removeEventListener("mousedown", handleClickOutside)
|
|
204
|
-
document.removeEventListener("keydown", handleEscape)
|
|
205
|
-
window.removeEventListener("resize", checkPosition)
|
|
206
|
-
window.removeEventListener("scroll", checkPosition, true)
|
|
207
|
-
}
|
|
208
|
-
}, [context.open, context, sideOffset])
|
|
209
|
-
|
|
210
|
-
if (!context.open) return null
|
|
211
|
-
if (portal && !mounted) return null
|
|
212
|
-
|
|
213
|
-
const content = (
|
|
214
|
-
<div
|
|
215
|
-
ref={(node) => {
|
|
216
|
-
(contentRef as any).current = node
|
|
217
|
-
if (typeof ref === 'function') ref(node)
|
|
218
|
-
else if (ref) ref.current = node
|
|
219
|
-
}}
|
|
220
|
-
role="listbox"
|
|
221
|
-
className={cn(
|
|
222
|
-
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
|
223
|
-
"animate-in fade-in-0 zoom-in-95",
|
|
224
|
-
!portal && "absolute",
|
|
225
|
-
!portal && "mt-1",
|
|
226
|
-
portal && "fixed",
|
|
227
|
-
className
|
|
228
|
-
)}
|
|
229
|
-
style={{
|
|
230
|
-
top: portal ? position.top : undefined,
|
|
231
|
-
left: portal ? position.left : undefined,
|
|
232
|
-
width: context.triggerRef.current ? context.triggerRef.current.offsetWidth : undefined,
|
|
233
|
-
...props.style
|
|
234
|
-
}}
|
|
235
|
-
onClick={(e) => e.stopPropagation()}
|
|
236
|
-
{...props}
|
|
237
|
-
>
|
|
238
|
-
{children}
|
|
239
|
-
</div>
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
if (portal) {
|
|
243
|
-
return createPortal(content, document.body)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return content
|
|
247
|
-
}
|
|
248
|
-
)
|
|
249
|
-
SelectContent.displayName = "SelectContent"
|
|
250
|
-
|
|
251
|
-
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
252
|
-
value: string
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const SelectItem = React.forwardRef<HTMLDivElement, SelectItemProps>(
|
|
256
|
-
({ className, children, value, ...props }, ref) => {
|
|
257
|
-
const context = React.useContext(SelectContext)
|
|
258
|
-
if (!context) throw new Error("SelectItem must be used within Select")
|
|
259
|
-
|
|
260
|
-
const isSelected = context.value === value
|
|
261
|
-
|
|
262
|
-
return (
|
|
263
|
-
<div
|
|
264
|
-
ref={ref}
|
|
265
|
-
role="option"
|
|
266
|
-
aria-selected={isSelected}
|
|
267
|
-
className={cn(
|
|
268
|
-
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
|
269
|
-
isSelected && "bg-accent text-accent-foreground",
|
|
270
|
-
className
|
|
271
|
-
)}
|
|
272
|
-
onClick={() => {
|
|
273
|
-
context.onValueChange(value)
|
|
274
|
-
context.setOpen(false)
|
|
275
|
-
}}
|
|
276
|
-
{...props}
|
|
277
|
-
>
|
|
278
|
-
{children}
|
|
279
|
-
{isSelected && (
|
|
280
|
-
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
281
|
-
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
282
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
283
|
-
</svg>
|
|
284
|
-
</span>
|
|
285
|
-
)}
|
|
286
|
-
</div>
|
|
287
|
-
)
|
|
288
|
-
}
|
|
289
|
-
)
|
|
290
|
-
SelectItem.displayName = "SelectItem"
|
|
291
|
-
|
|
292
|
-
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }
|
|
293
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { createPortal } from "react-dom"
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
interface SelectContextValue {
|
|
8
|
+
value: string
|
|
9
|
+
onValueChange: (value: string) => void
|
|
10
|
+
open: boolean
|
|
11
|
+
setOpen: (open: boolean) => void
|
|
12
|
+
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SelectContext = React.createContext<SelectContextValue | null>(null)
|
|
16
|
+
|
|
17
|
+
interface SelectProps {
|
|
18
|
+
children: React.ReactNode
|
|
19
|
+
value?: string
|
|
20
|
+
onValueChange?: (value: string) => void
|
|
21
|
+
defaultValue?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Custom Select dropdown with keyboard navigation
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <Select value={value} onValueChange={setValue}>
|
|
29
|
+
* <SelectTrigger>
|
|
30
|
+
* <SelectValue placeholder="Select option" />
|
|
31
|
+
* </SelectTrigger>
|
|
32
|
+
* <SelectContent>
|
|
33
|
+
* <SelectItem value="option1">Option 1</SelectItem>
|
|
34
|
+
* <SelectItem value="option2">Option 2</SelectItem>
|
|
35
|
+
* </SelectContent>
|
|
36
|
+
* </Select>
|
|
37
|
+
*/
|
|
38
|
+
function Select({ children, value: controlledValue, onValueChange, defaultValue = "" }: SelectProps) {
|
|
39
|
+
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
|
|
40
|
+
const [open, setOpen] = React.useState(false)
|
|
41
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
42
|
+
|
|
43
|
+
const value = controlledValue !== undefined ? controlledValue : uncontrolledValue
|
|
44
|
+
const setValue = onValueChange || setUncontrolledValue
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<SelectContext.Provider value={{ value, onValueChange: setValue, open, setOpen, triggerRef }}>
|
|
48
|
+
<div className="relative">
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
</SelectContext.Provider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SelectTrigger = React.forwardRef<
|
|
56
|
+
HTMLButtonElement,
|
|
57
|
+
React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
58
|
+
>(({ className, children, ...props }, ref) => {
|
|
59
|
+
const context = React.useContext(SelectContext)
|
|
60
|
+
if (!context) throw new Error("SelectTrigger must be used within Select")
|
|
61
|
+
|
|
62
|
+
// Combine refs
|
|
63
|
+
const combinedRef = (node: HTMLButtonElement | null) => {
|
|
64
|
+
(context.triggerRef as any).current = node
|
|
65
|
+
if (typeof ref === 'function') ref(node)
|
|
66
|
+
else if (ref) ref.current = node
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<button
|
|
71
|
+
ref={combinedRef}
|
|
72
|
+
type="button"
|
|
73
|
+
role="combobox"
|
|
74
|
+
aria-expanded={context.open}
|
|
75
|
+
aria-haspopup="listbox"
|
|
76
|
+
className={cn(
|
|
77
|
+
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
78
|
+
className
|
|
79
|
+
)}
|
|
80
|
+
onClick={() => context.setOpen(!context.open)}
|
|
81
|
+
{...props}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
<svg
|
|
85
|
+
className={cn("h-4 w-4 opacity-50 transition-transform", context.open && "rotate-180")}
|
|
86
|
+
fill="none"
|
|
87
|
+
viewBox="0 0 24 24"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth={2}
|
|
90
|
+
>
|
|
91
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
92
|
+
</svg>
|
|
93
|
+
</button>
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
SelectTrigger.displayName = "SelectTrigger"
|
|
97
|
+
|
|
98
|
+
interface SelectValueProps {
|
|
99
|
+
placeholder?: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function SelectValue({ placeholder }: SelectValueProps) {
|
|
103
|
+
const context = React.useContext(SelectContext)
|
|
104
|
+
if (!context) throw new Error("SelectValue must be used within Select")
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<span className={cn(!context.value && "text-muted-foreground")}>
|
|
108
|
+
{context.value || placeholder}
|
|
109
|
+
</span>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
114
|
+
sideOffset?: number
|
|
115
|
+
portal?: boolean
|
|
116
|
+
align?: "start" | "center" | "end"
|
|
117
|
+
side?: "top" | "bottom"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
|
|
121
|
+
({ className, children, sideOffset = 4, portal = true, align = "start", side = "bottom", ...props }, ref) => {
|
|
122
|
+
const context = React.useContext(SelectContext)
|
|
123
|
+
if (!context) throw new Error("SelectContent must be used within Select")
|
|
124
|
+
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
125
|
+
const [position, setPosition] = React.useState({ top: 0, left: 0 })
|
|
126
|
+
const [mounted, setMounted] = React.useState(false)
|
|
127
|
+
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
setMounted(true)
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
134
|
+
if (context.open) {
|
|
135
|
+
const target = e.target as Node
|
|
136
|
+
const content = contentRef.current
|
|
137
|
+
const trigger = context.triggerRef.current
|
|
138
|
+
|
|
139
|
+
if (content?.contains(target) || (trigger && trigger.contains(target))) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
context.setOpen(false)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
147
|
+
if (e.key === "Escape" && context.open) {
|
|
148
|
+
context.setOpen(false)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const checkPosition = () => {
|
|
153
|
+
if (context.open && contentRef.current && context.triggerRef.current) {
|
|
154
|
+
const triggerRect = context.triggerRef.current.getBoundingClientRect()
|
|
155
|
+
const contentRect = contentRef.current.getBoundingClientRect()
|
|
156
|
+
const viewportHeight = window.innerHeight
|
|
157
|
+
const viewportWidth = window.innerWidth
|
|
158
|
+
|
|
159
|
+
let top = 0
|
|
160
|
+
let left = 0
|
|
161
|
+
|
|
162
|
+
// Vertical
|
|
163
|
+
const spaceBelow = viewportHeight - triggerRect.bottom
|
|
164
|
+
const spaceAbove = triggerRect.top
|
|
165
|
+
const neededHeight = contentRect.height + sideOffset
|
|
166
|
+
const onBottom = spaceBelow >= neededHeight || spaceBelow > spaceAbove
|
|
167
|
+
|
|
168
|
+
if (onBottom) {
|
|
169
|
+
top = triggerRect.bottom + sideOffset
|
|
170
|
+
} else {
|
|
171
|
+
top = triggerRect.top - contentRect.height - sideOffset
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Horizontal - Select usually matches trigger width or starts aligned
|
|
175
|
+
left = triggerRect.left
|
|
176
|
+
|
|
177
|
+
// Clamping
|
|
178
|
+
if (left < 4) left = 4
|
|
179
|
+
if (left + contentRect.width > viewportWidth - 4) {
|
|
180
|
+
left = viewportWidth - contentRect.width - 4
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Match width if it fits, or at least min-width of trigger
|
|
184
|
+
// For now keeping simple positioning
|
|
185
|
+
|
|
186
|
+
setPosition({ top, left })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (context.open) {
|
|
191
|
+
requestAnimationFrame(checkPosition)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const timer = setTimeout(() => {
|
|
195
|
+
document.addEventListener("mousedown", handleClickOutside)
|
|
196
|
+
}, 0)
|
|
197
|
+
document.addEventListener("keydown", handleEscape)
|
|
198
|
+
window.addEventListener("resize", checkPosition)
|
|
199
|
+
window.addEventListener("scroll", checkPosition, true)
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
clearTimeout(timer)
|
|
203
|
+
document.removeEventListener("mousedown", handleClickOutside)
|
|
204
|
+
document.removeEventListener("keydown", handleEscape)
|
|
205
|
+
window.removeEventListener("resize", checkPosition)
|
|
206
|
+
window.removeEventListener("scroll", checkPosition, true)
|
|
207
|
+
}
|
|
208
|
+
}, [context.open, context, sideOffset])
|
|
209
|
+
|
|
210
|
+
if (!context.open) return null
|
|
211
|
+
if (portal && !mounted) return null
|
|
212
|
+
|
|
213
|
+
const content = (
|
|
214
|
+
<div
|
|
215
|
+
ref={(node) => {
|
|
216
|
+
(contentRef as any).current = node
|
|
217
|
+
if (typeof ref === 'function') ref(node)
|
|
218
|
+
else if (ref) ref.current = node
|
|
219
|
+
}}
|
|
220
|
+
role="listbox"
|
|
221
|
+
className={cn(
|
|
222
|
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
|
223
|
+
"animate-in fade-in-0 zoom-in-95",
|
|
224
|
+
!portal && "absolute",
|
|
225
|
+
!portal && "mt-1",
|
|
226
|
+
portal && "fixed",
|
|
227
|
+
className
|
|
228
|
+
)}
|
|
229
|
+
style={{
|
|
230
|
+
top: portal ? position.top : undefined,
|
|
231
|
+
left: portal ? position.left : undefined,
|
|
232
|
+
width: context.triggerRef.current ? context.triggerRef.current.offsetWidth : undefined,
|
|
233
|
+
...props.style
|
|
234
|
+
}}
|
|
235
|
+
onClick={(e) => e.stopPropagation()}
|
|
236
|
+
{...props}
|
|
237
|
+
>
|
|
238
|
+
{children}
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if (portal) {
|
|
243
|
+
return createPortal(content, document.body)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return content
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
SelectContent.displayName = "SelectContent"
|
|
250
|
+
|
|
251
|
+
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
252
|
+
value: string
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const SelectItem = React.forwardRef<HTMLDivElement, SelectItemProps>(
|
|
256
|
+
({ className, children, value, ...props }, ref) => {
|
|
257
|
+
const context = React.useContext(SelectContext)
|
|
258
|
+
if (!context) throw new Error("SelectItem must be used within Select")
|
|
259
|
+
|
|
260
|
+
const isSelected = context.value === value
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
ref={ref}
|
|
265
|
+
role="option"
|
|
266
|
+
aria-selected={isSelected}
|
|
267
|
+
className={cn(
|
|
268
|
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
|
269
|
+
isSelected && "bg-accent text-accent-foreground",
|
|
270
|
+
className
|
|
271
|
+
)}
|
|
272
|
+
onClick={() => {
|
|
273
|
+
context.onValueChange(value)
|
|
274
|
+
context.setOpen(false)
|
|
275
|
+
}}
|
|
276
|
+
{...props}
|
|
277
|
+
>
|
|
278
|
+
{children}
|
|
279
|
+
{isSelected && (
|
|
280
|
+
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
281
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
282
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
283
|
+
</svg>
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
SelectItem.displayName = "SelectItem"
|
|
291
|
+
|
|
292
|
+
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }
|