Filterable command palette with controlled selection
Suggestions
Settings
No results found.
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'
...import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'
export default function CommandDemoExample() {
return (
<Command class="w-96 rounded-lg border">
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions"><CommandItem value="calendar">Calendar</CommandItem><CommandItem value="emoji">Search Emoji</CommandItem><CommandItem value="calculator" disabled>Calculator</CommandItem></CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings"><CommandItem value="profile">Profile <span class="ml-auto">⌘P</span></CommandItem><CommandItem value="billing">Billing <span class="ml-auto">⌘B</span></CommandItem><CommandItem value="settings">Settings <span class="ml-auto">⌘S</span></CommandItem></CommandGroup>
</CommandList>
</Command>
)
}
Installation
npx @fictjs/shadcn@latest add commandCopy the Fict shadcn component into your project.
Source
components/ui/command.tsx
src/components/ui/command.tsx
import { createContext, onDestroy, onMount, useContext } from 'fict'
import { createSignal } from 'fict/advanced'
import { cn } from '@/lib/cn'
type GenericProps = {
class?: string
children?: unknown
[key: string]: unknown
}
type MaybeAccessor<T> = T | (() => T)
type CommandProps = GenericProps & {
value?: MaybeAccessor<string>
defaultValue?: string
onValueChange?: (value: string) => void
query?: MaybeAccessor<string>
defaultQuery?: string
onQueryChange?: (query: string) => void
}
type CommandItemProps = GenericProps & {
value?: string
keywords?: string[]
keepOpen?: boolean
onSelect?: (value: string, event: MouseEvent) => void
}
type CommandRecord = {
value: string
text: string
keywords: string[]
}
type CommandContextValue = {
value: () => string
setValue: (value: string) => void
query: () => string
setQuery: (query: string) => void
open: () => boolean
setOpen: (open: boolean) => void
register: (record: CommandRecord) => () => void
matches: (record: CommandRecord) => boolean
hasMatches: () => boolean
}
const CommandContext = createContext<CommandContextValue | null>(null)
function read<T>(value: MaybeAccessor<T> | undefined, fallback: T): T {
if (typeof value === 'function') return (value as () => T)()
return value ?? fallback
}
function matchesQuery(query: string, record: CommandRecord): boolean {
const normalized = query.trim().toLowerCase()
if (!normalized) return true
return [record.value, record.text, ...record.keywords].join(' ').toLowerCase().includes(normalized)
}
function useCommand(): CommandContextValue {
const context = useContext(CommandContext)
if (!context) throw new Error('Command parts must be used inside Command')
return context
}
export function Command(props: CommandProps) {
const internalValue = createSignal(props.defaultValue ?? '')
const internalQuery = createSignal(props.defaultQuery ?? '')
const open = createSignal(false)
const records = createSignal<CommandRecord[]>([])
const contextValue: CommandContextValue = {
value: () => read(props.value, internalValue()),
setValue: value => {
if (props.value === undefined) internalValue(value)
props.onValueChange?.(value)
},
query: () => read(props.query, internalQuery()),
setQuery: query => {
if (props.query === undefined) internalQuery(query)
props.onQueryChange?.(query)
},
open,
setOpen: value => {
open(value)
},
register: record => {
records([...records(), record])
return () => {
records(records().filter(item => item !== record))
}
},
matches: record => matchesQuery(read(props.query, internalQuery()), record),
hasMatches: () =>
records().some(record => matchesQuery(read(props.query, internalQuery()), record)),
}
const {
class: className,
children,
value,
defaultValue,
onValueChange,
query,
defaultQuery,
onQueryChange,
...rest
} = props
return (
<CommandContext.Provider value={contextValue}>
<div data-slot='command' class={cn('flex flex-col overflow-hidden bg-popover text-popover-foreground', className)} {...rest}>
{children}
</div>
</CommandContext.Provider>
)
}
export function CommandDialog(props: GenericProps) {
const context = useCommand()
const { class: className, onKeyDown, ...rest } = props
return () => context.open() ? (
<div
role='dialog'
aria-modal='true'
data-slot='command-dialog'
class={cn('overflow-hidden rounded-lg border bg-popover shadow-md', className)}
onKeyDown={(event: KeyboardEvent) => {
;(onKeyDown as ((event: KeyboardEvent) => void) | undefined)?.(event)
if (!event.defaultPrevented && event.key === 'Escape') context.setOpen(false)
}}
{...rest}
/>
) : null
}
export function CommandTrigger(props: GenericProps) {
const context = useCommand()
const { onClick, ...rest } = props
return (
<button
type='button'
aria-haspopup='dialog'
aria-expanded={() => context.open()}
onClick={(event: MouseEvent) => {
;(onClick as ((event: MouseEvent) => void) | undefined)?.(event)
if (!event.defaultPrevented) context.setOpen(true)
}}
{...rest}
/>
)
}
export function CommandClose(props: GenericProps) {
const context = useCommand()
const { onClick, ...rest } = props
return (
<button
type='button'
onClick={(event: MouseEvent) => {
;(onClick as ((event: MouseEvent) => void) | undefined)?.(event)
if (!event.defaultPrevented) context.setOpen(false)
}}
{...rest}
/>
)
}
export function CommandInput(props: GenericProps) {
const context = useCommand()
const { class: className, onInput, ...rest } = props
return (
<input
type='text'
role='combobox'
aria-autocomplete='list'
value={() => context.query()}
data-slot='command-input'
class={cn('flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground', className)}
onInput={(event: Event) => {
;(onInput as ((event: Event) => void) | undefined)?.(event)
if (!event.defaultPrevented) context.setQuery((event.currentTarget as HTMLInputElement).value)
}}
{...rest}
/>
)
}
export function CommandList(props: GenericProps) {
const { class: className, ...rest } = props
return <div role='listbox' data-slot='command-list' class={cn('max-h-80 overflow-y-auto p-1', className)} {...rest} />
}
export function CommandItem(props: CommandItemProps) {
const context = useCommand()
const { class: className, children, value = String(children ?? ''), keywords = [], keepOpen, onSelect, onClick, ...rest } = props
const record: CommandRecord = {
value,
text: typeof children === 'string' ? children : value,
keywords,
}
let unregister: (() => void) | undefined
onMount(() => {
unregister = context.register(record)
})
onDestroy(() => unregister?.())
return () =>
context.matches(record) ? (
<button
type='button'
role='option'
aria-selected={() => context.value() === value}
data-slot='command-item'
data-state={() => (context.value() === value ? 'selected' : 'idle')}
class={cn('flex w-full cursor-default items-center rounded-sm px-2 py-1.5 text-left text-sm outline-none data-[state=selected]:bg-accent data-[state=selected]:text-accent-foreground', className)}
onClick={(event: MouseEvent) => {
;(onClick as ((event: MouseEvent) => void) | undefined)?.(event)
onSelect?.(value, event)
if (event.defaultPrevented) return
context.setValue(value)
if (!keepOpen) context.setOpen(false)
}}
{...rest}
>
{children}
</button>
) : null
}
export function CommandGroup(props: GenericProps) {
const { class: className, children, heading, ...rest } = props
return (
<div role='group' data-slot='command-group' class={cn('overflow-hidden p-1 text-foreground', className)} {...rest}>
{heading ? <div data-slot='command-group-heading' class='px-2 py-1.5 text-xs font-medium text-muted-foreground'>{heading}</div> : null}
{children}
</div>
)
}
export function CommandSeparator(props: GenericProps) {
const { class: className, ...rest } = props
return <div role='separator' data-slot='command-separator' class={cn('my-1 h-px bg-border', className)} {...rest} />
}
export function CommandEmpty(props: GenericProps) {
const context = useCommand()
const { class: className, ...rest } = props
return () =>
context.hasMatches() ? null : (
<div data-slot='command-empty' class={cn('py-6 text-center text-sm text-muted-foreground', className)} {...rest} />
)
}
export function CommandContent(props: GenericProps) {
const { class: className, ...rest } = props
return <div data-slot='command-content' class={cn('overflow-hidden rounded-lg border bg-popover shadow-md', className)} {...rest} />
}
Usage
Examples
Basic
Suggestions
No results found.
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger } from '@/components/ui/command'
...import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger } from '@/components/ui/command'
export default function CommandBasicExample() {
return (
<Command>
<CommandTrigger class="rounded-md border px-4 py-2">Open Menu</CommandTrigger>
<CommandDialog aria-label="Command Palette">
<CommandInput placeholder="Type a command or search..." />
<CommandList><CommandEmpty>No results found.</CommandEmpty><CommandGroup heading="Suggestions"><CommandItem value="calendar">Calendar</CommandItem><CommandItem value="emoji">Search Emoji</CommandItem><CommandItem value="calculator">Calculator</CommandItem></CommandGroup></CommandList>
</CommandDialog>
</Command>
)
}
Shortcuts
Settings
No results found.
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger } from '@/components/ui/command'
...import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger } from '@/components/ui/command'
export default function CommandShortcutsExample() {
return (
<Command>
<CommandTrigger class="rounded-md border px-4 py-2">Open Menu</CommandTrigger>
<CommandDialog aria-label="Command Palette"><CommandInput placeholder="Type a command or search..." /><CommandList><CommandEmpty>No results found.</CommandEmpty><CommandGroup heading="Settings"><CommandItem value="profile">Profile <span class="ml-auto">⌘P</span></CommandItem><CommandItem value="billing">Billing <span class="ml-auto">⌘B</span></CommandItem><CommandItem value="settings">Settings <span class="ml-auto">⌘S</span></CommandItem></CommandGroup></CommandList></CommandDialog>
</Command>
)
}
Groups
Suggestions
Settings
No results found.
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandTrigger } from '@/components/ui/command'
...import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandTrigger } from '@/components/ui/command'
export default function CommandGroupsExample() {
return (
<Command>
<CommandTrigger class="rounded-md border px-4 py-2">Open Menu</CommandTrigger>
<CommandDialog aria-label="Command Palette"><CommandInput placeholder="Type a command or search..." /><CommandList><CommandEmpty>No results found.</CommandEmpty><CommandGroup heading="Suggestions"><CommandItem value="calendar">Calendar</CommandItem><CommandItem value="emoji">Search Emoji</CommandItem><CommandItem value="calculator">Calculator</CommandItem></CommandGroup><CommandSeparator /><CommandGroup heading="Settings"><CommandItem value="profile">Profile <span class="ml-auto">⌘P</span></CommandItem><CommandItem value="billing">Billing <span class="ml-auto">⌘B</span></CommandItem><CommandItem value="settings">Settings <span class="ml-auto">⌘S</span></CommandItem></CommandGroup></CommandList></CommandDialog>
</Command>
)
}
Scrollable
Navigation
Actions
View
Account
Tools
No results found.
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger } from '@/components/ui/command'
...import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandTrigger } from '@/components/ui/command'
const groups = [
['Navigation', ['Home', 'Inbox', 'Documents', 'Folders']],
['Actions', ['New File', 'New Folder', 'Copy', 'Cut', 'Paste', 'Delete']],
['View', ['Grid View', 'List View', 'Zoom In', 'Zoom Out']],
['Account', ['Profile', 'Billing', 'Settings', 'Notifications', 'Help & Support']],
['Tools', ['Calculator', 'Calendar', 'Image Editor', 'Code Editor']],
] as const
export default function CommandScrollableExample() {
return (
<Command>
<CommandTrigger class="rounded-md border px-4 py-2">Open Menu</CommandTrigger>
<CommandDialog aria-label="Command Palette"><CommandInput placeholder="Type a command or search..." /><CommandList class="max-h-72"><CommandEmpty>No results found.</CommandEmpty>{groups.map(([heading, items]) => <CommandGroup heading={heading}>{items.map(item => <CommandItem value={item.toLowerCase().replaceAll(' ', '-')}>{item}</CommandItem>)}</CommandGroup>)}</CommandList></CommandDialog>
</Command>
)
}
Rtl
اقتراحات
الإعدادات
لم يتم العثور على نتائج.
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'
...import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'
const translations = {
ar: { dir: 'rtl', placeholder: 'اكتب أمرًا أو ابحث...', empty: 'لم يتم العثور على نتائج.', suggestions: 'اقتراحات', settings: 'الإعدادات', items: ['التقويم', 'البحث عن الرموز التعبيرية', 'الآلة الحاسبة', 'الملف الشخصي', 'الفوترة', 'الإعدادات'] },
he: { dir: 'rtl', placeholder: 'הקלד פקודה או חפש...', empty: 'לא נמצאו תוצאות.', suggestions: 'הצעות', settings: 'הגדרות', items: ['לוח שנה', "חפש אמוג'י", 'מחשבון', 'פרופיל', 'חיוב', 'הגדרות'] },
en: { dir: 'ltr', placeholder: 'Type a command or search...', empty: 'No results found.', suggestions: 'Suggestions', settings: 'Settings', items: ['Calendar', 'Search Emoji', 'Calculator', 'Profile', 'Billing', 'Settings'] },
} as const
export default function CommandRtlExample() {
let language = $state<keyof typeof translations>('ar')
const t = translations[language]
return (
<div>
<select aria-label="Preview language" value={language} onChange={event => language = event.currentTarget.value as keyof typeof translations}><option value="ar">Arabic (العربية)</option><option value="he">Hebrew (עברית)</option><option value="en">English</option></select>
<Command dir={t.dir} class="w-96 rounded-lg border"><CommandInput placeholder={t.placeholder} /><CommandList><CommandEmpty>{t.empty}</CommandEmpty><CommandGroup heading={t.suggestions}>{t.items.slice(0, 3).map((item, index) => <CommandItem value={String(index)} disabled={index === 2}>{item}</CommandItem>)}</CommandGroup><CommandSeparator /><CommandGroup heading={t.settings}>{t.items.slice(3).map((item, index) => <CommandItem value={String(index + 3)}>{item}</CommandItem>)}</CommandGroup></CommandList></Command>
</div>
)
}