primitives
Command
Command — базовый примитив RevueUI (Reka UI + shadcn-совместимый API).
Загрузка превью…
src/ui/command/Command.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, reactive, ref, watch } from "vue"
import { ListboxRoot, type ListboxRootEmits, type ListboxRootProps, useFilter, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { provideCommandContext } from "./context"
const props = withDefaults(
defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(),
{
modelValue: "",
highlightOnHover: true,
}
)
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: "base" })
const filterState = reactive({
search: "",
filtered: {
count: 0,
items: new Map() as Map<string, number>,
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
return
}
filterState.filtered.groups = new Set()
let itemCount = 0
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score) itemCount++
}
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(
() => filterState.search,
() => {
filterItems()
}
)
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
data-slot="command"
v-bind="forwarded"
:class="cn('cn-command flex size-full flex-col overflow-hidden', props.class)"
>
<slot />
</ListboxRoot>
</template>
src/ui/command/CommandDialog.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { type DialogRootEmits, type DialogRootProps, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import Command from "./Command.vue"
const props = withDefaults(
defineProps<
DialogRootProps & {
title?: string
description?: string
class?: HTMLAttributes["class"]
showCloseButton?: boolean
}
>(),
{
title: "Command Palette",
description: "Search for a command to run...",
showCloseButton: false,
}
)
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-slot="slotProps" v-bind="forwarded">
<DialogContent
:class="cn('cn-command-dialog top-1/3 translate-y-0 overflow-hidden p-0', props.class)"
:show-close-button="showCloseButton"
>
<DialogHeader class="sr-only">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<Command>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
</Dialog>
</template>
src/ui/command/CommandEmpty.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { Primitive, type PrimitiveProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { useCommand } from "./context"
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0)
</script>
<template>
<Primitive
v-if="isRender"
data-slot="command-empty"
v-bind="delegatedProps"
:class="cn('cn-command-empty', props.class)"
>
<slot />
</Primitive>
</template>
src/ui/command/CommandGroup.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, onMounted, onUnmounted } from "vue"
import { ListboxGroup, type ListboxGroupProps, ListboxGroupLabel, useId } from "reka-ui"
import { cn } from "@/lib/utils"
import { provideCommandGroupContext, useCommand } from "./context"
const props = defineProps<
ListboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}
>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => (!filterState.search ? true : filterState.filtered.groups.has(id)))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id)) allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
:id="id"
v-bind="delegatedProps"
data-slot="command-group"
:class="cn('cn-command-group', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" cmdk-group-heading>
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>
src/ui/command/CommandInput.vue
<script setup lang="ts">
/**
* Оригинал (registry/bases/radix/ui/command.tsx) рисует иконку поиска через
* IconPlaceholder — тот же блокер, что у ui-checkbox/ui-spinner (docs/
* PORTING.md, §5): лениво импортирует @/registry/icons/icon-lucide, не
* резолвится вне полного Next.js-приложения ReUI. Заменена инлайновым
* <svg> (путь lucide "search", lucide-react v0.545.0), тем же, что и в
* react-эталоне кейса.
*/
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { ListboxFilter, type ListboxFilterProps, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import { useCommand } from "./context"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ListboxFilterProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div data-slot="command-input-wrapper" class="cn-command-input-wrapper">
<InputGroup class="cn-command-input-group">
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
:class="cn('cn-command-input outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
<InputGroupAddon>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cn-command-input-icon"
>
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
</InputGroupAddon>
</InputGroup>
</div>
</template>
src/ui/command/CommandItem.vue
<script setup lang="ts">
/**
* Индикатор "checked" в оригинале — IconPlaceholder (тот же блокер, что и
* в CommandInput). Заменён инлайновым <svg> (путь lucide "check").
*
* cmdk ставит буквально `data-selected="true"` на клавиатурно
* подсвеченный пункт и `data-disabled="true"`/`"false"` на отключённый —
* `.cn-command-item` (design-system CSS) написан именно под эти строковые
* значения (`data-selected:` / `data-[disabled=true]:`). У reka-ui
* `ListboxItem` та же роль — атрибуты `data-highlighted` (подсветка) и
* `data-disabled` (пустая строка, не `"true"`) — другое имя/значение,
* тот же класс расхождения, что описан в docs/PORTING.md §16 (голые
* data-* не матчатся строковыми селекторами). Правка — не в самом
* `.cn-command-item` (правило 1: cva/Tailwind-строки не трогаем), а
* точечный scoped-стиль ниже, отображающий атрибуты reka-ui на тот же
* визуальный результат.
*/
import type { HTMLAttributes } from "vue"
import { computed, onMounted, onUnmounted, ref } from "vue"
import { useCurrentElement } from "@vueuse/core"
import { ListboxItem, type ListboxItemEmits, type ListboxItemProps, useForwardPropsEmits, useId } from "reka-ui"
import { cn } from "@/lib/utils"
import { useCommand, useCommandGroup } from "./context"
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
}
const filteredCurrentItem = filterState.filtered.items.get(id)
if (filteredCurrentItem === undefined) {
return true
}
return filteredCurrentItem > 0
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement)) return
allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? ""))
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
} else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
:id="id"
ref="itemRef"
v-bind="forwarded"
data-slot="command-item"
:class="
cn(
'cn-command-item group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class
)
"
@select="
() => {
filterState.search = ''
}
"
>
<slot />
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cn-command-item-indicator ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</ListboxItem>
</template>
<style scoped>
.cn-command-item[data-highlighted] {
background-color: var(--muted);
color: var(--foreground);
}
.cn-command-item[data-highlighted] :deep(svg) {
color: var(--foreground);
}
.cn-command-item[data-highlighted] :deep(.cn-command-shortcut) {
color: var(--foreground);
}
.cn-command-item[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
</style>
src/ui/command/CommandList.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { ListboxContent, type ListboxContentProps, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ListboxContent
data-slot="command-list"
v-bind="forwarded"
:class="cn('cn-command-list overflow-x-hidden overflow-y-auto', props.class)"
>
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>
src/ui/command/CommandSeparator.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { Separator, type SeparatorProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = computed(() => {
const { class: _class, ...rest } = props
return rest
})
</script>
<template>
<Separator
data-slot="command-separator"
v-bind="delegatedProps"
:class="cn('cn-command-separator', props.class)"
>
<slot />
</Separator>
</template>
src/ui/command/CommandShortcut.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span data-slot="command-shortcut" :class="cn('cn-command-shortcut', props.class)">
<slot />
</span>
</template>
src/ui/command/context.ts
import type { Ref } from "vue"
import { createContext } from "reka-ui"
/**
* cmdk (апстрим, registry/bases/radix/ui/command.tsx) не имеет Vue-порта.
* Порт собран на reka-ui Listbox*, тем же приёмом, что и у shadcn-vue
* (apps/v4/registry/bases/reka/ui/command в unovue/shadcn-vue, MIT) —
* см. docs/PORTING.md. Фильтрация (allItems/allGroups/filterState)
* воспроизводит поведение cmdk: элементы и группы регистрируются сами
* при монтировании, видимость считается по текстовому совпадению
* (`useFilter` reka-ui, sensitivity "base").
*/
export const [useCommand, provideCommandContext] = createContext<{
allItems: Ref<Map<string, string>>
allGroups: Ref<Map<string, Set<string>>>
filterState: {
search: string
filtered: { count: number; items: Map<string, number>; groups: Set<string> }
}
}>("Command")
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
id?: string
}>("CommandGroup")
src/ui/command/index.ts
export { default as Command } from "./Command.vue"
export { default as CommandDialog } from "./CommandDialog.vue"
export { default as CommandEmpty } from "./CommandEmpty.vue"
export { default as CommandGroup } from "./CommandGroup.vue"
export { default as CommandInput } from "./CommandInput.vue"
export { default as CommandItem } from "./CommandItem.vue"
export { default as CommandList } from "./CommandList.vue"
export { default as CommandSeparator } from "./CommandSeparator.vue"
export { default as CommandShortcut } from "./CommandShortcut.vue"
export { useCommand, useCommandGroup } from "./context"
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/command.jsonЗависимости реестра
npm-зависимости
- @vueuse/core
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)