@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.
- package/dist/index.js +91 -0
- package/package.json +9 -3
- package/registry/calendar.tsx +416 -142
- package/registry/combobox.tsx +174 -0
- package/registry/command.tsx +298 -0
- package/registry/context-menu.tsx +221 -0
- package/registry/date-picker.tsx +179 -0
- package/registry/drawer.tsx +217 -0
- package/registry/dropdown-menu.tsx +1 -30
- package/registry/file-upload.tsx +240 -0
- package/registry/hover-card.tsx +165 -0
- package/registry/kbd.tsx +60 -0
- package/registry/menubar.tsx +246 -0
- package/registry/native-select.tsx +49 -0
- package/registry/pagination.tsx +3 -0
- package/registry/resizable.tsx +213 -0
- package/registry/scroll-area.tsx +60 -0
- package/registry/search.tsx +2 -1
- package/registry/sheet.tsx +1 -0
- package/registry/sidebar.tsx +505 -0
- package/registry/slider.tsx +82 -18
- package/registry/toggle-group.tsx +129 -0
- package/registry/toggle.tsx +72 -0
- package/registry/tooltip.tsx +21 -3
|
@@ -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
|
+
}
|