35bcb63300
Componentes React accesibles basados en Radix UI con soporte de temas via CSS variables. Incluye barrel export en index.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
205 lines
5.8 KiB
TypeScript
205 lines
5.8 KiB
TypeScript
import * as React from "react"
|
|
import { SearchIcon, XIcon } from "lucide-react"
|
|
import { cn } from "../core/cn"
|
|
|
|
interface CommandItem {
|
|
value: string
|
|
label: string
|
|
description?: string
|
|
icon?: React.ReactNode
|
|
disabled?: boolean
|
|
group?: string
|
|
}
|
|
|
|
interface CommandProps {
|
|
items: CommandItem[]
|
|
value?: string
|
|
onValueChange?: (value: string) => void
|
|
placeholder?: string
|
|
emptyMessage?: string
|
|
className?: string
|
|
inputClassName?: string
|
|
listClassName?: string
|
|
}
|
|
|
|
function Command({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="command"
|
|
className={cn("flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CommandInput({ className, ...props }: React.ComponentPropsWithoutRef<"input">) {
|
|
return (
|
|
<div data-slot="command-input-wrapper" className="flex items-center border-b px-3">
|
|
<SearchIcon className="mr-2 size-4 shrink-0 text-muted-foreground" />
|
|
<input
|
|
data-slot="command-input"
|
|
className={cn(
|
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none",
|
|
"placeholder:text-muted-foreground",
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CommandList({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="command-list"
|
|
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CommandEmpty({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="command-empty"
|
|
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CommandGroup({ className, heading, ...props }: React.ComponentPropsWithoutRef<"div"> & { heading?: string }) {
|
|
return (
|
|
<div data-slot="command-group" className={cn("overflow-hidden p-1 text-foreground", className)}>
|
|
{heading && (
|
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
|
|
)}
|
|
<div {...props} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CommandSeparator({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="command-separator"
|
|
className={cn("-mx-1 h-px bg-border", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface CommandItemProps extends React.ComponentPropsWithoutRef<"div"> {
|
|
selected?: boolean
|
|
disabled?: boolean
|
|
onSelect?: () => void
|
|
}
|
|
|
|
function CommandItem({ className, selected, disabled, onSelect, ...props }: CommandItemProps) {
|
|
return (
|
|
<div
|
|
data-slot="command-item"
|
|
data-selected={selected}
|
|
aria-disabled={disabled}
|
|
role="option"
|
|
aria-selected={selected}
|
|
onClick={!disabled ? onSelect : undefined}
|
|
className={cn(
|
|
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none",
|
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
|
|
"hover:bg-accent hover:text-accent-foreground",
|
|
disabled && "pointer-events-none opacity-50",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CommandShortcut({ className, ...props }: React.ComponentPropsWithoutRef<"span">) {
|
|
return (
|
|
<span
|
|
data-slot="command-shortcut"
|
|
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CommandSearch({
|
|
items,
|
|
value,
|
|
onValueChange,
|
|
placeholder = "Search...",
|
|
emptyMessage = "No results found.",
|
|
className,
|
|
}: CommandProps) {
|
|
const [query, setQuery] = React.useState("")
|
|
const [selectedValue, setSelectedValue] = React.useState(value ?? "")
|
|
|
|
const filtered = React.useMemo(() => {
|
|
if (!query) return items
|
|
const q = query.toLowerCase()
|
|
return items.filter(
|
|
(item) =>
|
|
item.label.toLowerCase().includes(q) ||
|
|
item.description?.toLowerCase().includes(q) ||
|
|
item.value.toLowerCase().includes(q)
|
|
)
|
|
}, [items, query])
|
|
|
|
const groups = React.useMemo(() => {
|
|
const map = new Map<string, CommandItem[]>()
|
|
for (const item of filtered) {
|
|
const key = item.group ?? ""
|
|
if (!map.has(key)) map.set(key, [])
|
|
map.get(key)!.push(item)
|
|
}
|
|
return map
|
|
}, [filtered])
|
|
|
|
const handleSelect = (val: string) => {
|
|
setSelectedValue(val)
|
|
onValueChange?.(val)
|
|
}
|
|
|
|
return (
|
|
<Command className={className}>
|
|
<CommandInput
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={placeholder}
|
|
/>
|
|
<CommandList>
|
|
{filtered.length === 0 ? (
|
|
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
|
) : (
|
|
Array.from(groups.entries()).map(([group, groupItems]) => (
|
|
<CommandGroup key={group} heading={group || undefined}>
|
|
{groupItems.map((item) => (
|
|
<CommandItem
|
|
key={item.value}
|
|
selected={selectedValue === item.value}
|
|
disabled={item.disabled}
|
|
onSelect={() => handleSelect(item.value)}
|
|
>
|
|
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
|
<span>{item.label}</span>
|
|
{item.description && (
|
|
<span className="ml-auto text-xs text-muted-foreground">{item.description}</span>
|
|
)}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
))
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
)
|
|
}
|
|
|
|
export { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSearch, CommandSeparator, CommandShortcut }
|
|
export type { CommandItem, CommandProps }
|