UIPackage

Command

Vue overlay
Edit on GitHub

Searchable command palette à la Cmd-K — keyboard-driven menu with grouped items, icons, shortcuts, and fuzzy filtering. Use as a global launcher (mounted in a Dialog) or inline as a typeahead select.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/command.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/command

Examples

Props

Name Type / Values Default Required
class HTMLAttributes['class'] optional

Dependencies

Used by

Files (10)

  • app/components/ui/command/Command.vue 2.3 kB
    <script setup lang="ts">
    import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'
    import { reactive, ref, watch } from 'vue'
    import { cn } from '@/lib/utils'
    import { provideCommandContext } from '.'
    
    const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes['class'] }>(), {
      modelValue: '',
    })
    
    const emits = defineEmits<ListboxRootEmits>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    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: {
        /** The count of all visible items. */
        count: 0,
        /** Map from visible item id to its search score. */
        items: new Map() as Map<string, number>,
        /** Set of groups with at least one visible item. */
        groups: new Set() as Set<string>,
      },
    })
    
    function filterItems() {
      if (!filterState.search) {
        filterState.filtered.count = allItems.value.size
        // Do nothing, each item will know to show itself because search is empty
        return
      }
    
      // Reset the groups
      filterState.filtered.groups = new Set()
      let itemCount = 0
    
      // Check which items should be included
      for (const [id, value] of allItems.value) {
        const score = contains(value, filterState.search)
        filterState.filtered.items.set(id, score ? 1 : 0)
        if (score) itemCount++
      }
    
      // Check which groups have at least 1 item shown
      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-uipkge
        data-slot="command"
        v-bind="forwarded"
        :class="
          cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)
        "
      >
        <slot />
      </ListboxRoot>
    </template>
  • app/components/ui/command/CommandDialog.vue 1 kB
    <script setup lang="ts">
    import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
    import { useForwardPropsEmits } from 'reka-ui'
    import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
    import Command from './Command.vue'
    
    const props = withDefaults(
      defineProps<
        DialogRootProps & {
          title?: string
          description?: string
        }
      >(),
      {
        title: 'Command Palette',
        description: 'Search for a command to run...',
      },
    )
    const emits = defineEmits<DialogRootEmits>()
    
    const forwarded = useForwardPropsEmits(props, emits)
    </script>
    
    <template>
      <Dialog v-slot="slotProps" v-bind="forwarded">
        <DialogContent class="overflow-hidden p-0">
          <DialogHeader class="sr-only">
            <DialogTitle>{{ title }}</DialogTitle>
            <DialogDescription>{{ description }}</DialogDescription>
          </DialogHeader>
          <Command>
            <slot v-bind="slotProps" />
          </Command>
        </DialogContent>
      </Dialog>
    </template>
  • app/components/ui/command/CommandEmpty.vue 0.8 kB
    <script setup lang="ts">
    import type { PrimitiveProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { Primitive } from 'reka-ui'
    import { computed } from 'vue'
    import { cn } from '@/lib/utils'
    import { useCommand } from '.'
    
    const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const { filterState } = useCommand()
    const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0)
    </script>
    
    <template>
      <Primitive
        v-if="isRender"
        data-uipkge
        data-slot="command-empty"
        v-bind="delegatedProps"
        :class="cn('py-6 text-center text-sm', props.class)"
      >
        <slot />
      </Primitive>
    </template>
  • app/components/ui/command/CommandGroup.vue 1.4 kB
    <script setup lang="ts">
    import type { ListboxGroupProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui'
    import { computed, onMounted, onUnmounted } from 'vue'
    import { cn } from '@/lib/utils'
    import { provideCommandGroupContext, useCommand } from '.'
    
    const props = defineProps<
      ListboxGroupProps & {
        class?: HTMLAttributes['class']
        heading?: string
      }
    >()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    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
        v-bind="delegatedProps"
        :id="id"
        data-uipkge
        data-slot="command-group"
        :class="cn('text-foreground overflow-hidden p-1', props.class)"
        :hidden="isRender ? undefined : true"
      >
        <ListboxGroupLabel
          v-if="heading"
          data-uipkge
          data-slot="command-group-heading"
          class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
        >
          {{ heading }}
        </ListboxGroupLabel>
        <slot />
      </ListboxGroup>
    </template>
  • app/components/ui/command/CommandInput.vue 1.5 kB
    <script setup lang="ts">
    import type { ListboxFilterProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { Search } from 'lucide-vue-next'
    import { ListboxFilter, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { useCommand } from '.'
    
    defineOptions({
      inheritAttrs: false,
    })
    
    const props = withDefaults(
      defineProps<
        ListboxFilterProps & {
          class?: HTMLAttributes['class']
        }
      >(),
      // Focus on mount by default (command-palette behavior). Consumers embedding
      // Command inline on a page can pass :auto-focus="false" so it doesn't steal
      // focus and scroll the input into view on load.
      { autoFocus: true },
    )
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const forwardedProps = useForwardProps(delegatedProps)
    
    const { filterState } = useCommand()
    </script>
    
    <template>
      <div data-uipkge data-slot="command-input-wrapper" class="flex h-9 items-center gap-2 border-b px-3">
        <Search class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
        <ListboxFilter
          v-bind="{ ...forwardedProps, ...$attrs }"
          v-model="filterState.search"
          data-uipkge
          data-slot="command-input"
          :class="
            cn(
              'placeholder:text-muted-foreground focus-visible:ring-ring/40 flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
              props.class,
            )
          "
        />
      </div>
    </template>
  • app/components/ui/command/CommandItem.vue 2.4 kB
    <script setup lang="ts">
    import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit, useCurrentElement } from '@vueuse/core'
    import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui'
    import { computed, onMounted, onUnmounted, ref } from 'vue'
    import { cn } from '@/lib/utils'
    import { useCommand, useCommandGroup } from '.'
    
    const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>()
    const emits = defineEmits<ListboxItemEmits>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    const id = useId()
    const { filterState, allItems, allGroups } = useCommand()
    const groupContext = useCommandGroup()
    
    const isRender = computed(() => {
      if (!filterState.search) {
        return true
      } else {
        const filteredCurrentItem = filterState.filtered.items.get(id)
        // If the filtered items is undefined means not in the all times map yet
        // Do the first render to add into the map
        if (filteredCurrentItem === undefined) {
          return true
        }
    
        // Check with filter
        return filteredCurrentItem > 0
      }
    })
    
    const itemRef = ref()
    const currentElement = useCurrentElement(itemRef)
    onMounted(() => {
      if (!(currentElement.value instanceof HTMLElement)) return
    
      // textValue to perform filter
      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"
        v-bind="forwarded"
        :id="id"
        ref="itemRef"
        data-uipkge
        data-slot="command-item"
        :class="
          cn(
            'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
            props.class,
          )
        "
        @select="
          () => {
            filterState.search = ''
          }
        "
      >
        <slot />
      </ListboxItem>
    </template>
  • app/components/ui/command/CommandList.vue 0.7 kB
    <script setup lang="ts">
    import type { ListboxContentProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { ListboxContent, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    const props = defineProps<ListboxContentProps & { class?: HTMLAttributes['class'] }>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const forwarded = useForwardProps(delegatedProps)
    </script>
    
    <template>
      <ListboxContent
        data-uipkge
        data-slot="command-list"
        v-bind="forwarded"
        :class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
      >
        <div role="presentation">
          <slot />
        </div>
      </ListboxContent>
    </template>
  • app/components/ui/command/CommandSeparator.vue 0.6 kB
    <script setup lang="ts">
    import type { SeparatorProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { Separator } from 'reka-ui'
    import { cn } from '@/lib/utils'
    
    const props = defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    </script>
    
    <template>
      <Separator
        data-uipkge
        data-slot="command-separator"
        v-bind="delegatedProps"
        :class="cn('bg-border -mx-1 h-px', props.class)"
      >
        <slot />
      </Separator>
    </template>
  • app/components/ui/command/CommandShortcut.vue 0.4 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
    }>()
    </script>
    
    <template>
      <span
        data-uipkge
        data-slot="command-shortcut"
        :class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
      >
        <slot />
      </span>
    </template>
  • app/components/ui/command/index.ts 1 kB
    import type { Ref } from 'vue'
    import { createContext } from 'reka-ui'
    
    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 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')

Raw manifest: https://uipkge.dev/r/vue/command.json