Install

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

Dependencies

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

https://reactnativereusables.com/r/nativewind/button.json
https://reactnativereusables.com/r/nativewind/icon.json
https://reactnativereusables.com/r/nativewind/text.json
https://rnraielements.com/r/nativewind/command.json
https://rnraielements.com/r/nativewind/item.json

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

Source

mic-selector.tsx
import { Button } from '@/registry/nativewind/components/ui/button'; import { Command } from '@/registry/nativewind/components/ui/command'; import { Icon } from '@/registry/nativewind/components/ui/icon'; import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/registry/nativewind/components/ui/item'; import { Text } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import { AudioLinesIcon, BluetoothIcon, ChevronsUpDownIcon, HeadphonesIcon, MicIcon, type LucideIcon, } from 'lucide-react-native'; import * as React from 'react'; import { findMicRoute, orderMicRoutes, type MicRoute, type MicRouteKind, } from './mic-selector.logic'; import type { CommandItem } from '../ui/command.logic'; /** * MicSelector — the audio input route picker (UC-VOICE-01 AC-3: "choose an audio * input route such as built-in microphone, wired headset, or Bluetooth from a native * route picker"). * * THE PRD VERDICT IS NATIVE-SUBSTITUTE, PURE-RNR: "Web enumerates input devices * through the media devices API. iOS and Android do not expose an enumerable input * device list; they expose audio routes. Ships as a route picker (built-in, wired, * Bluetooth) with the same product job." The substitution lands in the CONTRACT, not * the component: the route list is CALLER-SUPPLIED, because enumeration is the one * part with no honest registry implementation — AVAudioSession inputs on iOS and * Android's audio routing live behind platform APIs (and outside Expo Go), and * guessing routes would ship a lie. The web's useAudioDevices hook (getUserMedia) * does NOT port; there is no hook here. * * PERMISSION IS THE CALLER'S TOO (speech-input's framing). The web original requests * microphone permission on open and shows an empty list until it is granted; here an * empty route list renders the empty state, and `emptyTitle`/`emptyDescription` exist * so a consumer can say "Microphone access denied" when that is WHY the list is * empty. The component cannot know why its list is empty, and pretending otherwise * would be the lie. * * THE SURFACE IS THE COMMAND ATOM per the build plan ("command backs model-selector, * voice-selector and mic-selector") — the same sheet+filter+list muscle memory as the * other two selectors, with the filter carrying route labels and kinds. The web's * Input/List/Item/Empty parts are absorbed into that substrate; MicSelectorLabel * (Chrome's "(XXXX:XXXX)" hardware-ID stripper) is dead code natively per the KB. * * Composition: * <MicSelector routes={routes} value={value} onValueChange={setValue}> * <MicSelectorTrigger /> * </MicSelector> */ /** The route-kind → mark map. Kind is identity, never the sole channel — the label carries it too. */ const routeKindIcons: Record<MicRouteKind, LucideIcon> = { 'built-in': MicIcon, wired: HeadphonesIcon, bluetooth: BluetoothIcon, other: AudioLinesIcon, }; type MicSelectorContextValue = { routes: readonly MicRoute[]; value: string | undefined; open: boolean; setOpen: (open: boolean) => void; }; const MicSelectorContext = React.createContext<MicSelectorContextValue | null>(null); function useMicSelectorContext(): MicSelectorContextValue { const ctx = React.useContext(MicSelectorContext); if (!ctx) throw new Error('MicSelector components must be used within MicSelector'); return ctx; } type MicSelectorProps = { /** The routes YOUR platform exposes — the registry cannot enumerate them for you. */ routes: readonly MicRoute[]; value: string | undefined; onValueChange: (value: string) => void; open?: boolean; onOpenChange?: (open: boolean) => void; placeholder?: string; emptyTitle?: string; /** Say WHY the list is empty when you know — "Microphone access denied", not "None". */ emptyDescription?: string; /** The sheet's screen-reader title. Defaults to "Choose an input route". */ title?: string; /** Passed to the sheet content — height and padding adjustments. */ className?: string; children?: React.ReactNode; }; function MicSelector({ routes, value, onValueChange, open: openProp, onOpenChange, placeholder = 'Filter routes…', emptyTitle = 'No input routes', emptyDescription = 'No audio input routes are available right now.', title = 'Choose an input route', className, children, }: MicSelectorProps) { const [internalOpen, setInternalOpen] = React.useState(false); const open = openProp ?? internalOpen; const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setInternalOpen(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); // Built-in first, wired, bluetooth, other — caller order kept within a kind. const ordered = React.useMemo(() => orderMicRoutes(routes), [routes]); const items = React.useMemo( () => ordered.map((r) => ({ value: r.id, label: r.label, description: r.kind && r.kind !== 'other' ? r.kind : undefined, keywords: r.kind, group: undefined, })), [ordered], ); const contextValue = React.useMemo<MicSelectorContextValue>( () => ({ routes, value, open, setOpen }), [routes, value, open, setOpen], ); function renderRow({ item, selected }: { item: CommandItem; selected: boolean }) { // kind rides `keywords` (set when the rows were built); 'other' and absent fall // to the generic mark. The label still carries the kind — the icon is never the // sole channel. const kind = (routeKindIcons as Record<string, LucideIcon>)[item.keywords ?? ''] ? (item.keywords as MicRouteKind) : 'other'; return ( <Item variant={selected ? 'muted' : 'default'} onPress={() => { onValueChange(item.value); setOpen(false); }} > <ItemMedia> <Icon as={routeKindIcons[kind]} size={16} className="text-muted-foreground" /> </ItemMedia> <ItemContent> <ItemTitle>{item.label}</ItemTitle> {/* The kind is ANNOUNCED, not just iconized (review M2) — the icon alone can't tell a screen reader "Car Stereo" is a Bluetooth route. */} <ItemDescription>{item.description}</ItemDescription> </ItemContent> </Item> ); } return ( <MicSelectorContext.Provider value={contextValue}> <Command open={open} onOpenChange={setOpen} items={items} value={value} onSelect={(next) => { onValueChange(next); setOpen(false); }} renderItem={renderRow} placeholder={placeholder} emptyTitle={emptyTitle} emptyDescription={emptyDescription} title={title} className={className} > {children} </Command> </MicSelectorContext.Provider> ); } type MicSelectorTriggerProps = { /** Overrides the default outline button (web: children on the trigger). */ children?: React.ReactNode; className?: string; }; function MicSelectorTrigger({ children, className }: MicSelectorTriggerProps) { const { routes, value, setOpen, open } = useMicSelectorContext(); const selected = findMicRoute(routes, value); const label = selected?.label ?? 'Input'; return ( children ?? ( <Button variant="outline" onPress={() => setOpen(!open)} accessibilityLabel={`Choose input route, current: ${label}`} accessibilityState={{ expanded: open }} className={cn('justify-between gap-2', className)} > <Icon as={MicIcon} size={16} className="text-muted-foreground" /> <Text numberOfLines={1} className="flex-1 text-left"> {label} </Text> {/* The web trigger's up-down mark (mic-selector.tsx:18,241), aligned with the model/voice triggers' affordance. */} <Icon as={ChevronsUpDownIcon} size={16} className="shrink-0 text-muted-foreground" /> </Button> ) ); } type MicSelectorValueProps = { /** The placeholder when nothing is selected — the web part's own fallback text. */ placeholder?: string; className?: string; }; /** * The selected route's label, for consumers composing a custom trigger — the web * part-set's MicSelectorValue. Renders the placeholder when the value matches * nothing, because a disconnected route should read as unselected, not as a lie. */ function MicSelectorValue({ placeholder = 'Input', className }: MicSelectorValueProps) { const { routes, value } = useMicSelectorContext(); const selected = findMicRoute(routes, value); return ( <Text numberOfLines={1} className={className}> {selected?.label ?? placeholder} </Text> ); } export { MicSelector, MicSelectorTrigger, MicSelectorValue, routeKindIcons }; export type { MicRoute, MicRouteKind };
mic-selector.logic.ts
/** * MicSelector — 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). * * The web original lists devices from navigator.mediaDevices.enumerateDevices() — an * API React Native does not have, and the PRD verdict (native-substitute) is explicit * that iOS and Android expose audio ROUTES, not an enumerable input device list. So * the list here is caller-supplied, and the only logic this file owns is the route * vocabulary and the display order of a list the caller built. */ /** The route kinds the mobile OS actually exposes (the PRD verdict's own list, plus a catch-all). */ export const MIC_ROUTE_KINDS = ['built-in', 'wired', 'bluetooth', 'other'] as const; export type MicRouteKind = (typeof MIC_ROUTE_KINDS)[number]; /** One audio input route the caller enumerated on their platform. */ export type MicRoute = { id: string; label: string; /** Optional — an unknown or unspecified kind falls to 'other' for ordering and icon. */ kind?: MicRouteKind; }; /** Display rank: built-in first (the sane default), then wired, bluetooth, other. */ const KIND_RANK: Record<MicRouteKind, number> = { 'built-in': 0, wired: 1, bluetooth: 2, other: 3, }; function rankOf(route: MicRoute): number { const kind = route.kind ?? 'other'; return KIND_RANK[kind] ?? KIND_RANK.other; } /** * Deterministic display order for a list the caller supplied: stable-sorted by kind * rank, caller order preserved within a kind (Array.prototype.sort is stable per * spec). Never throws on a malformed entry — an unsortable route lands with 'other'. */ export function orderMicRoutes(routes: readonly MicRoute[]): MicRoute[] { return [...routes].sort((a, b) => rankOf(a) - rankOf(b)); } /** * The trigger's label: the selected route's label, the caller's fallback (or "Input") * when the value matches nothing. No-throw — a stale route id degrades to the * placeholder, the way a disconnected device should read. */ export function findMicRoute( routes: readonly MicRoute[], value: string | undefined, ): MicRoute | undefined { if (!value) return undefined; return routes.find((r) => r.id === value); }
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; }

Props

NameTypeDefaultDescription
routesreadonly MicRoute[]—The routes YOUR platform exposes — the registry cannot enumerate them for you.
valuestring | undefined——
onValueChange(value: string) => void——
openboolean | undefined——
onOpenChange((open: boolean) => void) | undefined——
placeholderstring | undefined'Filter routes…'—
emptyTitlestring | undefined'No input routes'—
emptyDescriptionstring | undefined'No audio input routes are available right now.'Say WHY the list is empty when you know — "Microphone access denied", not "None".
titlestring | undefined'Choose an input route'The sheet's screen-reader title. Defaults to "Choose an input route".
classNamestring | undefined—Passed to the sheet content — height and padding adjustments.
childrenReact.ReactNode——

Theming

Themed via text-muted-foreground.

Usage

() => ( <MicSelector routes={[ { id: 'builtin', label: 'MacBook Pro Microphone', kind: 'builtin' }, { id: 'airpods', label: 'Justin’s AirPods Pro', kind: 'bluetooth' }, { id: 'studio', label: 'Mac mini Studio Display', kind: 'other' }, ]} value="airpods" onValueChange={() => {}} > <MicSelectorTrigger /> <MicSelectorValue placeholder="Pick an input…" /> </MicSelector> )