@srcroot/ui 0.0.1 → 0.0.2

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.
@@ -0,0 +1,174 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Check, ChevronsUpDown, X } from "lucide-react"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { Button } from "@/components/ui/button"
8
+ import {
9
+ Command,
10
+ CommandEmpty,
11
+ CommandGroup,
12
+ CommandInput,
13
+ CommandItem,
14
+ CommandList,
15
+ } from "@/components/ui/command"
16
+ import {
17
+ Popover,
18
+ PopoverContent,
19
+ PopoverTrigger,
20
+ } from "@/components/ui/popover"
21
+ import { Badge } from "@/components/ui/badge"
22
+
23
+ export type ComboboxOption = {
24
+ value: string
25
+ label: string
26
+ icon?: React.ComponentType<{ className?: string }>
27
+ }
28
+
29
+ interface ComboboxProps {
30
+ options: ComboboxOption[]
31
+ value?: string | string[]
32
+ onValueChange?: (value: string | string[]) => void
33
+ placeholder?: string
34
+ searchPlaceholder?: string
35
+ emptyMessage?: string
36
+ multiple?: boolean
37
+ className?: string
38
+ disabled?: boolean
39
+ }
40
+
41
+ export function Combobox({
42
+ options,
43
+ value,
44
+ onValueChange,
45
+ placeholder = "Select option...",
46
+ searchPlaceholder = "Search...",
47
+ emptyMessage = "No option found.",
48
+ multiple = false,
49
+ className,
50
+ disabled = false,
51
+ }: ComboboxProps) {
52
+ const [open, setOpen] = React.useState(false)
53
+
54
+ // Helper to handle selection
55
+ const handleSelect = React.useCallback(
56
+ (currentValue: string) => {
57
+ if (multiple) {
58
+ const currentValues = Array.isArray(value) ? value : []
59
+ const newValues = currentValues.includes(currentValue)
60
+ ? currentValues.filter((v) => v !== currentValue)
61
+ : [...currentValues, currentValue]
62
+ onValueChange?.(newValues)
63
+ } else {
64
+ onValueChange?.(currentValue === value ? "" : currentValue)
65
+ setOpen(false)
66
+ }
67
+ },
68
+ [multiple, value, onValueChange]
69
+ )
70
+
71
+ // Derived state for display
72
+ const selectedOptions = React.useMemo(() => {
73
+ if (multiple) {
74
+ const currentValues = Array.isArray(value) ? value : []
75
+ return currentValues
76
+ .map((v) => options.find((opt) => opt.value === v))
77
+ .filter(Boolean) as ComboboxOption[]
78
+ } else {
79
+ const option = options.find((opt) => opt.value === value)
80
+ return option ? [option] : []
81
+ }
82
+ }, [multiple, value, options])
83
+
84
+ return (
85
+ <Popover open={open} onOpenChange={setOpen}>
86
+ <PopoverTrigger asChild>
87
+ <Button
88
+ variant="outline"
89
+ role="combobox"
90
+ aria-expanded={open}
91
+ className={cn("w-full justify-between h-auto min-h-10", className)}
92
+ disabled={disabled}
93
+ >
94
+ <div className="flex gap-1 flex-wrap items-center text-left">
95
+ {selectedOptions.length > 0 ? (
96
+ multiple ? (
97
+ selectedOptions.length > 3 ? (
98
+ <Badge variant="secondary" className="rounded-sm px-1 font-normal">
99
+ {selectedOptions.length} selected
100
+ </Badge>
101
+ ) : (
102
+ selectedOptions.map((opt) => {
103
+ const Icon = opt.icon
104
+ return (
105
+ <Badge
106
+ variant="secondary"
107
+ key={opt.value}
108
+ className="rounded-sm px-1 font-normal items-center gap-1"
109
+ onClick={(e: React.MouseEvent) => {
110
+ e.stopPropagation()
111
+ handleSelect(opt.value)
112
+ }}
113
+ >
114
+ {Icon && <Icon className="h-3 w-3" />}
115
+ {opt.label}
116
+ <X className="h-3 w-3 text-muted-foreground hover:text-foreground ml-0.5" />
117
+ </Badge>
118
+ )
119
+ })
120
+ )
121
+ ) : (
122
+ <div className="flex items-center gap-2">
123
+ {selectedOptions[0].icon && (
124
+ <selectedOptions[0].icon className="h-4 w-4 text-muted-foreground" />
125
+ ) /* this effectively forces me to use a variable too */}
126
+ {(() => {
127
+ const Icon = selectedOptions[0].icon
128
+ return Icon ? <Icon className="h-4 w-4 text-muted-foreground" /> : null
129
+ })()}
130
+ <span>{selectedOptions[0].label}</span>
131
+ </div>
132
+ )
133
+ ) : (
134
+ <span className="text-muted-foreground">{placeholder}</span>
135
+ )}
136
+ </div>
137
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
138
+ </Button>
139
+ </PopoverTrigger>
140
+ <PopoverContent className="w-full min-w-[200px] p-0">
141
+ <Command>
142
+ <CommandInput placeholder={searchPlaceholder} />
143
+ <CommandList>
144
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
145
+ <CommandGroup>
146
+ {options.map((option) => (
147
+ <CommandItem
148
+ key={option.value}
149
+ value={option.label}
150
+ onSelect={() => handleSelect(option.value)}
151
+ >
152
+ <Check
153
+ className={cn(
154
+ "mr-2 h-4 w-4",
155
+ multiple
156
+ ? (Array.isArray(value) && value.includes(option.value)
157
+ ? "opacity-100"
158
+ : "opacity-0")
159
+ : value === option.value
160
+ ? "opacity-100"
161
+ : "opacity-0"
162
+ )}
163
+ />
164
+ {option.icon && <option.icon className="mr-2 h-4 w-4 text-muted-foreground" />}
165
+ {option.label}
166
+ </CommandItem>
167
+ ))}
168
+ </CommandGroup>
169
+ </CommandList>
170
+ </Command>
171
+ </PopoverContent>
172
+ </Popover>
173
+ )
174
+ }
@@ -0,0 +1,298 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Search } from "lucide-react"
5
+ import { cn } from "@/lib/utils"
6
+ import { Dialog, DialogContent } from "@/components/ui/dialog"
7
+
8
+ interface CommandContextValue {
9
+ search: string
10
+ setSearch: (search: string) => void
11
+ selectedIndex: number
12
+ setSelectedIndex: (index: number) => void
13
+ items: string[]
14
+ registerItem: (value: string) => void
15
+ unregisterItem: (value: string) => void
16
+ }
17
+
18
+ const CommandContext = React.createContext<CommandContextValue | null>(null)
19
+
20
+ function useCommandContext() {
21
+ const context = React.useContext(CommandContext)
22
+ if (!context) {
23
+ throw new Error("Command components must be used within Command")
24
+ }
25
+ return context
26
+ }
27
+
28
+ interface CommandProps extends React.HTMLAttributes<HTMLDivElement> {
29
+ children: React.ReactNode
30
+ }
31
+
32
+ const Command = React.forwardRef<HTMLDivElement, CommandProps>(
33
+ ({ className, children, ...props }, ref) => {
34
+ const [search, setSearch] = React.useState("")
35
+ const [selectedIndex, setSelectedIndex] = React.useState(0)
36
+ const [items, setItems] = React.useState<string[]>([])
37
+
38
+ const registerItem = React.useCallback((value: string) => {
39
+ setItems((prev) => [...prev, value])
40
+ }, [])
41
+
42
+ const unregisterItem = React.useCallback((value: string) => {
43
+ setItems((prev) => prev.filter((item) => item !== value))
44
+ }, [])
45
+
46
+ // Handle keyboard navigation
47
+ const handleKeyDown = (e: React.KeyboardEvent) => {
48
+ switch (e.key) {
49
+ case "ArrowDown":
50
+ e.preventDefault()
51
+ setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
52
+ break
53
+ case "ArrowUp":
54
+ e.preventDefault()
55
+ setSelectedIndex((prev) => Math.max(prev - 1, 0))
56
+ break
57
+ case "Enter":
58
+ e.preventDefault()
59
+ const selectedItem = document.querySelector('[data-selected="true"]') as HTMLElement
60
+ selectedItem?.click()
61
+ break
62
+ }
63
+ }
64
+
65
+ return (
66
+ <CommandContext.Provider
67
+ value={{
68
+ search,
69
+ setSearch,
70
+ selectedIndex,
71
+ setSelectedIndex,
72
+ items,
73
+ registerItem,
74
+ unregisterItem,
75
+ }}
76
+ >
77
+ <div
78
+ ref={ref}
79
+ className={cn(
80
+ "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
81
+ className
82
+ )}
83
+ onKeyDown={handleKeyDown}
84
+ {...props}
85
+ >
86
+ {children}
87
+ </div>
88
+ </CommandContext.Provider>
89
+ )
90
+ }
91
+ )
92
+ Command.displayName = "Command"
93
+
94
+ interface CommandDialogProps {
95
+ children: React.ReactNode
96
+ open?: boolean
97
+ onOpenChange?: (open: boolean) => void
98
+ }
99
+
100
+ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
101
+ return (
102
+ <Dialog {...props}>
103
+ <DialogContent className="overflow-hidden p-0 shadow-lg">
104
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground">
105
+ {children}
106
+ </Command>
107
+ </DialogContent>
108
+ </Dialog>
109
+ )
110
+ }
111
+
112
+ interface CommandInputProps extends React.InputHTMLAttributes<HTMLInputElement> { }
113
+
114
+ const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(
115
+ ({ className, ...props }, ref) => {
116
+ const { search, setSearch, setSelectedIndex } = useCommandContext()
117
+
118
+ return (
119
+ <div className="flex items-center border-b px-3">
120
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
121
+ <input
122
+ ref={ref}
123
+ value={search}
124
+ onChange={(e) => {
125
+ setSearch(e.target.value)
126
+ setSelectedIndex(0)
127
+ }}
128
+ className={cn(
129
+ "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
130
+ className
131
+ )}
132
+ {...props}
133
+ />
134
+ </div>
135
+ )
136
+ }
137
+ )
138
+ CommandInput.displayName = "CommandInput"
139
+
140
+ interface CommandListProps extends React.HTMLAttributes<HTMLDivElement> { }
141
+
142
+ const CommandList = React.forwardRef<HTMLDivElement, CommandListProps>(
143
+ ({ className, ...props }, ref) => (
144
+ <div
145
+ ref={ref}
146
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
147
+ {...props}
148
+ />
149
+ )
150
+ )
151
+ CommandList.displayName = "CommandList"
152
+
153
+ interface CommandEmptyProps extends React.HTMLAttributes<HTMLDivElement> { }
154
+
155
+ const CommandEmpty = React.forwardRef<HTMLDivElement, CommandEmptyProps>(
156
+ ({ className, children, ...props }, ref) => {
157
+ const { search, items } = useCommandContext()
158
+
159
+ // Check if any items are visible
160
+ const hasVisibleItems = items.length > 0
161
+
162
+ if (hasVisibleItems && search) {
163
+ // This will be handled by filtering in CommandItem
164
+ return null
165
+ }
166
+
167
+ return (
168
+ <div
169
+ ref={ref}
170
+ className={cn("py-6 text-center text-sm", className)}
171
+ {...props}
172
+ >
173
+ {children}
174
+ </div>
175
+ )
176
+ }
177
+ )
178
+ CommandEmpty.displayName = "CommandEmpty"
179
+
180
+ interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
181
+ heading?: string
182
+ }
183
+
184
+ const CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(
185
+ ({ className, heading, children, ...props }, ref) => (
186
+ <div
187
+ ref={ref}
188
+ className={cn(
189
+ "overflow-hidden p-1 text-foreground",
190
+ className
191
+ )}
192
+ {...props}
193
+ >
194
+ {heading && (
195
+ <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
196
+ {heading}
197
+ </div>
198
+ )}
199
+ {children}
200
+ </div>
201
+ )
202
+ )
203
+ CommandGroup.displayName = "CommandGroup"
204
+
205
+ interface CommandSeparatorProps extends React.HTMLAttributes<HTMLDivElement> { }
206
+
207
+ const CommandSeparator = React.forwardRef<HTMLDivElement, CommandSeparatorProps>(
208
+ ({ className, ...props }, ref) => (
209
+ <div
210
+ ref={ref}
211
+ className={cn("-mx-1 h-px bg-border", className)}
212
+ {...props}
213
+ />
214
+ )
215
+ )
216
+ CommandSeparator.displayName = "CommandSeparator"
217
+
218
+ interface CommandItemProps extends React.HTMLAttributes<HTMLDivElement> {
219
+ value?: string
220
+ onSelect?: () => void
221
+ disabled?: boolean
222
+ }
223
+
224
+ const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(
225
+ ({ className, value, onSelect, disabled, children, ...props }, ref) => {
226
+ const { search, selectedIndex, setSelectedIndex, registerItem, unregisterItem, items } = useCommandContext()
227
+ const itemValue = value || (typeof children === "string" ? children : "")
228
+
229
+ // Register/unregister on mount
230
+ React.useEffect(() => {
231
+ registerItem(itemValue)
232
+ return () => unregisterItem(itemValue)
233
+ }, [itemValue, registerItem, unregisterItem])
234
+
235
+ // Filter based on search
236
+ const isVisible = !search || itemValue.toLowerCase().includes(search.toLowerCase())
237
+
238
+ if (!isVisible) return null
239
+
240
+ const itemIndex = items.indexOf(itemValue)
241
+ const isSelected = itemIndex === selectedIndex
242
+
243
+ return (
244
+ <div
245
+ ref={ref}
246
+ role="option"
247
+ aria-selected={isSelected}
248
+ data-selected={isSelected}
249
+ data-disabled={disabled}
250
+ className={cn(
251
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
252
+ isSelected && "bg-accent text-accent-foreground",
253
+ disabled && "pointer-events-none opacity-50",
254
+ !disabled && "cursor-pointer",
255
+ className
256
+ )}
257
+ onClick={() => {
258
+ if (!disabled) {
259
+ onSelect?.()
260
+ }
261
+ }}
262
+ onMouseEnter={() => setSelectedIndex(itemIndex)}
263
+ {...props}
264
+ >
265
+ {children}
266
+ </div>
267
+ )
268
+ }
269
+ )
270
+ CommandItem.displayName = "CommandItem"
271
+
272
+ const CommandShortcut = ({
273
+ className,
274
+ ...props
275
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
276
+ return (
277
+ <span
278
+ className={cn(
279
+ "ml-auto text-xs tracking-widest text-muted-foreground",
280
+ className
281
+ )}
282
+ {...props}
283
+ />
284
+ )
285
+ }
286
+ CommandShortcut.displayName = "CommandShortcut"
287
+
288
+ export {
289
+ Command,
290
+ CommandDialog,
291
+ CommandInput,
292
+ CommandList,
293
+ CommandEmpty,
294
+ CommandGroup,
295
+ CommandItem,
296
+ CommandShortcut,
297
+ CommandSeparator,
298
+ }
@@ -0,0 +1,221 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ // Context Menu Context
7
+ interface ContextMenuContextValue {
8
+ open: boolean
9
+ position: { x: number; y: number }
10
+ onOpenChange: (open: boolean) => void
11
+ }
12
+
13
+ const ContextMenuContext = React.createContext<ContextMenuContextValue | null>(null)
14
+
15
+ function useContextMenu() {
16
+ const context = React.useContext(ContextMenuContext)
17
+ if (!context) {
18
+ throw new Error("useContextMenu must be used within a ContextMenu")
19
+ }
20
+ return context
21
+ }
22
+
23
+ // ContextMenu Root
24
+ interface ContextMenuProps {
25
+ children: React.ReactNode
26
+ }
27
+
28
+ const ContextMenu = ({ children }: ContextMenuProps) => {
29
+ const [open, setOpen] = React.useState(false)
30
+ const [position, setPosition] = React.useState({ x: 0, y: 0 })
31
+
32
+ const handleContextMenu = React.useCallback((e: React.MouseEvent) => {
33
+ e.preventDefault()
34
+ setPosition({ x: e.clientX, y: e.clientY })
35
+ setOpen(true)
36
+ }, [])
37
+
38
+ // Close on outside click
39
+ React.useEffect(() => {
40
+ if (!open) return
41
+
42
+ const handleClick = () => setOpen(false)
43
+ const handleEscape = (e: KeyboardEvent) => {
44
+ if (e.key === "Escape") setOpen(false)
45
+ }
46
+
47
+ document.addEventListener("click", handleClick)
48
+ document.addEventListener("keydown", handleEscape)
49
+ return () => {
50
+ document.removeEventListener("click", handleClick)
51
+ document.removeEventListener("keydown", handleEscape)
52
+ }
53
+ }, [open])
54
+
55
+ return (
56
+ <ContextMenuContext.Provider value={{ open, position, onOpenChange: setOpen }}>
57
+ <div onContextMenu={handleContextMenu}>
58
+ {children}
59
+ </div>
60
+ </ContextMenuContext.Provider>
61
+ )
62
+ }
63
+
64
+ // ContextMenu Trigger (the area that can be right-clicked)
65
+ interface ContextMenuTriggerProps {
66
+ children: React.ReactNode
67
+ className?: string
68
+ }
69
+
70
+ const ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(
71
+ ({ children, className, ...props }, ref) => {
72
+ return (
73
+ <div ref={ref} className={className} {...props}>
74
+ {children}
75
+ </div>
76
+ )
77
+ }
78
+ )
79
+ ContextMenuTrigger.displayName = "ContextMenuTrigger"
80
+
81
+ // ContextMenu Content
82
+ interface ContextMenuContentProps {
83
+ children: React.ReactNode
84
+ className?: string
85
+ }
86
+
87
+ const ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(
88
+ ({ children, className, ...props }, ref) => {
89
+ const { open, position } = useContextMenu()
90
+ const contentRef = React.useRef<HTMLDivElement>(null)
91
+ const [adjustedPosition, setAdjustedPosition] = React.useState(position)
92
+
93
+ // Adjust position to keep menu in viewport
94
+ React.useEffect(() => {
95
+ if (!open || !contentRef.current) return
96
+
97
+ const rect = contentRef.current.getBoundingClientRect()
98
+ const viewportWidth = window.innerWidth
99
+ const viewportHeight = window.innerHeight
100
+
101
+ let x = position.x
102
+ let y = position.y
103
+
104
+ // Adjust if overflowing right
105
+ if (x + rect.width > viewportWidth) {
106
+ x = viewportWidth - rect.width - 8
107
+ }
108
+ // Adjust if overflowing bottom
109
+ if (y + rect.height > viewportHeight) {
110
+ y = viewportHeight - rect.height - 8
111
+ }
112
+
113
+ setAdjustedPosition({ x: Math.max(8, x), y: Math.max(8, y) })
114
+ }, [open, position])
115
+
116
+ if (!open) return null
117
+
118
+ return (
119
+ <div
120
+ ref={contentRef}
121
+ className={cn(
122
+ "fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
123
+ "animate-in fade-in-0 zoom-in-95",
124
+ className
125
+ )}
126
+ style={{
127
+ left: adjustedPosition.x,
128
+ top: adjustedPosition.y,
129
+ }}
130
+ onClick={(e) => e.stopPropagation()}
131
+ {...props}
132
+ >
133
+ {children}
134
+ </div>
135
+ )
136
+ }
137
+ )
138
+ ContextMenuContent.displayName = "ContextMenuContent"
139
+
140
+ // ContextMenu Item
141
+ interface ContextMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
142
+ inset?: boolean
143
+ }
144
+
145
+ const ContextMenuItem = React.forwardRef<HTMLButtonElement, ContextMenuItemProps>(
146
+ ({ className, inset, children, ...props }, ref) => {
147
+ const { onOpenChange } = useContextMenu()
148
+
149
+ return (
150
+ <button
151
+ ref={ref}
152
+ className={cn(
153
+ "relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
154
+ "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
155
+ "disabled:pointer-events-none disabled:opacity-50",
156
+ inset && "pl-8",
157
+ className
158
+ )}
159
+ onClick={() => onOpenChange(false)}
160
+ {...props}
161
+ >
162
+ {children}
163
+ </button>
164
+ )
165
+ }
166
+ )
167
+ ContextMenuItem.displayName = "ContextMenuItem"
168
+
169
+ // ContextMenu Separator
170
+ const ContextMenuSeparator = React.forwardRef<
171
+ HTMLDivElement,
172
+ React.HTMLAttributes<HTMLDivElement>
173
+ >(({ className, ...props }, ref) => (
174
+ <div
175
+ ref={ref}
176
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
177
+ {...props}
178
+ />
179
+ ))
180
+ ContextMenuSeparator.displayName = "ContextMenuSeparator"
181
+
182
+ // ContextMenu Label
183
+ const ContextMenuLabel = React.forwardRef<
184
+ HTMLDivElement,
185
+ React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
186
+ >(({ className, inset, ...props }, ref) => (
187
+ <div
188
+ ref={ref}
189
+ className={cn(
190
+ "px-2 py-1.5 text-sm font-semibold text-foreground",
191
+ inset && "pl-8",
192
+ className
193
+ )}
194
+ {...props}
195
+ />
196
+ ))
197
+ ContextMenuLabel.displayName = "ContextMenuLabel"
198
+
199
+ // ContextMenu Shortcut
200
+ const ContextMenuShortcut = ({
201
+ className,
202
+ ...props
203
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
204
+ return (
205
+ <span
206
+ className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
207
+ {...props}
208
+ />
209
+ )
210
+ }
211
+ ContextMenuShortcut.displayName = "ContextMenuShortcut"
212
+
213
+ export {
214
+ ContextMenu,
215
+ ContextMenuTrigger,
216
+ ContextMenuContent,
217
+ ContextMenuItem,
218
+ ContextMenuSeparator,
219
+ ContextMenuLabel,
220
+ ContextMenuShortcut,
221
+ }