Install

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

Dependencies

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

voice-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, ItemActions, ItemContent, ItemDescription, ItemTitle, } from '@/registry/nativewind/components/ui/item'; import { Text } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import { AudioLinesIcon, ChevronsUpDownIcon, CircleSmallIcon, LoaderCircleIcon, MarsStrokeIcon, NonBinaryIcon, PauseIcon, PlayIcon, TransgenderIcon, VenusAndMarsIcon, VenusIcon, MarsIcon, type LucideIcon, } from 'lucide-react-native'; import * as React from 'react'; import { View, type ViewProps } from 'react-native'; import Animated, { cancelAnimation, Easing, ReduceMotion, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; import { resolveVoiceGender, voiceAccentFlag, voicePreviewState, type VoiceSelectorVoice, } from './voice-selector.logic'; import type { CommandItem } from '../ui/command.logic'; /** * VoiceSelector — the TTS voice picker (UC-VOICE-02 AC-2: "select a text-to-speech * voice from a bottom-sheet list showing each voice name and language"). * * THE PRD VERDICT IS PORT-ADAPTED, PURE-RNR: "Voice list ports, but the picker * becomes a bottom sheet and the available voice set comes from the native speech * synthesis provider rather than the browser." The set is therefore CALLER-SUPPLIED * (expo-speech's getVoices(), a provider roster) — the browser's voice enumeration * has no RN equivalent, and the registry takes no audio dependency. Preview playback * is a caller callback too: onPreview(voice) fires, the component only DISPLAYS the * state you report back. * * THE COMPOSITION LAW (the PRD's own words): "tapping the preview button PLAYS the * sample and must NOT select the voice. Two nested targets, two distinct outcomes." * Realized structurally: the preview Button lives in the row's ItemActions, and React * Native's responder system gives the touch to the DEEPEST pressable — the row's * onPress never fires when the preview is the target. The preview target is RNR's * h-10 icon Button + hitSlop (the 44pt house formula), visually separated in the * row's action slot per the KB's mistap advice. THE ROW-PRESS-DOES-NOT-FIRE HALF IS * A DEVICE-TIER CLAIM — verify it on the sign-off run. * * PREVIEW STATES are the web's three: idle (play mark, "Play preview"), playing * (pause mark, "Pause preview"), loading (disabled). Report them with * previewingId / previewLoadingId. With NO onPreview wired the control DISABLES * rather than disappears (speech-input: a control that pretends to work is the lie). * * THE UPSTREAM TRAPS, PRESERVED AT BYTE PARITY: * - useVoiceSelector() outside <VoiceSelector> throws * 'VoiceSelector components must be used within VoiceSelector'. * - The accent lookup is CASE-SENSITIVE — "AMERICAN" renders no flag (voice-selector.logic). * - Unknown genders fall back to a default mark rather than throwing. * - Type-to-filter with an explicit empty state (the command atom's). * * DROPPED ON THE RECORD: VoiceSelectorShortcut (⌘ hints — no ⌘ on a phone, PRD port * advice) and VoiceSelectorDialog (the ⌘K dialog variant — the bottom sheet IS the * presentation). Input/List/Item/Empty are absorbed into the command atom; rows are * data-driven because the list is a FlatList (the KB: "a voice picker can carry * hundreds of entries"). * * Composition: * <VoiceSelector voices={VOICES} value={v} onValueChange={setV} onPreview={play} * previewingId={playingId} previewLoadingId={loadingId}> * <VoiceSelectorTrigger /> * </VoiceSelector> */ // The web's full mark set (voice-selector.tsx:205-229), at the web's size-4 (16px); // omitted or unknown input falls to the default mark rather than throwing. const genderIcons: Record<string, LucideIcon> = { male: MarsIcon, female: VenusIcon, transgender: TransgenderIcon, androgyne: MarsStrokeIcon, 'non-binary': NonBinaryIcon, intersex: VenusAndMarsIcon, unknown: CircleSmallIcon, }; type VoiceSelectorContextValue = { value: string | undefined; setValue: (value: string) => void; open: boolean; setOpen: (open: boolean) => void; /** The roster, so the default trigger can resolve an id to its NAME. */ voices: readonly VoiceSelectorVoice[]; }; const VoiceSelectorContext = React.createContext<VoiceSelectorContextValue | null>(null); /** * The web hook, byte-shape ({ value, setValue, open, setOpen }) and byte-trap * (throws outside the provider). setValue notifies onValueChange WITHOUT closing — * the row press is what selects-and-closes, the hook is the programmatic seam. */ function useVoiceSelector(): VoiceSelectorContextValue { const ctx = React.useContext(VoiceSelectorContext); if (!ctx) { throw new Error('VoiceSelector components must be used within VoiceSelector'); } return ctx; } type VoiceSelectorProps = { /** The voices YOUR provider offers — native TTS or a hosted roster. */ voices: readonly VoiceSelectorVoice[]; value: string | undefined; onValueChange: (value: string) => void; /** Play this voice's sample. Absent → every preview control DISABLES, never fakes. */ onPreview?: (voice: VoiceSelectorVoice) => void; /** The voice whose sample is currently playing (pause mark on that row). */ previewingId?: string; /** The voice whose sample is loading (disabled control on that row). */ previewLoadingId?: string; open?: boolean; /** The web contract ships defaultOpen; the sheet opens closed without it. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; placeholder?: string; emptyTitle?: string; emptyDescription?: string; /** The sheet's screen-reader title — the web Content's `title` prop. */ title?: string; /** Passed to the sheet content — height and padding adjustments. */ className?: string; children?: React.ReactNode; }; function VoiceSelector({ voices, value, onValueChange, onPreview, previewingId, previewLoadingId, open: openProp, defaultOpen = false, onOpenChange, placeholder = 'Search voices…', emptyTitle = 'No voices found', emptyDescription = 'Try a different search.', title = 'Select AI Voice', className, children, }: VoiceSelectorProps) { const [internalOpen, setInternalOpen] = React.useState(defaultOpen); const open = openProp ?? internalOpen; const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setInternalOpen(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); const setValue = React.useCallback( (next: string) => { onValueChange(next); }, [onValueChange], ); const items = React.useMemo( () => voices.map((v) => ({ value: v.id, label: v.name, description: v.description, keywords: [v.accent, v.age, v.keywords].filter(Boolean).join(' ') || undefined, group: v.group, })), [voices], ); const voicesById = React.useMemo( () => new Map(voices.map((v) => [v.id, v])), [voices], ); function renderRow({ item, selected }: { item: CommandItem; selected: boolean }) { const voice = voicesById.get(item.value); if (!voice) return null; return ( <Item variant={selected ? 'muted' : 'default'} onPress={() => { onValueChange(voice.id); setOpen(false); }} > <ItemContent> <VoiceSelectorName>{voice.name}</VoiceSelectorName> <VoiceSelectorAttributes voice={voice} /> {voice.description ? ( <VoiceSelectorDescription>{voice.description}</VoiceSelectorDescription> ) : null} </ItemContent> <ItemActions> <VoiceSelectorPreview playing={voicePreviewState(voice.id, previewingId, previewLoadingId) === 'playing'} loading={voicePreviewState(voice.id, previewingId, previewLoadingId) === 'loading'} onPlay={onPreview ? () => onPreview(voice) : undefined} /> </ItemActions> </Item> ); } const contextValue = React.useMemo<VoiceSelectorContextValue>( () => ({ value, setValue, open, setOpen, voices }), [value, setValue, open, setOpen, voices], ); return ( <VoiceSelectorContext.Provider value={contextValue}> <Command open={open} onOpenChange={setOpen} items={items} value={value} onSelect={(next) => { onValueChange(next); setOpen(false); }} renderItem={renderRow} // The rows read previewingId/previewLoadingId from the closure — without // extraData the FlatList would keep stale rows while the sheet is open. extraData={[previewingId, previewLoadingId]} placeholder={placeholder} emptyTitle={emptyTitle} emptyDescription={emptyDescription} title={title} className={className} > {children} </Command> </VoiceSelectorContext.Provider> ); } type VoiceSelectorTriggerProps = { /** Overrides the default outline button (web: children on the trigger). */ children?: React.ReactNode; className?: string; }; function VoiceSelectorTrigger({ children, className }: VoiceSelectorTriggerProps) { const { value, open, setOpen, voices } = useVoiceSelector(); // The trigger reads the NAME, never the raw id (findMicRoute/resolveModelLabel // precedent — review M1: an id like 'voice-9f2a' is not a human label). const label = value === undefined ? 'Select voice' : (voices.find((v) => v.id === value)?.name ?? value); return ( children ?? ( <Button variant="outline" onPress={() => setOpen(!open)} accessibilityLabel={`Select voice, current: ${label}`} accessibilityState={{ expanded: open }} className={cn('justify-between gap-2', className)} > <Icon as={AudioLinesIcon} size={16} className="text-muted-foreground" /> <Text numberOfLines={1} className="flex-1 text-left"> {label} </Text> <Icon as={ChevronsUpDownIcon} size={16} className="shrink-0 text-muted-foreground" /> </Button> ) ); } type VoiceSelectorNameProps = { children: string; className?: string; }; /** The voice's display name — the web part, one Text. */ function VoiceSelectorName({ children, className }: VoiceSelectorNameProps) { return <ItemTitle className={className}>{children}</ItemTitle>; } type VoiceSelectorDescriptionProps = { children: string; className?: string; }; /** The voice's optional second line — the web part, one Text. */ function VoiceSelectorDescription({ children, className }: VoiceSelectorDescriptionProps) { return <ItemDescription className={className}>{children}</ItemDescription>; } type VoiceSelectorAttributesProps = { voice: VoiceSelectorVoice; className?: string; }; /** * The gender/accent/age line. Renders nothing when the voice carries no attribute * fields at all — the web composes the Attributes part per item, so a voice with no * attributes shows no line, not an empty one. Unknown accents leave a GAP (upstream's * empty span), never a placeholder glyph. */ function VoiceSelectorAttributes({ voice, className }: VoiceSelectorAttributesProps) { const flag = voiceAccentFlag(voice.accent); const age = voice.age?.trim(); const hasAny = Boolean(voice.gender || flag || age); if (!hasAny) return null; const trailing = [flag, age].filter((part): part is string => Boolean(part)).join(' • '); return ( <View className={cn('flex-row items-center gap-1', className)}> <VoiceSelectorGender value={voice.gender} /> {trailing ? ( <Text numberOfLines={1} className="text-xs text-muted-foreground tabular-nums"> {trailing} </Text> ) : null} </View> ); } type VoiceSelectorGenderProps = { /** One of VOICE_GENDERS — anything else renders the default mark. */ value?: string; className?: string; }; /** * The gender mark — the web's full set (male/female/transgender/androgyne/non-binary/ * intersex) at size-4/16px, default CircleSmallIcon for omitted or unknown values * (voice-selector.tsx:205-229). */ function VoiceSelectorGender({ value, className }: VoiceSelectorGenderProps) { const gender = resolveVoiceGender(value); return ( <View accessible accessibilityLabel={`Voice gender: ${gender}`}> <Icon as={genderIcons[gender]} size={16} className={cn('shrink-0 text-muted-foreground', className)} /> </View> ); } type VoiceSelectorAccentProps = { /** One of the 30 lowercase accent keys — CASE-SENSITIVE; unknown renders NOTHING. */ value?: string; className?: string; }; /** * The accent flag, upstream byte-behavior: exactly-lowercase keys render their flag * emoji; anything else renders nothing (the web's empty span — no placeholder). */ function VoiceSelectorAccent({ value, className }: VoiceSelectorAccentProps) { const flag = voiceAccentFlag(value); if (!flag) return null; return ( <Text className={cn('text-xs text-muted-foreground', className)} accessibilityLabel={value}> {flag} </Text> ); } type VoiceSelectorAgeProps = { children?: string; className?: string; }; /** The free-text age band ("40-50") — the web part, `tabular-nums` on (:423). */ function VoiceSelectorAge({ children, className }: VoiceSelectorAgeProps) { if (!children) return null; return <Text className={cn('text-xs text-muted-foreground tabular-nums', className)}>{children}</Text>; } type VoiceSelectorBulletProps = ViewProps; /** * The attributes separator — the web's `•` span (aria-hidden, text-border; * voice-selector.tsx:461-472). RNR Text has no cascade, so the web's inherited * text-xs is stated explicitly. */ function VoiceSelectorBullet({ className, ...props }: VoiceSelectorBulletProps) { return ( <Text aria-hidden className={cn('text-xs text-border', className)} {...props}> • </Text> ); } type VoiceSelectorPreviewProps = { playing?: boolean; loading?: boolean; /** Play this row's sample. Absent → the control DISABLES, never disappears. */ onPlay?: () => void; className?: string; }; /** * The loading mark, spinning: the web's <Spinner> (voice-selector.tsx:505) as a * Reanimated rotate — the PulseRing idiom (speech-input.tsx): withRepeat -1, * ReduceMotion.System, cancelAnimation cleanup. Under the OS reduce-motion setting * the animation resolves to its end value — the mark sits still instead of spinning. */ function SpinningLoaderIcon({ className }: { className?: string }) { const rotation = useSharedValue(0); React.useEffect(() => { rotation.value = withRepeat( withTiming(360, { duration: 1000, easing: Easing.linear, reduceMotion: ReduceMotion.System }), -1, false, ); return () => cancelAnimation(rotation); }, [rotation]); const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }], })); return ( <Animated.View style={style}> <Icon as={LoaderCircleIcon} size={12} className={className} /> </Animated.View> ); } /** * THE TWO-TARGET LAW'S PLAY HALF. Lives in the row's action slot — a nested pressable * wins the touch over the row, so pressing this NEVER selects the voice. Three * states: play, playing (pause mark), loading (disabled) — and disabled-when-unwired * (no onPlay), because a control that pretends to play is the lie (speech-input). * * Converged to the web control (voice-selector.tsx:511-518): variant outline, 12px * mark. KEPT on the record: the 40pt hit target (the web button is size-6/24px — * under the touch floor) and disabled-when-unwired. The mark inherits the outline * button's text class, as the web's bare icons inherit — no explicit color. */ function VoiceSelectorPreview({ playing, loading, onPlay, className }: VoiceSelectorPreviewProps) { const label = loading ? 'Loading preview' : playing ? 'Pause preview' : 'Play preview'; return ( <Button variant="outline" size="icon" disabled={loading || !onPlay} onPress={onPlay} accessibilityLabel={label} accessibilityState={{ disabled: loading || !onPlay, busy: loading }} // House formula (item/attachments precedent): h-10 + hitSlop 2/side = the 44pt // platform minimum, no pixel of extra chrome. hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }} className={cn('shrink-0', className)} > {loading ? <SpinningLoaderIcon /> : <Icon as={playing ? PauseIcon : PlayIcon} size={12} />} </Button> ); } export { useVoiceSelector, VoiceSelector, VoiceSelectorAccent, VoiceSelectorAge, VoiceSelectorAttributes, VoiceSelectorBullet, VoiceSelectorDescription, VoiceSelectorGender, VoiceSelectorName, VoiceSelectorPreview, VoiceSelectorTrigger, }; export type { VoiceSelectorVoice };
voice-selector.logic.ts
/** * VoiceSelector — 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's voice set comes from the browser TTS API; the PRD verdict * (port-adapted) says the available voice set comes from the NATIVE speech synthesis * provider — so the list is caller-supplied here and this file holds what the * component derives from it: the accent→flag table, the gender fallback, and the * preview-button state machine. */ /** One voice the caller offers (native TTS provider, ElevenLabs roster, …). */ export type VoiceSelectorVoice = { id: string; name: string; /** Section heading — the web's VoiceSelectorGroup heading, caller-defined. */ group?: string; description?: string; /** 'male' | 'female' | 'transgender' | 'non-binary' — anything else falls back, never throws. */ gender?: string; /** One of the VOICE_ACCENTS keys, CASE-SENSITIVE (upstream byte-behavior). */ accent?: string; /** Free text, e.g. "40-50". */ age?: string; /** Extra filter terms beyond name, accent, age and description. */ keywords?: string; }; /** * The web original's accent table, verbatim: 30 lowercase keys, each to a flag * emoji. THE LOOKUP IS CASE-SENSITIVE — upstream renders nothing for "AMERICAN" * (the KB's documented trap), and the port preserves that byte-behavior rather than * silently normalizing data the caller can normalize themselves. Android flag-glyph * coverage varies (KB port advice); the accent is ALSO carried into the filter * keywords, so the text channel is never lost. */ export const VOICE_ACCENTS = { american: '🇺🇸', british: '🇬🇧', australian: '🇦🇺', canadian: '🇨🇦', irish: '🇮🇪', scottish: '🏴󠁧󠁢󠁳󠁣󠁴󠁿', indian: '🇮🇳', 'south-african': '🇿🇦', 'new-zealand': '🇳🇿', spanish: '🇪🇸', french: '🇫🇷', german: '🇩🇪', italian: '🇮🇹', portuguese: '🇵🇹', brazilian: '🇧🇷', mexican: '🇲🇽', argentinian: '🇦🇷', japanese: '🇯🇵', chinese: '🇨🇳', korean: '🇰🇷', russian: '🇷🇺', arabic: '🇸🇦', dutch: '🇳🇱', swedish: '🇸🇪', norwegian: '🇳🇴', danish: '🇩🇰', finnish: '🇫🇮', polish: '🇵🇱', turkish: '🇹🇷', greek: '🇬🇷', } as const; export type VoiceAccent = keyof typeof VOICE_ACCENTS; /** * The flag for `accent`, or undefined when it is unknown or not exactly lowercase — * "AMERICAN" renders NOTHING, by upstream behavior. Callers render nothing for * undefined, which is the web's empty span. */ export function voiceAccentFlag(accent: string | undefined): string | undefined { if (!accent) return undefined; return (VOICE_ACCENTS as Record<string, string | undefined>)[accent]; } export const VOICE_GENDERS = [ 'male', 'female', 'transgender', 'androgyne', 'non-binary', 'intersex', ] as const; export type VoiceGender = (typeof VOICE_GENDERS)[number] | 'unknown'; /** * The gender key for the icon lookup. UNKNOWN VALUES FALL BACK, NEVER THROW (the * KB's documented gender behavior) — and unlike the accent table, the match is * case-INSENSITIVE: the web documents graceful fallback for gender with no * case-sensitivity language, so "Male" resolves instead of silently icon-less. * undefined and unrecognized inputs return 'unknown', which renders the default mark. */ export function resolveVoiceGender(gender: string | undefined): VoiceGender { const key = gender?.trim().toLowerCase(); return (VOICE_GENDERS as readonly string[]).includes(key ?? '') ? (key as Exclude<VoiceGender, 'unknown'>) : 'unknown'; } export type VoicePreviewState = 'idle' | 'playing' | 'loading'; /** * The preview button's state for one voice: loading wins over playing (a restart * requested mid-playback shows loading), playing needs previewingId === voiceId, and * everything else is idle. Loading is disabled; playing shows the pause mark. */ export function voicePreviewState( voiceId: string, previewingId?: string, previewLoadingId?: string, ): VoicePreviewState { if (previewLoadingId && previewLoadingId === voiceId) return 'loading'; if (previewingId && previewingId === voiceId) return 'playing'; return 'idle'; }
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
voicesreadonly VoiceSelectorVoice[]—The voices YOUR provider offers — native TTS or a hosted roster.
valuestring | undefined——
onValueChange(value: string) => void——
onPreview((voice: VoiceSelectorVoice) => void) | undefined—Play this voice's sample. Absent → every preview control DISABLES, never fakes.
previewingIdstring | undefined—The voice whose sample is currently playing (pause mark on that row).
previewLoadingIdstring | undefined—The voice whose sample is loading (disabled control on that row).
openboolean | undefined——
defaultOpenboolean | undefinedfalseThe web contract ships defaultOpen; the sheet opens closed without it.
onOpenChange((open: boolean) => void) | undefined——
placeholderstring | undefined'Search voices…'—
emptyTitlestring | undefined'No voices found'—
emptyDescriptionstring | undefined'Try a different search.'—
titlestring | undefined'Select AI Voice'The sheet's screen-reader title — the web Content's `title` prop.
classNamestring | undefined—Passed to the sheet content — height and padding adjustments.
childrenReact.ReactNode——

Theming

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

Usage

() => ( <VoiceSelector voices={[ { id: 'alloy', name: 'Alloy', group: 'Neutral', description: 'Balanced and clear', gender: 'female', accent: 'american' }, { id: 'echo', name: 'Echo', group: 'Neutral', description: 'Warm and even', gender: 'male', accent: 'american' }, { id: 'fable', name: 'Fable', group: 'Expressive', description: 'British storytelling', gender: 'female', accent: 'british' }, ]} value="alloy" onValueChange={() => {}} > <VoiceSelectorTrigger /> </VoiceSelector> )