feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk)

This commit is contained in:
Denozordec
2026-09-25 01:35:29 +07:00
parent 28f1f35f16
commit 3d4edbad8b
7 changed files with 448 additions and 125 deletions
+2
View File
@@ -26,6 +26,8 @@
"@tanstack/react-virtual": "^3.14.7", "@tanstack/react-virtual": "^3.14.7",
"@tanstack/router-plugin": "^1.120.0", "@tanstack/router-plugin": "^1.120.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"cn": "^0.4.0",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+189 -105
View File
@@ -1,36 +1,55 @@
import { useEffect, useId, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { SearchIcon } from 'lucide-react' import { useQuery } from '@tanstack/react-query'
import {
ListIcon,
ListPlusIcon,
PlusIcon,
ServerIcon,
ShieldIcon,
ShieldPlusIcon,
} from 'lucide-react'
import { NAV_ITEMS } from '@/lib/nav' import { NAV_ITEMS } from '@/lib/nav'
import { Button } from '@evofw/ui/components/button' import { useCan } from '@/lib/permissions'
import { import {
Dialog, agentsQueryOptions,
DialogContent, listsQueryOptions,
DialogDescription, policySetsQueryOptions,
DialogHeader, } from '@/queries'
DialogTitle,
} from '@evofw/ui/components/dialog'
import { Input } from '@evofw/ui/components/input'
import { import {
Item, CommandDialog,
ItemContent, CommandEmpty,
ItemGroup, CommandGroup,
ItemMedia, CommandInput,
ItemTitle, CommandItem,
} from '@evofw/ui/components/item' CommandList,
CommandSeparator,
} from '@evofw/ui/components/command'
/**
* Command-K palette — навигация, поиск ресурсов и действия.
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
*/
/** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) { export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
void hotkeyOnly
return <SearchMenuDialog />
}
function SearchMenuDialog() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
const navigate = useNavigate() const navigate = useNavigate()
const can = useCan()
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault() event.preventDefault()
setOpen(true) setOpen((prev) => !prev)
} }
} }
@@ -38,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, []) }, [])
useEffect(() => { const go = (run: () => void) => {
if (!open) setQuery('')
}, [open])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return NAV_ITEMS
return NAV_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.keywords.some((k) => k.includes(q)),
)
}, [query])
function goTo(to: string) {
setOpen(false) setOpen(false)
void navigate({ to }) run()
} }
const navItems = NAV_ITEMS.filter(
(item) => !item.permission || can(item.permission),
)
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
const lists = (listsQ.data?.items ?? []).slice(0, 50)
const sets = (setsQ.data?.items ?? []).slice(0, 50)
return ( return (
<> <CommandDialog
{hotkeyOnly ? null : ( open={open}
<Button onOpenChange={setOpen}
type="button" title="Палитра команд"
variant="ghost" description="Поиск разделов, агентов, списков и действий"
size="icon" className="max-w-xl"
aria-label="Поиск"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
> >
<SearchIcon <CommandInput placeholder="Поиск разделов, агентов, списков…" />
className="size-4.5 transition-colors" <CommandList>
aria-hidden="true" <CommandEmpty>Ничего не найдено.</CommandEmpty>
/>
</Button>
)}
<Dialog open={open} onOpenChange={setOpen}> <CommandGroup heading="Действия">
<DialogHeader className="sr-only"> {can('fw:agents:write') ? (
<DialogTitle>Поиск</DialogTitle> <CommandItem
<DialogDescription> value="Добавить агента agent invite"
Переход к разделам приложения onSelect={() =>
</DialogDescription> go(() =>
</DialogHeader> void navigate({
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60"> to: '/agents',
<div className="relative flex items-center gap-3 border-b px-4 py-2"> search: { view: 'cards', add: true },
<SearchIcon }),
aria-hidden="true" )
className="pointer-events-none size-4 opacity-60 select-none"
/>
<Input
id={searchInputId}
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
autoFocus
placeholder="Перейти к разделу…"
aria-label="Поиск разделов"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered[0]) {
e.preventDefault()
goTo(filtered[0].to)
} }
}} >
/> <PlusIcon aria-hidden />
</div> Добавить агента
<ItemGroup className="max-h-72 overflow-y-auto p-2"> </CommandItem>
{filtered.length === 0 ? ( ) : null}
<p className="text-muted-foreground px-2 py-4 text-center text-sm"> {can('fw:lists:write') ? (
Ничего не найдено <CommandItem
</p> value="Создать список blocklist"
) : ( onSelect={() =>
filtered.map((item) => ( go(() =>
<Item void navigate({ to: '/lists', search: { create: true } }),
)
}
>
<ListPlusIcon aria-hidden />
Создать список
</CommandItem>
) : null}
{can('fw:policies:write') ? (
<CommandItem
value="Создать набор правил policy"
onSelect={() =>
go(() =>
void navigate({ to: '/rules', search: { create: true } }),
)
}
>
<ShieldPlusIcon aria-hidden />
Создать набор правил
</CommandItem>
) : null}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Навигация">
{navItems.map((item) => (
<CommandItem
key={item.to} key={item.to}
size="sm" value={`${item.label} ${item.keywords.join(' ')}`}
variant="muted" onSelect={() => go(() => void navigate({ to: item.to }))}
className="cursor-pointer border-0" >
render={<Link to={item.to} onClick={() => setOpen(false)} />} <item.icon aria-hidden />
{item.label}
</CommandItem>
))}
</CommandGroup>
{agents.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading="Агенты">
{agents.map((a) => (
<CommandItem
key={a.id}
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
onSelect={() =>
go(() =>
void navigate({
to: '/agents',
search: { view: 'cards', agent: a.id },
}),
)
}
>
<ServerIcon aria-hidden />
<span className="truncate">{a.name}</span>
<span className="text-muted-foreground truncate text-xs">
{a.hostname ?? a.status}
</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
{lists.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading="Списки">
{lists.map((l) => (
<CommandItem
key={l.id}
value={l.name}
onSelect={() =>
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
}
>
<ListIcon aria-hidden />
<span className="truncate">{l.name}</span>
</CommandItem>
))}
</CommandGroup>
</>
) : null}
{sets.length > 0 ? (
<>
<CommandSeparator />
<CommandGroup heading="Наборы правил">
{sets.map((s) => (
<CommandItem
key={s.id}
value={s.name}
onSelect={() =>
go(() =>
void navigate({
to: '/rules/$setId',
params: { setId: s.id },
}),
)
}
> >
<ItemMedia variant="icon"> <ShieldIcon aria-hidden />
<item.icon aria-hidden="true" /> <span className="truncate">{s.name}</span>
</ItemMedia> </CommandItem>
<ItemContent> ))}
<ItemTitle>{item.label}</ItemTitle> </CommandGroup>
</ItemContent>
</Item>
))
)}
</ItemGroup>
</DialogContent>
</Dialog>
</> </>
) : null}
</CommandList>
</CommandDialog>
) )
} }
+20 -6
View File
@@ -68,6 +68,8 @@ import type { Agent } from '@evofw/shared'
type AgentsSearch = { type AgentsSearch = {
agent?: string agent?: string
view: 'cards' | 'table' view: 'cards' | 'table'
/** Открыть sheet добавления агента (из ⌘K-палитры). */
add?: boolean
} }
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch { function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
@@ -76,7 +78,8 @@ function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
typeof search.agent === 'string' && search.agent.length > 0 typeof search.agent === 'string' && search.agent.length > 0
? search.agent ? search.agent
: undefined : undefined
return { view, agent } const add = search.add === true
return { view, agent, add }
} }
export const Route = createFileRoute('/_auth/agents/')({ export const Route = createFileRoute('/_auth/agents/')({
@@ -101,13 +104,14 @@ function formatNames(names: string[]): string {
function AgentsPage() { function AgentsPage() {
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const { agent: detailAgentId, view } = Route.useSearch() const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
const qc = useQueryClient() const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions()) const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions()) const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
const canWrite = useCan()('fw:agents:write') const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false) const [createOpenState, setCreateOpenState] = useState(false)
const createOpen = createOpenState || addParam === true
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null) const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [approveAllOpen, setApproveAllOpen] = useState(false) const [approveAllOpen, setApproveAllOpen] = useState(false)
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null) const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
@@ -130,6 +134,10 @@ function AgentsPage() {
next.agent !== undefined next.agent !== undefined
? next.agent || undefined ? next.agent || undefined
: base.agent, : base.agent,
add:
next.add !== undefined
? next.add || undefined
: base.add,
} satisfies AgentsSearch } satisfies AgentsSearch
}, },
replace: true, replace: true,
@@ -463,7 +471,7 @@ function AgentsPage() {
icon: <Plus aria-hidden />, icon: <Plus aria-hidden />,
iconClassName: 'text-primary [&_svg]:text-current', iconClassName: 'text-primary [&_svg]:text-current',
badgeLabel: 'Открыть', badgeLabel: 'Открыть',
onSelect: () => setCreateOpen(true), onSelect: () => setCreateOpenState(true),
}, },
{ {
id: 'pending', id: 'pending',
@@ -568,7 +576,7 @@ function AgentsPage() {
) )
const addButton = canWrite ? ( const addButton = canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Добавить агента Добавить агента
</Button> </Button>
@@ -726,7 +734,13 @@ function AgentsPage() {
</QueryState> </QueryState>
)} )}
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} /> <AddAgentSheet
open={createOpen}
onOpenChange={(open) => {
setCreateOpenState(open)
if (!open && addParam) setSearch({ add: false })
}}
/>
<AgentDetailSheet <AgentDetailSheet
agentId={detailAgentId ?? null} agentId={detailAgentId ?? null}
+18 -4
View File
@@ -47,6 +47,10 @@ import {
import { guessListSourceFromInput } from '@evofw/shared' import { guessListSourceFromInput } from '@evofw/shared'
export const Route = createFileRoute('/_auth/lists/')({ export const Route = createFileRoute('/_auth/lists/')({
validateSearch: (search: Record<string, unknown>) => ({
/** Открыть sheet создания (из ⌘K-палитры). */
create: search.create === true || undefined,
}),
component: ListsPage, component: ListsPage,
}) })
@@ -69,7 +73,9 @@ function ListsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const canWrite = useCan()('fw:lists:write') const canWrite = useCan()('fw:lists:write')
const [createOpen, setCreateOpen] = useState(false) const createParam = Route.useSearch().create === true
const [createOpenState, setCreateOpenState] = useState(false)
const createOpen = createOpenState || createParam
const [name, setName] = useState('') const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static') const [source, setSource] = useState<CreateSource>('static')
const [extra, setExtra] = useState('') const [extra, setExtra] = useState('')
@@ -202,7 +208,7 @@ function ListsPage() {
Обновить Обновить
</Button> </Button>
{canWrite ? ( {canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Создать Создать
</Button> </Button>
@@ -241,7 +247,7 @@ function ListsPage() {
title: 'Нет списков', title: 'Нет списков',
description: 'Создайте первый список для политики firewall.', description: 'Создайте первый список для политики firewall.',
action: ( action: (
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Создать Создать
</Button> </Button>
@@ -262,7 +268,15 @@ function ListsPage() {
disabled={removeList.isPending} disabled={removeList.isPending}
/> />
<Sheet open={createOpen} onOpenChange={setCreateOpen}> <Sheet
open={createOpen}
onOpenChange={(open) => {
setCreateOpenState(open)
if (!open && createParam) {
void navigate({ to: '/lists', search: { create: undefined }, replace: true })
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md"> <SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0"> <SheetHeader className="shrink-0">
<SheetTitle>Новый список</SheetTitle> <SheetTitle>Новый список</SheetTitle>
+19 -5
View File
@@ -29,6 +29,10 @@ import {
import type { PolicySet } from '@evofw/shared' import type { PolicySet } from '@evofw/shared'
export const Route = createFileRoute('/_auth/rules/')({ export const Route = createFileRoute('/_auth/rules/')({
validateSearch: (search: Record<string, unknown>) => ({
/** Открыть sheet создания (из ⌘K-палитры). */
create: search.create === true || undefined,
}),
component: PolicySetsPage, component: PolicySetsPage,
}) })
@@ -42,7 +46,9 @@ function PolicySetsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const setsQ = useQuery(policySetsQueryOptions()) const setsQ = useQuery(policySetsQueryOptions())
const [sheetOpen, setSheetOpen] = useState(false) const createParam = Route.useSearch().create === true
const [sheetOpenState, setSheetOpenState] = useState(false)
const sheetOpen = sheetOpenState || createParam
const [name, setName] = useState('') const [name, setName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [filters, setFilters] = useState<Filter[]>([]) const [filters, setFilters] = useState<Filter[]>([])
@@ -63,7 +69,7 @@ function PolicySetsPage() {
toast.success('Набор создан') toast.success('Набор создан')
setName('') setName('')
setDescription('') setDescription('')
setSheetOpen(false) setSheetOpenState(false)
void qc.invalidateQueries({ queryKey: ['policy-sets'] }) void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void navigate({ to: '/rules/$setId', params: { setId: row.id } }) void navigate({ to: '/rules/$setId', params: { setId: row.id } })
}, },
@@ -203,7 +209,7 @@ function PolicySetsPage() {
const canCreate = useCan()('fw:policies:write') const canCreate = useCan()('fw:policies:write')
const addButton = canCreate ? ( const addButton = canCreate ? (
<Button size="sm" onClick={() => setSheetOpen(true)}> <Button size="sm" onClick={() => setSheetOpenState(true)}>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
Новый набор Новый набор
</Button> </Button>
@@ -260,7 +266,15 @@ function PolicySetsPage() {
disabled={remove.isPending} disabled={remove.isPending}
/> />
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet
open={sheetOpen}
onOpenChange={(open) => {
setSheetOpenState(open)
if (!open && createParam) {
void navigate({ to: '/rules', search: { create: undefined }, replace: true })
}
}}
>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md"> <SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0"> <SheetHeader className="shrink-0">
<SheetTitle>Новый набор</SheetTitle> <SheetTitle>Новый набор</SheetTitle>
@@ -289,7 +303,7 @@ function PolicySetsPage() {
</Field> </Field>
</div> </div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t"> <SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => setSheetOpen(false)}> <Button variant="outline" onClick={() => setSheetOpenState(false)}>
Отмена Отмена
</Button> </Button>
<Button <Button
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tabs": "^1.1.14",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+194
View File
@@ -0,0 +1,194 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@evofw/ui/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@evofw/ui/components/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@evofw/ui/components/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}