shadimage

Command

Searchable command list with grouping, keyboard navigation, and selection.

source cached 2026-10-19

components/ui/command.tsx

'use client'

import * as React from 'react'
import { Check, Command as CommandIcon, Search } from 'lucide-react'
import { cn } from '@/lib/utils'

export interface CommandItem {
  value: string
  label: React.ReactNode
  keywords?: string
  group?: string
  icon?: React.ReactNode
  disabled?: boolean
}

export interface CommandProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
  items: CommandItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  onSelect?: (item: CommandItem) => void
  placeholder?: string
  emptyMessage?: React.ReactNode
}

const listboxId = 'rawkitui-command-list'

export function Command({
  items,
  value: controlledValue,
  defaultValue,
  onValueChange,
  onSelect,
  placeholder = 'Search commands... ',
  emptyMessage = 'No results found.',
  className,
  ...props
}: CommandProps) {
  const [query, setQuery] = React.useState('')
  const [activeIndex, setActiveIndex] = React.useState(0)
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? '')
  const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([])
  const currentValue = controlledValue ?? internalValue
  const normalizedQuery = query.trim().toLowerCase()
  const filteredItems = items.filter((item) => `${item.value} ${item.label} ${item.keywords ?? ''}`.toLowerCase().includes(normalizedQuery))
  const groups = filteredItems.reduce<Record<string, CommandItem[]>>((result, item) => {
    const group = item.group ?? 'Suggestions'
    result[group] ??= []
    result[group].push(item)
    return result
  }, {})

  React.useEffect(() => setActiveIndex(0), [query])

  const activeItem = filteredItems[activeIndex]
  const activeId = activeItem ? `rawkitui-command-option-${activeItem.value}` : undefined

  // Keep the highlighted option in view inside the scrollable listbox.
  React.useEffect(() => {
    optionRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' })
  }, [activeIndex, query])

  const selectItem = (item: CommandItem) => {
    if (item.disabled) return
    setInternalValue(item.value)
    onValueChange?.(item.value)
    onSelect?.(item)
  }

  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'ArrowDown') {
      event.preventDefault()
      setActiveIndex((index) => Math.min(index + 1, filteredItems.length - 1))
    }
    if (event.key === 'ArrowUp') {
      event.preventDefault()
      setActiveIndex((index) => Math.max(index - 1, 0))
    }
    if (event.key === 'Home') {
      event.preventDefault()
      setActiveIndex(0)
    }
    if (event.key === 'End') {
      event.preventDefault()
      setActiveIndex(filteredItems.length - 1)
    }
    if (event.key === 'Enter' && activeItem) {
      event.preventDefault()
      selectItem(activeItem)
    }
  }

  return (
    <div className={cn('w-full max-w-lg overflow-hidden rounded-2xl bg-rk-surface font-sans rk-border rk-shadow-md', className)} {...props}>
      <div className="flex items-center gap-3 border-b-2 border-rk-ink/15 px-4">
        <Search className="h-4 w-4 shrink-0 text-black/50" aria-hidden="true" />
        <input
          value={query}
          onChange={(event) => setQuery(event.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          role="combobox"
          aria-expanded="true"
          aria-controls={listboxId}
          aria-activedescendant={activeId}
          aria-autocomplete="list"
          className="h-12 min-w-0 flex-1 bg-transparent text-sm font-bold outline-none placeholder:text-black/40"
        />
        <CommandIcon className="h-4 w-4 shrink-0 text-black/35" aria-hidden="true" />
      </div>
      <div id={listboxId} role="listbox" aria-label="Command options" className="max-h-72 overflow-y-auto p-2">
        {filteredItems.length === 0 ? (
          <div role="status" className="px-3 py-8 text-center text-sm font-bold text-black/55">{emptyMessage}</div>
        ) : (
          Object.entries(groups).map(([group, groupItems]) => (
            <div key={group} role="group" aria-label={group} className="mb-2 last:mb-0">
              <div className="px-3 py-2 font-mono text-[10px] font-black uppercase tracking-wider text-rk-secondary">{group}</div>
              {groupItems.map((item) => {
                const index = filteredItems.indexOf(item)
                const isActive = index === activeIndex
                return (
                  <button
                    key={item.value}
                    ref={(el) => {
                      optionRefs.current[index] = el
                    }}
                    type="button"
                    role="option"
                    id={`rawkitui-command-option-${item.value}`}
                    aria-selected={currentValue === item.value}
                    disabled={item.disabled}
                    onMouseEnter={() => setActiveIndex(index)}
                    onClick={() => selectItem(item)}
                    className={cn('flex min-h-10 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-extrabold transition-colors focus:outline-none disabled:pointer-events-none disabled:opacity-40', isActive ? 'bg-rk-primary' : 'hover:bg-rk-canvas')}
                  >
                    <span className="flex h-6 w-6 shrink-0 items-center justify-center">{item.icon}</span>
                    <span className="min-w-0 flex-1 truncate">{item.label}</span>
                    {currentValue === item.value && <Check className="h-4 w-4 shrink-0 stroke-[3]" aria-hidden="true" />}
                  </button>
                )
              })}
            </div>
          ))
        )}
      </div>
    </div>
  )
}
Where it goes
kanban
Interaction
dropdown

DependenciesBundle weight · light

icons
lucide-react