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)