Command
The searchable picker. cmdk is DOM/keyboard-first with no mobile analogue, so on a phone the palette IS a bottom sheet with a filter and a virtualized list.
- v0.2.1
- ui
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/command.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/command.jsonDependencies
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 |
https://reactnativereusables.com/r/uniwind/icon.json |
https://rnraielements.com/r/uniwind/sheet.json |
https://rnraielements.com/r/uniwind/input-group.json |
https://rnraielements.com/r/uniwind/item.json |
https://rnraielements.com/r/uniwind/empty.json |
https://reactnativereusables.com/r/uniwind/text.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
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 — 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' },
]}
/>
)