Voice Selector
Voice picker dialog with search, grouped voices, attributes, descriptions and a preview play button.
import { VoiceSelector, VoiceSelectorAccent, VoiceSelectorAge, VoiceSelectorAttributes, VoiceSelectorBullet, VoiceSelectorContent, VoiceSelectorDescription, VoiceSelectorDetails, VoiceSelectorEmpty, VoiceSelectorGender, VoiceSelectorGroup, VoiceSelectorHeader, VoiceSelectorInput, VoiceSelectorItem, VoiceSelectorList, VoiceSelectorName, VoiceSelectorPreview, VoiceSelectorSeparator, VoiceSelectorTrigger,} from "@/components/ai/voice-selector";import { Button } from "@/components/ui/button";import { useState } from "react";import { IconPlaceholder } from "@/edmi/icon-placeholder";
const voices = [ { id: "aria", name: "Aria", gender: "female", accent: "american", age: "young", description: "Warm, clear. Good for explainers.", group: "Recommended", }, { id: "theo", name: "Theo", gender: "male", accent: "british", age: "middle-aged", description: "Calm and precise.", group: "Recommended", }, { id: "priya", name: "Priya", gender: "female", accent: "indian", age: "young", description: "Bright and friendly.", group: "All voices", },];
export default function Demo() { const [value, setValue] = useState<string | undefined>("aria"); const [playing, setPlaying] = useState<string>(); const current = voices.find((v) => v.id === value);
return ( <VoiceSelector value={value} onValueChange={setValue}> <VoiceSelectorTrigger render={<Button variant="outline" />}> <IconPlaceholder lucide="AudioLinesIcon" tabler="IconPlayerRecordFilled" hugeicons="AudioWave01Icon" phosphor="RecordIcon" remixicon="RiRecordCircleLine" className="size-3.5" /> {current?.name ?? "Select voice"} <IconPlaceholder lucide="ChevronsUpDownIcon" tabler="IconSelector" hugeicons="UnfoldMoreIcon" phosphor="CaretUpDownIcon" remixicon="RiArrowUpDownLine" className="size-3.5 text-muted-foreground" /> </VoiceSelectorTrigger> <VoiceSelectorContent title="Choose a voice"> <VoiceSelectorInput /> <VoiceSelectorList> <VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty> {["Recommended", "All voices"].map((group, i) => ( <div key={group}> {i > 0 && <VoiceSelectorSeparator />} <VoiceSelectorGroup heading={group}> {voices .filter((v) => v.group === group) .map((v) => ( <VoiceSelectorItem key={v.id} value={v.id} keywords={[v.name, v.gender, v.accent]} > <VoiceSelectorPreview onPlay={() => setPlaying((p) => (p === v.id ? undefined : v.id)) } playing={playing === v.id} /> <VoiceSelectorDetails> <VoiceSelectorHeader> <VoiceSelectorName>{v.name}</VoiceSelectorName> <VoiceSelectorAttributes> <VoiceSelectorGender value={v.gender} /> <VoiceSelectorBullet /> <VoiceSelectorAccent value={v.accent} /> <VoiceSelectorBullet /> <VoiceSelectorAge>{v.age}</VoiceSelectorAge> </VoiceSelectorAttributes> </VoiceSelectorHeader> <VoiceSelectorDescription> {v.description} </VoiceSelectorDescription> </VoiceSelectorDetails> </VoiceSelectorItem> ))} </VoiceSelectorGroup> </div> ))} </VoiceSelectorList> </VoiceSelectorContent> </VoiceSelector> );}<script setup lang="ts">import { AudioLinesIcon, ChevronsUpDown } from "@lucide/vue";import { VoiceSelector, VoiceSelectorAccent, VoiceSelectorAge, VoiceSelectorAttributes, VoiceSelectorBullet, VoiceSelectorContent, VoiceSelectorDescription, VoiceSelectorDetails, VoiceSelectorEmpty, VoiceSelectorGender, VoiceSelectorGroup, VoiceSelectorHeader, VoiceSelectorInput, VoiceSelectorItem, VoiceSelectorList, VoiceSelectorName, VoiceSelectorPreview, VoiceSelectorSeparator, VoiceSelectorTrigger,} from "@/components/ai/voice-selector";import { Button } from "@/components/ui/button";import { computed, ref } from "vue";
const voices = [ { id: "aria", name: "Aria", gender: "female", accent: "american", age: "young", description: "Warm, clear. Good for explainers.", group: "Recommended" }, { id: "theo", name: "Theo", gender: "male", accent: "british", age: "middle-aged", description: "Calm and precise.", group: "Recommended" }, { id: "priya", name: "Priya", gender: "female", accent: "indian", age: "young", description: "Bright and friendly.", group: "All voices" },];const groups = ["Recommended", "All voices"];
const value = ref<string | undefined>("aria");const playing = ref<string>();const current = computed(() => voices.find((v) => v.id === value.value));</script>
<template> <VoiceSelector v-model:value="value"> <VoiceSelectorTrigger as-child> <Button variant="outline"> <AudioLinesIcon class="size-3.5" /> {{ current?.name ?? "Select voice" }} <ChevronsUpDown class="size-3.5 text-muted-foreground" /> </Button> </VoiceSelectorTrigger> <VoiceSelectorContent title="Choose a voice"> <VoiceSelectorInput /> <VoiceSelectorList> <VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty> <div v-for="(group, i) in groups" :key="group"> <VoiceSelectorSeparator v-if="i > 0" /> <VoiceSelectorGroup :heading="group"> <VoiceSelectorItem v-for="v in voices.filter((x) => x.group === group)" :key="v.id" :value="v.id" :keywords="[v.name, v.gender, v.accent]"> <VoiceSelectorPreview :playing="playing === v.id" @play="playing = playing === v.id ? undefined : v.id" /> <VoiceSelectorDetails> <VoiceSelectorHeader> <VoiceSelectorName>{{ v.name }}</VoiceSelectorName> <VoiceSelectorAttributes> <VoiceSelectorGender :value="v.gender" /> <VoiceSelectorBullet /> <VoiceSelectorAccent :value="v.accent" /> <VoiceSelectorBullet /> <VoiceSelectorAge>{{ v.age }}</VoiceSelectorAge> </VoiceSelectorAttributes> </VoiceSelectorHeader> <VoiceSelectorDescription>{{ v.description }}</VoiceSelectorDescription> </VoiceSelectorDetails> </VoiceSelectorItem> </VoiceSelectorGroup> </div> </VoiceSelectorList> </VoiceSelectorContent> </VoiceSelector></template><script lang="ts"> import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte"; import { Button } from "$lib/components/ui/button"; import { VoiceSelector, VoiceSelectorAccent, VoiceSelectorAge, VoiceSelectorAttributes, VoiceSelectorBullet, VoiceSelectorContent, VoiceSelectorDescription, VoiceSelectorDetails, VoiceSelectorEmpty, VoiceSelectorGender, VoiceSelectorGroup, VoiceSelectorHeader, VoiceSelectorInput, VoiceSelectorItem, VoiceSelectorList, VoiceSelectorName, VoiceSelectorPreview, VoiceSelectorSeparator, VoiceSelectorTrigger, } from "$lib/components/ai/voice-selector";
const voices = [ { id: "aria", name: "Aria", gender: "female", accent: "american", age: "young", description: "Warm, clear. Good for explainers.", group: "Recommended" }, { id: "theo", name: "Theo", gender: "male", accent: "british", age: "middle-aged", description: "Calm and precise.", group: "Recommended" }, { id: "priya", name: "Priya", gender: "female", accent: "indian", age: "young", description: "Bright and friendly.", group: "All voices" }, ]; const groups = ["Recommended", "All voices"];
let value = $state<string | undefined>("aria"); let playing = $state<string>(); const current = $derived(voices.find((v) => v.id === value));</script>
<VoiceSelector bind:value> <VoiceSelectorTrigger> {#snippet child({ props })} <Button variant="outline" {...props}> <IconPlaceholder lucide="AudioLinesIcon" tabler="IconPlayerRecordFilled" hugeicons="AudioWave01Icon" phosphor="RecordIcon" remixicon="RiRecordCircleLine" class="size-3.5" /> {current?.name ?? "Select voice"} <IconPlaceholder lucide="ChevronsUpDownIcon" tabler="IconSelector" hugeicons="UnfoldMoreIcon" phosphor="CaretUpDownIcon" remixicon="RiArrowUpDownLine" class="size-3.5 text-muted-foreground" /> </Button> {/snippet} </VoiceSelectorTrigger> <VoiceSelectorContent title="Choose a voice"> <VoiceSelectorInput /> <VoiceSelectorList> <VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty> {#each groups as group, i (group)} {#if i > 0}<VoiceSelectorSeparator />{/if} <VoiceSelectorGroup heading={group}> {#each voices.filter((x) => x.group === group) as v (v.id)} <VoiceSelectorItem value={v.id} keywords={[v.name, v.gender, v.accent]}> <VoiceSelectorPreview playing={playing === v.id} onPlay={() => (playing = playing === v.id ? undefined : v.id)} /> <VoiceSelectorDetails> <VoiceSelectorHeader> <VoiceSelectorName>{v.name}</VoiceSelectorName> <VoiceSelectorAttributes> <VoiceSelectorGender value={v.gender} /> <VoiceSelectorBullet /> <VoiceSelectorAccent value={v.accent} /> <VoiceSelectorBullet /> <VoiceSelectorAge>{v.age}</VoiceSelectorAge> </VoiceSelectorAttributes> </VoiceSelectorHeader> <VoiceSelectorDescription>{v.description}</VoiceSelectorDescription> </VoiceSelectorDetails> </VoiceSelectorItem> {/each} </VoiceSelectorGroup> {/each} </VoiceSelectorList> </VoiceSelectorContent></VoiceSelector>Built on command, dialog and spinner.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-voice-selectorpnpm dlx shadcn@latest add @edmi-ui/ai-voice-selectoryarn dlx shadcn@latest add @edmi-ui/ai-voice-selectorbunx shadcn@latest add @edmi-ui/ai-voice-selectorPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,command,dialog,spinner.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-voice-selectorpnpm dlx shadcn-vue@latest add @edmi-ui/ai-voice-selectoryarn dlx shadcn-vue@latest add @edmi-ui/ai-voice-selectorbunx shadcn-vue@latest add @edmi-ui/ai-voice-selectorPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @vueuse/core @lucide/vue reka-uiTerminal window pnpm add @vueuse/core @lucide/vue reka-uiTerminal window yarn add @vueuse/core @lucide/vue reka-uiTerminal window bun add @vueuse/core @lucide/vue reka-uiAdd the Edmi components this one builds on:
button,command,dialog,spinner.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,command,dialog,spinner.Copy the code from Component source into your project and update the import paths to match your aliases.
import { VoiceSelector, VoiceSelectorContent, VoiceSelectorEmpty, VoiceSelectorInput, VoiceSelectorList, VoiceSelectorTrigger,} from "@/components/ai/voice-selector";import { Button } from "@/components/ui/button";
<VoiceSelector> <VoiceSelectorTrigger render={<Button variant="outline" />}> <RecordIcon className="size-3.5" /> <CaretUpDownIcon className="size-3.5 text-muted-foreground" /> </VoiceSelectorTrigger> <VoiceSelectorContent title="Choose a voice"> <VoiceSelectorInput /> <VoiceSelectorList> <VoiceSelectorEmpty>No voices found.</VoiceSelectorEmpty> </VoiceSelectorList> </VoiceSelectorContent></VoiceSelector><script setup lang="ts">import { VoiceSelector, VoiceSelectorTrigger,} from "@/components/ai/voice-selector";import { Button } from "@/components/ui/button";</script>
<template> <VoiceSelector> <VoiceSelectorTrigger as-child> <Button variant="outline"> <AudioLinesIcon class="size-3.5" /> <ChevronsUpDown class="size-3.5 text-muted-foreground" /> </Button> </VoiceSelectorTrigger> <!-- … --> </VoiceSelector></template><script lang="ts"> import { Button } from "$lib/components/ui/button"; import { VoiceSelector, VoiceSelectorTrigger, } from "$lib/components/ai/voice-selector";</script>
<VoiceSelector bind:value> <VoiceSelectorTrigger> {#snippet child({ props })} <Button variant="outline" {...props}> <RecordIcon class="size-3.5" /> <CaretUpDownIcon class="size-3.5 text-muted-foreground" /> </Button> {/snippet} </VoiceSelectorTrigger> <!-- … --></VoiceSelector>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { ComponentProps, ReactNode } from "react";import { createContext, useCallback, useContext, useMemo } from "react";import { PlayIcon, PauseIcon } from "@phosphor-icons/react";import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";import { Button } from "@/registry/edmi/ui/button";import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut,} from "@/registry/edmi/ui/command";import { Dialog, DialogContent, DialogTitle, DialogTrigger,} from "@/registry/edmi/ui/dialog";import { Spinner } from "@/registry/edmi/ui/spinner";
// Built on ui/command + ui/dialog (DESIGN §5b). Items: ghost play button, name + muted attributes, description, check.
interface VoiceSelectorContextValue { value: string | undefined; setValue: (value: string | undefined) => void; open: boolean; setOpen: (open: boolean) => void;}
const VoiceSelectorContext = createContext<VoiceSelectorContextValue | null>( null,);
export const useVoiceSelector = () => { const context = useContext(VoiceSelectorContext); if (!context) { throw new Error( "VoiceSelector components must be used within VoiceSelector", ); } return context;};
export type VoiceSelectorProps = Omit< ComponentProps<typeof Dialog>, "onOpenChange"> & { onOpenChange?: (open: boolean) => void; value?: string; defaultValue?: string; onValueChange?: (value: string | undefined) => void;};
export const VoiceSelector = ({ value: valueProp, defaultValue, onValueChange, open: openProp, defaultOpen = false, onOpenChange, children, ...props}: VoiceSelectorProps) => { const [value, setValue] = useControllableState({ defaultProp: defaultValue, onChange: onValueChange, prop: valueProp, });
const [open, setOpen] = useControllableState({ defaultProp: defaultOpen, onChange: onOpenChange, prop: openProp, });
const voiceSelectorContext = useMemo( () => ({ open, setOpen, setValue, value }), [value, setValue, open, setOpen], );
return ( <VoiceSelectorContext.Provider value={voiceSelectorContext}> <Dialog onOpenChange={setOpen} open={open} {...props}> {children} </Dialog> </VoiceSelectorContext.Provider> );};
export type VoiceSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
/** Pass the visible trigger with `render`, e.g. `render={<Button variant="outline" />}`. */export const VoiceSelectorTrigger = (props: VoiceSelectorTriggerProps) => ( <DialogTrigger data-slot="ai-voice-selector-trigger" {...props} />);
export type VoiceSelectorContentProps = ComponentProps<typeof DialogContent> & { title?: ReactNode;};
export const VoiceSelectorContent = ({ className, children, title = "Voice Selector", ...props}: VoiceSelectorContentProps) => ( <DialogContent data-slot="ai-voice-selector-content" aria-describedby={undefined} showCloseButton={false} className={cn( "top-1/3 translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-md", className, )} {...props} > <DialogTitle className="sr-only">{title}</DialogTitle> <Command className="rounded-none **:data-[slot=command-input-wrapper]:h-10"> {children} </Command> </DialogContent>);
export type VoiceSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const VoiceSelectorDialog = (props: VoiceSelectorDialogProps) => ( <CommandDialog {...props} />);
export type VoiceSelectorInputProps = ComponentProps<typeof CommandInput>;
export const VoiceSelectorInput = (props: VoiceSelectorInputProps) => ( <CommandInput placeholder="Search voices..." {...props} />);
export type VoiceSelectorListProps = ComponentProps<typeof CommandList>;
export const VoiceSelectorList = (props: VoiceSelectorListProps) => ( <CommandList {...props} />);
export type VoiceSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const VoiceSelectorEmpty = (props: VoiceSelectorEmptyProps) => ( <CommandEmpty {...props} />);
export type VoiceSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const VoiceSelectorGroup = (props: VoiceSelectorGroupProps) => ( <CommandGroup {...props} />);
export type VoiceSelectorItemProps = ComponentProps<typeof CommandItem>;
/** * A two-line row: lay out `Preview`, then a `Details` block (name + attributes, description). The item whose * `value` equals the selector value shows the check; selecting sets the value and closes unless `onSelect` is used. */export const VoiceSelectorItem = ({ className, value, onSelect, ...props}: VoiceSelectorItemProps) => { const { value: selected, setValue, setOpen } = useVoiceSelector();
const handleSelect = useCallback( (currentValue: string) => { if (onSelect) { onSelect(currentValue); return; } setValue(currentValue); setOpen(false); }, [onSelect, setValue, setOpen], );
return ( <CommandItem className={cn("h-auto items-start p-2 whitespace-normal", className)} data-checked={value !== undefined && value === selected} onSelect={handleSelect} value={value} {...props} /> );};
export type VoiceSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const VoiceSelectorShortcut = (props: VoiceSelectorShortcutProps) => ( <CommandShortcut {...props} />);
export type VoiceSelectorSeparatorProps = ComponentProps< typeof CommandSeparator>;
export const VoiceSelectorSeparator = (props: VoiceSelectorSeparatorProps) => ( <CommandSeparator {...props} />);
export type VoiceSelectorDetailsProps = ComponentProps<"div">;
/** ✦ Column that grows between the preview button and the check. */export const VoiceSelectorDetails = ({ className, ...props}: VoiceSelectorDetailsProps) => ( <div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} data-slot="ai-voice-selector-details" {...props} />);
export type VoiceSelectorHeaderProps = ComponentProps<"div">;
/** ✦ Row for the name followed by the attributes. */export const VoiceSelectorHeader = ({ className, ...props}: VoiceSelectorHeaderProps) => ( <div className={cn("flex items-center gap-1.5", className)} data-slot="ai-voice-selector-header" {...props} />);
export type VoiceSelectorGenderProps = ComponentProps<"span"> & { value?: | "male" | "female" | "transgender" | "androgyne" | "non-binary" | "intersex" | (string & {});};
/** Renders `children`, falling back to the value as text. */export const VoiceSelectorGender = ({ className, value, children, ...props}: VoiceSelectorGenderProps) => ( <span className={cn("text-xs text-muted-foreground", className)} {...props}> {children ?? value} </span>);
export type VoiceSelectorAccentProps = ComponentProps<"span"> & { /** `american`, `british`, `indian`, ...; any string works. */ value?: string;};
/** Renders `children`, falling back to the value as text. */export const VoiceSelectorAccent = ({ className, value, children, ...props}: VoiceSelectorAccentProps) => ( <span className={cn("text-xs text-muted-foreground", className)} {...props}> {children ?? value} </span>);
export type VoiceSelectorAgeProps = ComponentProps<"span">;
export const VoiceSelectorAge = ({ className, ...props}: VoiceSelectorAgeProps) => ( <span className={cn("text-xs text-muted-foreground tabular-nums", className)} {...props} />);
export type VoiceSelectorNameProps = ComponentProps<"span">;
export const VoiceSelectorName = ({ className, ...props}: VoiceSelectorNameProps) => ( <span className={cn("truncate text-left font-medium", className)} {...props} />);
export type VoiceSelectorDescriptionProps = ComponentProps<"span">;
export const VoiceSelectorDescription = ({ className, ...props}: VoiceSelectorDescriptionProps) => ( <span className={cn("text-[12.5px] text-muted-foreground", className)} {...props} />);
export type VoiceSelectorAttributesProps = ComponentProps<"div">;
export const VoiceSelectorAttributes = ({ className, children, ...props}: VoiceSelectorAttributesProps) => ( <div className={cn("flex items-center gap-1 text-xs", className)} {...props}> {children} </div>);
export type VoiceSelectorBulletProps = ComponentProps<"span">;
export const VoiceSelectorBullet = ({ className, ...props}: VoiceSelectorBulletProps) => ( <span aria-hidden="true" className={cn("select-none text-muted-foreground-2", className)} {...props} > · </span>);
export type VoiceSelectorPreviewProps = Omit< ComponentProps<"button">, "children"> & { playing?: boolean; loading?: boolean; onPlay?: () => void;};
export const VoiceSelectorPreview = ({ className, playing, loading, onPlay, onClick, ...props}: VoiceSelectorPreviewProps) => { const handleClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { event.stopPropagation(); onClick?.(event); onPlay?.(); }, [onClick, onPlay], );
let icon = ( <PlayIcon className="size-3 fill-current" /> );
if (loading) { icon = <Spinner className="size-3" />; } else if (playing) { icon = ( <PauseIcon className="size-3 fill-current" /> ); }
return ( <Button aria-label={playing ? "Pause preview" : "Play preview"} className={cn("mt-px", className)} data-slot="ai-voice-selector-preview" disabled={loading} onClick={handleClick} size="icon-xs" type="button" variant="ghost" {...props} > {icon} </Button> );};<script setup lang="ts">import { useVModel } from "@vueuse/core"import { computed, provide } from "vue"import { Dialog } from "@/registry/edmi/ui/dialog"import { VoiceSelectorKey } from "./context"
// Built on ui/command + ui/dialog (DESIGN §5b). `v-model:value` is the selected voice id.const props = withDefaults(defineProps<{ value?: string defaultValue?: string open?: boolean defaultOpen?: boolean modal?: boolean}>(), { value: undefined, open: undefined, defaultOpen: false, modal: true,})
const emit = defineEmits<{ (e: "update:value", value: string | undefined): void (e: "update:open", open: boolean): void}>()
const value = useVModel(props, "value", emit, { defaultValue: props.defaultValue, passive: true,})
const open = useVModel(props, "open", emit, { defaultValue: props.defaultOpen, passive: true,})
provide(VoiceSelectorKey, { value, setValue: (next) => { value.value = next }, open: computed(() => !!open.value), setOpen: (next) => { open.value = next },})</script>
<template> <Dialog :open="!!open" :modal="modal" @update:open="(next) => (open = next)"> <slot /> </Dialog></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
// Renders the slot, falling back to the value as text.const props = defineProps<{ value?: string class?: HTMLAttributes["class"]}>()</script>
<template> <span data-slot="ai-voice-selector-accent" :class="cn('text-xs text-muted-foreground', props.class)" > <slot>{{ props.value }}</slot> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span data-slot="ai-voice-selector-age" :class="cn('text-xs text-muted-foreground tabular-nums', props.class)" > <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div data-slot="ai-voice-selector-attributes" :class="cn('flex items-center gap-1 text-xs', props.class)" > <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span aria-hidden="true" :class="cn('select-none text-muted-foreground-2', props.class)" >·</span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Command } from "@/registry/edmi/ui/command"import { DialogContent, DialogDescription, DialogTitle } from "@/registry/edmi/ui/dialog"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] title?: string}>(), { title: "Voice Selector" })</script>
<template> <DialogContent data-slot="ai-voice-selector-content" :show-close-button="false" :class="cn('top-1/3 translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-md', props.class)" > <DialogTitle class="sr-only"> {{ title }} </DialogTitle> <DialogDescription class="sr-only"> Select a voice from the list </DialogDescription> <Command class="rounded-none **:data-[slot=command-input-wrapper]:h-10"> <slot /> </Command> </DialogContent></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span data-slot="ai-voice-selector-description" :class="cn('text-[12.5px] text-muted-foreground', props.class)" > <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
// ✦ Column that grows between the preview button and the check.const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div data-slot="ai-voice-selector-details" :class="cn('flex min-w-0 flex-1 flex-col gap-0.5', props.class)" > <slot /> </div></template><script setup lang="ts">import type { DialogRootEmits, DialogRootProps } from "reka-ui"import { useForwardPropsEmits } from "reka-ui"import { CommandDialog } from "@/registry/edmi/ui/command"
const props = defineProps<DialogRootProps & { title?: string, description?: string }>()const emits = defineEmits<DialogRootEmits>()const forwarded = useForwardPropsEmits(props, emits)</script>
<template> <CommandDialog v-bind="forwarded"> <slot /> </CommandDialog></template><script setup lang="ts">import { CommandEmpty } from "@/registry/edmi/ui/command"</script>
<template> <CommandEmpty> <slot>No voices found.</slot> </CommandEmpty></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
// Renders the slot, falling back to the value as text.const props = defineProps<{ value?: string class?: HTMLAttributes["class"]}>()</script>
<template> <span data-slot="ai-voice-selector-gender" :class="cn('text-xs text-muted-foreground', props.class)" > <slot>{{ props.value }}</slot> </span></template><script setup lang="ts">import { CommandGroup } from "@/registry/edmi/ui/command"</script>
<template> <CommandGroup> <slot /> </CommandGroup></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
// ✦ Row for the name followed by the attributes.const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <div data-slot="ai-voice-selector-header" :class="cn('flex items-center gap-1.5', props.class)" > <slot /> </div></template><script setup lang="ts">import { CommandInput } from "@/registry/edmi/ui/command"</script>
<template> <CommandInput placeholder="Search voices..." /></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { Check } from "@lucide/vue"import { useCurrentElement } from "@vueuse/core"import { onMounted, ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CommandItem, useCommand } from "@/registry/edmi/ui/command"import { useVoiceSelector } from "./context"
// A two-line row: lay out `VoiceSelectorPreview`, then a `VoiceSelectorDetails` block. The item whose `value`// equals the selector value shows the check; selecting sets the value and closes the dialog.const props = defineProps<{ /** The voice id. */ value: string /** Extra search terms (name, gender, accent). When set, the search matches these instead of the row text. */ keywords?: string[] class?: HTMLAttributes["class"]}>()
const { value: selected, setValue, setOpen } = useVoiceSelector("VoiceSelectorItem")
// CommandItem filters on the row's text content (it would match descriptions); keywords narrow it down// like cmdk does in the other ports. The inner element carries the same id the command registered.const { allItems } = useCommand()const itemRef = ref()const el = useCurrentElement(itemRef)onMounted(() => { if (!props.keywords?.length || !(el.value instanceof HTMLElement)) return allItems.value.set(el.value.id, [props.value, ...props.keywords].join(" "))})
function handleSelect() { setValue(props.value) setOpen(false)}</script>
<template> <CommandItem ref="itemRef" :value="props.value" :data-checked="props.value === selected" :class="cn('h-auto items-start p-2 whitespace-normal', props.class)" @select="handleSelect" > <slot /> <Check class="ml-auto opacity-0 group-data-[checked=true]/command-item:opacity-100" /> </CommandItem></template><script setup lang="ts">import { CommandList } from "@/registry/edmi/ui/command"</script>
<template> <CommandList> <slot /> </CommandList></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()</script>
<template> <span data-slot="ai-voice-selector-name" :class="cn('truncate text-left font-medium', props.class)" > <slot /> </span></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { PlayIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import { Spinner } from "@/registry/edmi/ui/spinner"
const props = defineProps<{ class?: HTMLAttributes["class"] playing?: boolean loading?: boolean}>()
const emit = defineEmits<{ (e: "play"): void}>()
function handleClick(event: MouseEvent) { event.stopPropagation() emit("play")}</script>
<template> <Button data-slot="ai-voice-selector-preview" type="button" variant="ghost" size="icon-xs" :aria-label="props.playing ? 'Pause preview' : 'Play preview'" :disabled="props.loading" :class="cn('mt-px', props.class)" @click="handleClick" > <Spinner v-if="props.loading" class="size-3" /> <svg v-else-if="props.playing" class="size-3" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <path d="M7 4h4v16H7zM13 4h4v16h-4z" /> </svg> <PlayIcon v-else class="size-3 fill-current" /> </Button></template><script setup lang="ts">import { CommandSeparator } from "@/registry/edmi/ui/command"</script>
<template> <CommandSeparator> <slot /> </CommandSeparator></template><script setup lang="ts">import { CommandShortcut } from "@/registry/edmi/ui/command"</script>
<template> <CommandShortcut> <slot /> </CommandShortcut></template><script setup lang="ts">import type { DialogTriggerProps } from "reka-ui"import { DialogTrigger } from "@/registry/edmi/ui/dialog"
// Pass the visible trigger as the slot with `as-child`, e.g. `<VoiceSelectorTrigger as-child><Button variant="outline" /></VoiceSelectorTrigger>`.const props = defineProps<DialogTriggerProps>()</script>
<template> <DialogTrigger data-slot="ai-voice-selector-trigger" v-bind="props"> <slot /> </DialogTrigger></template>import type { InjectionKey, Ref } from "vue";import { inject } from "vue";
export interface VoiceSelectorContextValue { value: Ref<string | undefined>; setValue: (value: string | undefined) => void; open: Ref<boolean>; setOpen: (open: boolean) => void;}
export const VoiceSelectorKey: InjectionKey<VoiceSelectorContextValue> = Symbol("VoiceSelector");
export function useVoiceSelector( componentName: string,): VoiceSelectorContextValue { const context = inject(VoiceSelectorKey); if (!context) { throw new Error(`${componentName} must be used within VoiceSelector`); } return context;}export * from "./context";export { default as VoiceSelector } from "./VoiceSelector.vue";export { default as VoiceSelectorAccent } from "./VoiceSelectorAccent.vue";export { default as VoiceSelectorAge } from "./VoiceSelectorAge.vue";export { default as VoiceSelectorAttributes } from "./VoiceSelectorAttributes.vue";export { default as VoiceSelectorBullet } from "./VoiceSelectorBullet.vue";export { default as VoiceSelectorContent } from "./VoiceSelectorContent.vue";export { default as VoiceSelectorDescription } from "./VoiceSelectorDescription.vue";export { default as VoiceSelectorDetails } from "./VoiceSelectorDetails.vue";export { default as VoiceSelectorDialog } from "./VoiceSelectorDialog.vue";export { default as VoiceSelectorEmpty } from "./VoiceSelectorEmpty.vue";export { default as VoiceSelectorGender } from "./VoiceSelectorGender.vue";export { default as VoiceSelectorGroup } from "./VoiceSelectorGroup.vue";export { default as VoiceSelectorHeader } from "./VoiceSelectorHeader.vue";export { default as VoiceSelectorInput } from "./VoiceSelectorInput.vue";export { default as VoiceSelectorItem } from "./VoiceSelectorItem.vue";export { default as VoiceSelectorList } from "./VoiceSelectorList.vue";export { default as VoiceSelectorName } from "./VoiceSelectorName.vue";export { default as VoiceSelectorPreview } from "./VoiceSelectorPreview.vue";export { default as VoiceSelectorSeparator } from "./VoiceSelectorSeparator.vue";export { default as VoiceSelectorShortcut } from "./VoiceSelectorShortcut.vue";export { default as VoiceSelectorTrigger } from "./VoiceSelectorTrigger.vue";import { getContext, setContext } from "svelte";
/** Getter object so consumers stay reactive when the props change. */export interface VoiceSelectorContext { readonly value: string | undefined; setValue: (value: string | undefined) => void; setOpen: (open: boolean) => void;}
const KEY = Symbol("ai-voice-selector");
export function setVoiceSelectorContext(value: VoiceSelectorContext) { return setContext(KEY, value);}
export function useVoiceSelector(): VoiceSelectorContext { const context = getContext<VoiceSelectorContext | undefined>(KEY); if (!context) { throw new Error( "VoiceSelector components must be used within VoiceSelector", ); } return context;}<script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
// Renders the children, falling back to the value as text. let { value, class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { value?: string } = $props();</script>
<span data-slot="ai-voice-selector-accent" class={cn("text-xs text-muted-foreground", className)} {...restProps}> {#if children} {@render children()} {:else} {value} {/if}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();</script>
<span data-slot="ai-voice-selector-age" class={cn("text-xs text-muted-foreground tabular-nums", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();</script>
<div data-slot="ai-voice-selector-attributes" class={cn("flex items-center gap-1 text-xs", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();</script>
<span aria-hidden="true" class={cn("select-none text-muted-foreground-2", className)} {...restProps}>·</span><script lang="ts"> import { Command } from "$lib/registry/ui/command/index.js"; import { DialogContent, DialogDescription, DialogTitle, } from "$lib/registry/ui/dialog/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps, Snippet } from "svelte";
let { class: className, children, title = "Voice Selector", ...restProps }: Omit<ComponentProps<typeof DialogContent>, "children" | "showCloseButton"> & { title?: string; children?: Snippet; } = $props();</script>
<DialogContent data-slot="ai-voice-selector-content" showCloseButton={false} class={cn("top-1/3 translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-md", className)} {...restProps}> <DialogTitle class="sr-only">{title}</DialogTitle> <DialogDescription class="sr-only">Select a voice from the list</DialogDescription> <Command class="rounded-none **:data-[slot=command-input-wrapper]:h-10"> {@render children?.()} </Command></DialogContent><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();</script>
<span data-slot="ai-voice-selector-description" class={cn("text-[12.5px] text-muted-foreground", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
// ✦ Column that grows between the preview button and the check. let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();</script>
<div data-slot="ai-voice-selector-details" class={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { CommandDialog } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { open = $bindable(false), ...restProps }: ComponentProps<typeof CommandDialog> = $props();</script>
<CommandDialog bind:open {...restProps} /><script lang="ts"> import { CommandEmpty } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { children, ...restProps }: ComponentProps<typeof CommandEmpty> = $props();</script>
<CommandEmpty {...restProps}> {#if children} {@render children()} {:else} No voices found. {/if}</CommandEmpty><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
// Renders the children, falling back to the value as text. let { value, class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> & { value?: string } = $props();</script>
<span data-slot="ai-voice-selector-gender" class={cn("text-xs text-muted-foreground", className)} {...restProps}> {#if children} {@render children()} {:else} {value} {/if}</span><script lang="ts"> import { CommandGroup } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ...restProps }: ComponentProps<typeof CommandGroup> = $props();</script>
<CommandGroup {...restProps} /><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
// ✦ Row for the name followed by the attributes. let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();</script>
<div data-slot="ai-voice-selector-header" class={cn("flex items-center gap-1.5", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { CommandInput } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { value = $bindable(""), ...restProps }: ComponentProps<typeof CommandInput> = $props();</script>
<CommandInput bind:value placeholder="Search voices..." {...restProps} /><script lang="ts"> import { CommandItem } from "$lib/registry/ui/command/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte"; import { useVoiceSelector } from "./use-voice-selector.svelte.js";
// A two-line row: lay out `VoiceSelectorPreview`, then a `VoiceSelectorDetails` block. The item whose `value` // equals the selector value shows the check; selecting sets the value and closes unless `onSelect` is used. let { value, onSelect, class: className, children, ...restProps }: ComponentProps<typeof CommandItem> = $props();
const ctx = useVoiceSelector();</script>
<CommandItem {value} data-checked={value !== undefined && value === ctx.value} class={cn("h-auto items-start p-2 whitespace-normal", className)} onSelect={() => { if (onSelect) { onSelect(); return; } ctx.setValue(value); ctx.setOpen(false); }} {...restProps}> {@render children?.()}</CommandItem><script lang="ts"> import { CommandList } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ...restProps }: ComponentProps<typeof CommandList> = $props();</script>
<CommandList {...restProps} /><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();</script>
<span data-slot="ai-voice-selector-name" class={cn("truncate text-left font-medium", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import PauseIcon from "phosphor-svelte/lib/Pause"; import PlayIcon from "phosphor-svelte/lib/Play"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { Spinner } from "$lib/registry/ui/spinner/index.js"; import { cn } from "$lib/utils.js";
let { class: className, playing = false, loading = false, onPlay, onclick, ...restProps }: Omit<ButtonProps, "children" | "href" | "onclick"> & { onclick?: (event: MouseEvent) => void; playing?: boolean; loading?: boolean; onPlay?: () => void; } = $props();</script>
<Button data-slot="ai-voice-selector-preview" type="button" variant="ghost" size="icon-xs" aria-label={playing ? "Pause preview" : "Play preview"} disabled={loading} class={cn("mt-px", className)} onclick={(event) => { event.stopPropagation(); onclick?.(event); onPlay?.(); }} {...restProps}> {#if loading} <Spinner class="size-3" /> {:else if playing} <PauseIcon class="size-3 fill-current" /> {:else} <PlayIcon class="size-3 fill-current" /> {/if}</Button><script lang="ts"> import { CommandSeparator } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ...restProps }: ComponentProps<typeof CommandSeparator> = $props();</script>
<CommandSeparator {...restProps} /><script lang="ts"> import { CommandShortcut } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ...restProps }: ComponentProps<typeof CommandShortcut> = $props();</script>
<CommandShortcut {...restProps} /><script lang="ts"> import { DialogTrigger } from "$lib/registry/ui/dialog/index.js"; import type { ComponentProps } from "svelte";
// Pass the visible trigger with the `child` snippet, e.g. `{#snippet child({ props })}<Button variant="outline" {...props}>…`. let { ...restProps }: ComponentProps<typeof DialogTrigger> = $props();</script>
<DialogTrigger data-slot="ai-voice-selector-trigger" {...restProps} /><script lang="ts"> import { Dialog } from "$lib/registry/ui/dialog/index.js"; import type { ComponentProps } from "svelte"; import { setVoiceSelectorContext } from "./use-voice-selector.svelte.js";
// Built on ui/command + ui/dialog (DESIGN §5b). `bind:value` is the selected voice id. let { value = $bindable(), open = $bindable(false), children, ...restProps }: ComponentProps<typeof Dialog> & { value?: string } = $props();
setVoiceSelectorContext({ get value() { return value; }, setValue: (next) => { value = next; }, setOpen: (next) => { open = next; }, });</script>
<Dialog bind:open {...restProps}> {@render children?.()}</Dialog>import Root from "./voice-selector.svelte";import Accent from "./voice-selector-accent.svelte";import Age from "./voice-selector-age.svelte";import Attributes from "./voice-selector-attributes.svelte";import Bullet from "./voice-selector-bullet.svelte";import Content from "./voice-selector-content.svelte";import Description from "./voice-selector-description.svelte";import Details from "./voice-selector-details.svelte";import Dialog from "./voice-selector-dialog.svelte";import Empty from "./voice-selector-empty.svelte";import Gender from "./voice-selector-gender.svelte";import Group from "./voice-selector-group.svelte";import Header from "./voice-selector-header.svelte";import Input from "./voice-selector-input.svelte";import Item from "./voice-selector-item.svelte";import List from "./voice-selector-list.svelte";import Name from "./voice-selector-name.svelte";import Preview from "./voice-selector-preview.svelte";import Separator from "./voice-selector-separator.svelte";import Shortcut from "./voice-selector-shortcut.svelte";import Trigger from "./voice-selector-trigger.svelte";
export { Accent, Accent as VoiceSelectorAccent, Age, Age as VoiceSelectorAge, Attributes, Attributes as VoiceSelectorAttributes, Bullet, Bullet as VoiceSelectorBullet, Content, Content as VoiceSelectorContent, Description, Description as VoiceSelectorDescription, Details, Details as VoiceSelectorDetails, Dialog, Dialog as VoiceSelectorDialog, Empty, Empty as VoiceSelectorEmpty, Gender, Gender as VoiceSelectorGender, Group, Group as VoiceSelectorGroup, Header, Header as VoiceSelectorHeader, Input, Input as VoiceSelectorInput, Item, Item as VoiceSelectorItem, List, List as VoiceSelectorList, Name, Name as VoiceSelectorName, Preview, Preview as VoiceSelectorPreview, Root, // Root as VoiceSelector, Separator, Separator as VoiceSelectorSeparator, Shortcut, Shortcut as VoiceSelectorShortcut, Trigger, Trigger as VoiceSelectorTrigger,};| Part | Prop | Type | Default |
|---|---|---|---|
| VoiceSelector | value, defaultValue, onValueChange |
voice id | |
| VoiceSelector | open, defaultOpen, onOpenChange |
boolean |
false |
| VoiceSelectorTrigger | render |
the visible trigger, e.g. <Button variant="outline" /> |
|
| VoiceSelectorContent | title |
ReactNode (screen reader title) |
Voice Selector |
| VoiceSelectorItem | value, onSelect |
the selected item shows the check; without onSelect it sets the value and closes |
|
| VoiceSelectorPreview | playing, loading, onPlay |
boolean, boolean, () => void |
|
| VoiceSelectorGender, VoiceSelectorAccent | value |
text fallback when no children |
✦ Edmi additions
Section titled “✦ Edmi additions”VoiceSelectorDetailsandVoiceSelectorHeaderlay out the two-line row (name + attributes, then description).- Gender and accent render as muted text separated by
VoiceSelectorBullet(middle dot), as on the board, not as icons and flags.
Framework notes
Section titled “Framework notes”- Vue:
v-model:value/v-model:open, trigger viaas-child; selecting always sets the value and closes (extra@selectruns after). Svelte:bind:value, trigger via thechildsnippet; passingonSelectreplaces the default select behaviour, as in React.