Install

npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/command.json

Dependencies

No required npm packages — this item installs through the registry dependencies below.

https://reactnativereusables.com/r/nativewind/icon.json
https://rnraielements.com/r/nativewind/sheet.json
https://rnraielements.com/r/nativewind/input-group.json
https://rnraielements.com/r/nativewind/item.json
https://rnraielements.com/r/nativewind/empty.json
https://reactnativereusables.com/r/nativewind/text.json

AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92

Source

command.tsx
import { Empty, EmptyDescription, EmptyIcon, EmptyTitle } from '@/registry/nativewind/components/ui/empty'; import { Icon } from '@/registry/nativewind/components/ui/icon'; import { InputGroup, InputGroupAddon, InputGroupInput, } from '@/registry/nativewind/components/ui/input-group'; import { Item, ItemContent, ItemDescription, ItemTitle } from '@/registry/nativewind/components/ui/item'; import { Sheet, SheetContent, SheetTitle, useSheetPortalHost } from '@/registry/nativewind/components/ui/sheet'; import { Text } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import { SearchIcon, SearchXIcon } from 'lucide-react-native'; import * as React from 'react'; import { FlatList, View } from 'react-native'; import { buildCommandRows, filterCommandItems, type CommandItem, type CommandRow, } from './command.logic'; /** * Command — the searchable picker. * * cmdk is DOM- and keyboard-first and has no mobile analogue: there is no ⌘K, no arrow-key * navigation, and no hover highlight. On a phone the command palette IS A BOTTOM SHEET * with a filter field and a virtualized list, so that is what this is. * * Backs model-selector, voice-selector and mic-selector. * * VIRTUALIZED — a FlatList, not a mapped ScrollView. A voice picker can carry hundreds of * entries and this is the surface where that shows. * * GROUPING — CommandItem carries an optional `group`; buildCommandRows (command.logic) * interleaves one heading per group, in first-appearance order, after the filter runs * (a group whose items all fail the filter renders no header). * * CUSTOM ROWS — `renderItem` replaces the default item-atom row for palettes whose rows * carry more than title + description (voice-selector's two-target preview row). It * receives the item and the selection state; the default row is what mic-selector uses. * * TITLE — an optional SheetTitle. A dialog with no title is an a11y hole, and * voice-selector's upstream contract ships one ("Select AI Voice"). * * Anything overlay-shaped opened from inside must use the sheet's own portal host, or it * renders behind the sheet. `useCommandPortalHost()` re-exports it for that. */ export type { CommandItem }; type CommandProps = { open: boolean; onOpenChange: (open: boolean) => void; items: readonly CommandItem[]; value?: string; onSelect: (value: string) => void; placeholder?: string; emptyTitle?: string; emptyDescription?: string; /** Sheet title — announced by screen readers; renders as the palette's heading row. */ title?: string; className?: string; /** Replace the default item-atom row. Receives the item and its selection state. */ renderItem?: (info: { item: CommandItem; selected: boolean }) => React.ReactNode; /** * FlatList extraData — pass any state your renderItem reads from outside `items` * (voice-selector's previewing/previewLoading ids). Without it the list would keep * stale rows while the sheet is open. */ extraData?: unknown; children?: React.ReactNode; }; /** The sheet's portal host — pass to any overlay nested inside the palette. */ const useCommandPortalHost = useSheetPortalHost; function Command({ open, onOpenChange, items, value, onSelect, placeholder = 'Search…', emptyTitle = 'No matches', emptyDescription = 'Try a different search.', title, className, renderItem, extraData, children, }: CommandProps) { const [query, setQuery] = React.useState(''); const filtered = React.useMemo( () => filterCommandItems(items, query), [items, query], ); const rows = React.useMemo(() => buildCommandRows(filtered, value), [filtered, value]); // Reset the filter when the palette closes, so reopening is not mid-search. React.useEffect(() => { if (!open) setQuery(''); }, [open]); function renderRow({ item }: { item: CommandRow }): React.ReactElement | null { if (item.kind === 'header') { return ( <Text accessibilityRole="header" className="px-3 pb-1 pt-3 text-xs font-medium uppercase text-muted-foreground" > {item.label} </Text> ); } if (renderItem) { // The public contract is ReactNode (lenient for consumers); the list needs an // element or null, so a bare string/number render collapses here. return renderItem({ item: item.item, selected: item.selected }) as React.ReactElement | null; } return ( <Item variant={item.selected ? 'muted' : 'default'} onPress={() => { onSelect(item.item.value); onOpenChange(false); }} > <ItemContent> <ItemTitle>{item.item.label}</ItemTitle> {item.item.description ? ( <ItemDescription>{item.item.description}</ItemDescription> ) : null} </ItemContent> </Item> ); } return ( <Sheet open={open} onOpenChange={onOpenChange}> {children} <SheetContent side="bottom" className={cn('h-3/4', className)}> {title ? ( <SheetTitle className="text-base font-semibold text-foreground"> {title} </SheetTitle> ) : null} <InputGroup> <InputGroupAddon> <Icon as={SearchIcon} size={16} className="text-muted-foreground" /> </InputGroupAddon> <InputGroupInput placeholder={placeholder} value={query} onChangeText={setQuery} // The web selectors' search row (`h-auto py-3.5` — model-selector.tsx:70, // voice-selector.tsx:141 CommandInput): h-auto drops the input-group // atom's fixed h-10 and py-3.5 replaces its py-2 — both halves of the // override are needed, padding alone in a fixed-height field clips text. // Substrates only, other InputGroupInput consumers keep the atom default. className="h-auto py-3.5" // Do NOT autofocus: on mobile that slams the keyboard up over the list the // user came to read. autoFocus={false} accessibilityLabel={placeholder} /> </InputGroup> {filtered.length === 0 ? ( <Empty className="py-10"> <EmptyIcon as={SearchXIcon} /> <EmptyTitle>{emptyTitle}</EmptyTitle> <EmptyDescription>{emptyDescription}</EmptyDescription> </Empty> ) : ( <FlatList data={rows} keyExtractor={(r: CommandRow) => r.key} renderItem={renderRow} extraData={extraData} keyboardShouldPersistTaps="handled" contentContainerClassName="gap-1 pb-4" /> )} </SheetContent> </Sheet> ); } /** Optional trailing slot inside the palette, above the list. */ function CommandFooter({ className, ...props }: React.ComponentProps<typeof View>) { return <View className={cn('flex-row items-center gap-2 pt-2', className)} {...props} />; } export { Command, CommandFooter, useCommandPortalHost };
command.logic.ts
/** * Command — pure logic. Zero react-native imports, so the Vitest tier owns it (see the * header of sources.logic.ts for why component files themselves cannot load under * Node). * * Extracted when model-selector needed provider grouping and voice-selector needed * custom rows: the palette's filter and its flat-item → render-rows transform are the * two behaviors those selectors build on, and both are data-in/data-out. The component * file consumes them; tests pin them. */ /** One selectable entry in the palette. `group` names the section heading it sits under. */ export type CommandItem = { value: string; label: string; description?: string; /** Extra match terms beyond label and description — model-selector puts the provider here. */ keywords?: string; /** Section heading. Ungrouped items render without a header, in list order. */ group?: string; }; /** A row of the palette's list: either a section heading or a selectable item. */ export type CommandRow = | { kind: 'header'; key: string; label: string } | { kind: 'item'; key: string; item: CommandItem; selected: boolean }; /** * The palette filter — the web original's cmdk match (case-insensitive substring * across label, description and keywords). A blank query matches everything; a * whitespace query is blank. Order is preserved — the caller's list order IS the * display order. */ export function filterCommandItems<T extends CommandItem>( items: readonly T[], query: string, ): T[] { const q = query.trim().toLowerCase(); if (!q) return [...items]; return items.filter((i) => `${i.label} ${i.description ?? ''} ${i.keywords ?? ''}`.toLowerCase().includes(q), ); } /** * Interleave section headings into the flat item list: a group's heading renders the * first time one of its items appears, so groups keep the caller's first-appearance * order and interleaved groups each get exactly one header. Runs AFTER filtering, so * a group whose items all failed the filter renders no header at all. * * `selected` is resolved here, not at render time, so the memoized row array changes * identity when the selection changes — the FlatList's re-render trigger. */ export function buildCommandRows( items: readonly CommandItem[], value?: string, ): CommandRow[] { const rows: CommandRow[] = []; const seen = new Set<string>(); for (const item of items) { const group = item.group?.trim(); if (group && !seen.has(group)) { seen.add(group); rows.push({ kind: 'header', key: `header:${group}`, label: group }); } rows.push({ kind: 'item', key: item.value, item, selected: item.value === value }); } return rows; }

Theming

Themed via text-muted-foreground, text-foreground.

Usage

() => ( <Command open={false} onOpenChange={() => {}} value="conversation" onSelect={() => {}} placeholder="Search the registry…" items={[ { value: 'conversation', label: 'Conversation', description: 'Virtualized transcript', group: 'Components' }, { value: 'prompt-input', label: 'Prompt Input', description: 'The composer', group: 'Components' }, { value: 'button', label: 'Button', group: 'UI primitives' }, ]} /> )