Skip to content
edmi-ui

Voice Selector

Voice picker dialog with search, grouped voices, attributes, descriptions and a preview play button.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Built on command, dialog and spinner.

Terminal window
npx shadcn@latest add @edmi-ui/ai-voice-selector
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-voice-selector
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-voice-selector
Terminal window
bunx shadcn@latest add @edmi-ui/ai-voice-selector

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-voice-selector
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-voice-selector
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-voice-selector
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-voice-selector

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-voice-selector.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/voice-selector.tsx
"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}
>
&middot;
</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>
);
};
registry/components/ai/voice-selector/VoiceSelector.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorAccent.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorAge.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorAttributes.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorBullet.vue
<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)"
>&middot;</span>
</template>
registry/components/ai/voice-selector/VoiceSelectorContent.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorDescription.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorDetails.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorDialog.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorEmpty.vue
<script setup lang="ts">
import { CommandEmpty } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandEmpty>
<slot>No voices found.</slot>
</CommandEmpty>
</template>
registry/components/ai/voice-selector/VoiceSelectorGender.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorGroup.vue
<script setup lang="ts">
import { CommandGroup } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandGroup>
<slot />
</CommandGroup>
</template>
registry/components/ai/voice-selector/VoiceSelectorHeader.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorInput.vue
<script setup lang="ts">
import { CommandInput } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandInput placeholder="Search voices..." />
</template>
registry/components/ai/voice-selector/VoiceSelectorItem.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorList.vue
<script setup lang="ts">
import { CommandList } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandList>
<slot />
</CommandList>
</template>
registry/components/ai/voice-selector/VoiceSelectorName.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorPreview.vue
<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>
registry/components/ai/voice-selector/VoiceSelectorSeparator.vue
<script setup lang="ts">
import { CommandSeparator } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandSeparator>
<slot />
</CommandSeparator>
</template>
registry/components/ai/voice-selector/VoiceSelectorShortcut.vue
<script setup lang="ts">
import { CommandShortcut } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandShortcut>
<slot />
</CommandShortcut>
</template>
registry/components/ai/voice-selector/VoiceSelectorTrigger.vue
<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>
registry/components/ai/voice-selector/context.ts
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;
}
registry/components/ai/voice-selector/index.ts
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";
src/lib/registry/ai/voice-selector/use-voice-selector.svelte.ts
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;
}
src/lib/registry/ai/voice-selector/voice-selector-accent.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-age.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-attributes.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-bullet.svelte
<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}>&middot;</span>
src/lib/registry/ai/voice-selector/voice-selector-content.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-description.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-details.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-dialog.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector-empty.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-gender.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-group.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector-header.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-input.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector-item.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-list.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector-name.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-preview.svelte
<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>
src/lib/registry/ai/voice-selector/voice-selector-separator.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector-shortcut.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector-trigger.svelte
<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} />
src/lib/registry/ai/voice-selector/voice-selector.svelte
<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>
src/lib/registry/ai/voice-selector/index.ts
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
  • VoiceSelectorDetails and VoiceSelectorHeader lay 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.
  • Vue: v-model:value / v-model:open, trigger via as-child; selecting always sets the value and closes (extra @select runs after). Svelte: bind:value, trigger via the child snippet; passing onSelect replaces the default select behaviour, as in React.