Mic Selector
The audio input route picker (UC-VOICE-01 AC-3). Native-substitute per the PRD verdict: iOS and Android expose audio routes, not an enumerable input device list, so the route list is CALLER-SUPPLIED — the web's useAudioDevices (getUserMedia) does not port, and an empty list renders an honest empty state the consumer can point at permission denial. Composed on the command atom with the route-kind marks (built-in, wired, bluetooth, other); routes order built-in first, caller order preserved within a kind.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/mic-selector.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/mic-selector.jsonDependencies
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 |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/command.json |
https://rnraielements.com/r/uniwind/item.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
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 };
/**
* 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 — 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
| Name | Type | Default | Description |
|---|---|---|---|
| routes | readonly MicRoute[] | — | The routes YOUR platform exposes — the registry cannot enumerate them for you. |
| value | string | undefined | — | — |
| onValueChange | (value: string) => void | — | — |
| open | boolean | undefined | — | — |
| onOpenChange | ((open: boolean) => void) | undefined | — | — |
| placeholder | string | undefined | 'Filter routes…' | — |
| emptyTitle | string | undefined | 'No input routes' | — |
| emptyDescription | string | undefined | 'No audio input routes are available right now.' | Say WHY the list is empty when you know — "Microphone access denied", not "None". |
| title | string | undefined | 'Choose an input route' | The sheet's screen-reader title. Defaults to "Choose an input route". |
| className | string | undefined | — | Passed to the sheet content — height and padding adjustments. |
| children | React.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>
)