Skip to content
edmi-ui

Mic Selector

Microphone picker: outline trigger, searchable popover list, cleaned-up device names with the hardware id muted.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Built on popover and command.

Terminal window
npx shadcn@latest add @edmi-ui/ai-mic-selector
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-mic-selector
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-mic-selector
Terminal window
bunx shadcn@latest add @edmi-ui/ai-mic-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-mic-selector
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-mic-selector
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-mic-selector
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-mic-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-mic-selector.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-mic-selector.json

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

import {
MicSelector,
MicSelectorContent,
MicSelectorInput,
MicSelectorList,
MicSelectorTrigger,
MicSelectorValue,
} from "@/components/ai/mic-selector";
<MicSelector>
<MicSelectorTrigger className="w-[260px]">
<Mic />
<MicSelectorValue />
</MicSelectorTrigger>
<MicSelectorContent>
<MicSelectorInput />
<MicSelectorList>
</MicSelectorList>
</MicSelectorContent>
</MicSelector>
<script setup lang="ts">
import {
MicSelector,
MicSelectorContent,
MicSelectorEmpty,
MicSelectorInput,
MicSelectorItem,
MicSelectorLabel,
MicSelectorList,
MicSelectorTrigger,
MicSelectorValue,
} from "@/components/ai/mic-selector";
</script>
<template>
<MicSelector>
<MicSelectorTrigger class="w-[260px]">
<AudioLinesIcon class="size-4 shrink-0" />
<MicSelectorValue />
</MicSelectorTrigger>
<MicSelectorContent>
<MicSelectorInput />
<MicSelectorList v-slot="">
<MicSelectorEmpty />
<MicSelectorItem v-for="d in items" :key="d.deviceId" :value="d.deviceId">
<AudioLinesIcon />
<MicSelectorLabel :device="d" />
</MicSelectorItem>
</MicSelectorList>
</MicSelectorContent>
</MicSelector>
</template>
<script lang="ts">
import {
MicSelector,
MicSelectorContent,
MicSelectorEmpty,
MicSelectorInput,
MicSelectorItem,
MicSelectorLabel,
MicSelectorList,
MicSelectorTrigger,
MicSelectorValue,
} from "$lib/components/ai/mic-selector";
</script>
<MicSelector bind:value>
<MicSelectorTrigger class="w-[260px]">
<MicSelectorValue />
</MicSelectorTrigger>
<MicSelectorContent>
<MicSelectorInput />
<MicSelectorList>
{#snippet children(items)}
<MicSelectorEmpty />
{#each items as d (d.deviceId)}
<MicSelectorItem value={d.deviceId} keywords={[d.label]}>
<MicSelectorLabel device={d} />
</MicSelectorItem>
{/each}
{/snippet}
</MicSelectorList>
</MicSelectorContent>
</MicSelector>
registry/components/ai/mic-selector.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CaretUpDownIcon } from "@phosphor-icons/react";
import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";
import { Button } from "@/registry/edmi/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/edmi/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/edmi/ui/popover";
// Built on ui/popover + ui/command (DESIGN §5b): outline trigger, searchable list, hardware id in muted mono.
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
interface MicSelectorContextType {
data: MediaDeviceInfo[];
value: string | undefined;
onValueChange?: (value: string) => void;
open: boolean;
onOpenChange?: (open: boolean) => void;
width: number;
setWidth?: (width: number) => void;
}
const MicSelectorContext = createContext<MicSelectorContextType>({
data: [],
onOpenChange: undefined,
onValueChange: undefined,
open: false,
setWidth: undefined,
value: undefined,
width: 200,
});
export const useAudioDevices = () => {
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasPermission, setHasPermission] = useState(false);
const loadDevicesWithoutPermission = useCallback(async () => {
try {
setLoading(true);
setError(null);
const deviceList = await navigator.mediaDevices.enumerateDevices();
const audioInputs = deviceList.filter(
(device) => device.kind === "audioinput",
);
setDevices(audioInputs);
} catch (caughtError) {
const message =
caughtError instanceof Error
? caughtError.message
: "Failed to get audio devices";
setError(message);
console.error("Error getting audio devices:", message);
} finally {
setLoading(false);
}
}, []);
const loadDevicesWithPermission = useCallback(async () => {
if (loading) {
return;
}
try {
setLoading(true);
setError(null);
const tempStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
for (const track of tempStream.getTracks()) {
track.stop();
}
const deviceList = await navigator.mediaDevices.enumerateDevices();
const audioInputs = deviceList.filter(
(device) => device.kind === "audioinput",
);
setDevices(audioInputs);
setHasPermission(true);
} catch (caughtError) {
const message =
caughtError instanceof Error
? caughtError.message
: "Failed to get audio devices";
setError(message);
console.error("Error getting audio devices:", message);
} finally {
setLoading(false);
}
}, [loading]);
useEffect(() => {
loadDevicesWithoutPermission();
}, [loadDevicesWithoutPermission]);
useEffect(() => {
const handleDeviceChange = () => {
if (hasPermission) {
loadDevicesWithPermission();
} else {
loadDevicesWithoutPermission();
}
};
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
handleDeviceChange,
);
};
}, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
return {
devices,
error,
hasPermission,
loadDevices: loadDevicesWithPermission,
loading,
};
};
export type MicSelectorProps = ComponentProps<typeof Popover> & {
defaultValue?: string;
value?: string | undefined;
onValueChange?: (value: string | undefined) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
/**
* Devices to list. When omitted the component enumerates the audio inputs itself (and asks for
* microphone permission the first time the list opens).
*/
devices?: MediaDeviceInfo[];
};
export const MicSelector = ({
defaultValue,
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultOpen = false,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
devices: devicesProp,
...props
}: MicSelectorProps) => {
const [value, onValueChange] = useControllableState<string | undefined>({
defaultProp: defaultValue,
onChange: controlledOnValueChange,
prop: controlledValue,
});
const [open, onOpenChange] = useControllableState({
defaultProp: defaultOpen,
onChange: controlledOnOpenChange,
prop: controlledOpen,
});
const [width, setWidth] = useState(200);
const {
devices: enumerated,
loading,
hasPermission,
loadDevices,
} = useAudioDevices();
const devices = devicesProp ?? enumerated;
useEffect(() => {
if (!devicesProp && open && !hasPermission && !loading) {
loadDevices();
}
}, [devicesProp, open, hasPermission, loading, loadDevices]);
const contextValue = useMemo(
() => ({
data: devices,
onOpenChange,
onValueChange,
open,
setWidth,
value,
width,
}),
[devices, onOpenChange, onValueChange, open, value, width],
);
return (
<MicSelectorContext.Provider value={contextValue}>
<Popover {...props} onOpenChange={onOpenChange} open={open} />
</MicSelectorContext.Provider>
);
};
export type MicSelectorTriggerProps = ComponentProps<typeof PopoverTrigger>;
export const MicSelectorTrigger = ({
children,
className,
...props
}: MicSelectorTriggerProps) => {
const { setWidth } = useContext(MicSelectorContext);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Track the trigger width so the list below matches it.
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = (entry.target as HTMLElement).offsetWidth;
if (newWidth) {
setWidth?.(newWidth);
}
}
});
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
resizeObserver.disconnect();
};
}, [setWidth]);
return (
<PopoverTrigger
data-slot="ai-mic-selector-trigger"
ref={ref}
render={
<Button
variant="outline"
className={cn("justify-start font-normal", className)}
/>
}
{...props}
>
{children}
<CaretUpDownIcon
className="ml-auto size-3.5 shrink-0 text-muted-foreground"
/>
</PopoverTrigger>
);
};
export type MicSelectorContentProps = ComponentProps<typeof Command> & {
popoverOptions?: ComponentProps<typeof PopoverContent>;
};
export const MicSelectorContent = ({
className,
popoverOptions,
...props
}: MicSelectorContentProps) => {
const { width } = useContext(MicSelectorContext);
const {
className: popoverClassName,
style: popoverStyle,
...popoverRest
} = popoverOptions ?? {};
return (
<PopoverContent
data-slot="ai-mic-selector-content"
align="start"
className={cn(
"w-auto gap-0 overflow-hidden p-0",
popoverClassName as string,
)}
style={{ minWidth: 320, width, ...(popoverStyle as object) }}
{...popoverRest}
>
<Command
className={cn(
"rounded-xl **:data-[slot=command-input-wrapper]:h-10",
className,
)}
{...props}
/>
</PopoverContent>
);
};
export type MicSelectorInputProps = ComponentProps<typeof CommandInput>;
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => (
<CommandInput placeholder="Search microphones..." {...props} />
);
export type MicSelectorListProps = Omit<
ComponentProps<typeof CommandList>,
"children"
> & {
children: (devices: MediaDeviceInfo[]) => ReactNode;
};
export const MicSelectorList = ({
children,
...props
}: MicSelectorListProps) => {
const { data } = useContext(MicSelectorContext);
return <CommandList {...props}>{children(data)}</CommandList>;
};
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const MicSelectorEmpty = ({
children = "No microphone found.",
...props
}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
/** `value` is the device id; the selected item shows the check. */
export const MicSelectorItem = ({
value,
onSelect,
...props
}: MicSelectorItemProps) => {
const {
value: selected,
onValueChange,
onOpenChange,
} = useContext(MicSelectorContext);
const handleSelect = useCallback(
(currentValue: string) => {
onValueChange?.(currentValue);
onOpenChange?.(false);
onSelect?.(currentValue);
},
[onValueChange, onOpenChange, onSelect],
);
return (
<CommandItem
data-checked={value !== undefined && value === selected}
onSelect={handleSelect}
value={value}
{...props}
/>
);
};
export type MicSelectorLabelProps = ComponentProps<"span"> & {
device: MediaDeviceInfo;
/** Show the hardware id (`1a2b:3c4d`) in muted mono after the name. Default true. */
showId?: boolean;
};
export const MicSelectorLabel = ({
device,
showId = true,
className,
...props
}: MicSelectorLabelProps) => {
const matches = device.label.match(deviceIdRegex);
if (!matches) {
return (
<span className={cn("min-w-0 truncate", className)} {...props}>
{device.label}
</span>
);
}
const [, deviceId] = matches;
const name = device.label.replace(deviceIdRegex, "").trim();
return (
<span
className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
{...props}
>
<span className="truncate">{name}</span>
{showId && (
<span className="ml-auto shrink-0 font-mono text-[11.5px] text-muted-foreground">
{deviceId}
</span>
)}
</span>
);
};
export type MicSelectorValueProps = ComponentProps<"span">;
export const MicSelectorValue = ({
className,
...props
}: MicSelectorValueProps) => {
const { data, value } = useContext(MicSelectorContext);
const currentDevice = data.find((d) => d.deviceId === value);
if (!currentDevice) {
return (
<span
className={cn(
"flex-1 truncate text-left text-muted-foreground",
className,
)}
{...props}
>
Select microphone...
</span>
);
}
return (
<MicSelectorLabel
className={cn("flex-1 truncate text-left", className)}
device={currentDevice}
showId={false}
{...props}
/>
);
};
registry/components/ai/mic-selector/MicSelector.vue
<script setup lang="ts">
import type { PopoverRootProps } from "reka-ui"
import { useVModel } from "@vueuse/core"
import { computed, provide, ref, watch } from "vue"
import { Popover } from "@/registry/edmi/ui/popover"
import { MicSelectorKey } from "./context"
import { useAudioDevices } from "./useAudioDevices"
// Built on ui/popover + ui/command (DESIGN §5b). `v-model:value` is the selected device id.
const props = withDefaults(defineProps<Omit<PopoverRootProps, "open" | "defaultOpen"> & {
value?: string
defaultValue?: string
open?: boolean
defaultOpen?: boolean
/** ✦ Devices to list. Omit to enumerate the audio inputs (asks for microphone permission when the list opens). */
devices?: MediaDeviceInfo[]
}>(), {
value: undefined,
open: undefined,
defaultOpen: false,
devices: undefined,
})
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,
})
const width = ref(200)
const { devices: enumerated, hasPermission, loadDevices, loading } = useAudioDevices()
const devices = computed(() => props.devices ?? enumerated.value)
watch([open, hasPermission, loading], ([isOpen, permitted, isLoading]) => {
if (!props.devices && isOpen && !permitted && !isLoading)
loadDevices()
})
provide(MicSelectorKey, {
devices,
value,
setValue: (next) => { value.value = next },
open: computed(() => !!open.value),
setOpen: (next) => { open.value = next },
width,
setWidth: (next) => { width.value = next },
})
</script>
<template>
<Popover :open="!!open" :modal="modal" @update:open="(next) => (open = next)">
<slot />
</Popover>
</template>
registry/components/ai/mic-selector/MicSelectorContent.vue
<script setup lang="ts">
import type { PopoverContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Command } from "@/registry/edmi/ui/command"
import { PopoverContent } from "@/registry/edmi/ui/popover"
import { useMicSelector } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
popoverOptions?: PopoverContentProps & { class?: HTMLAttributes["class"] }
}>()
const { width } = useMicSelector("MicSelectorContent")
</script>
<template>
<PopoverContent
data-slot="ai-mic-selector-content"
align="start"
v-bind="props.popoverOptions"
:class="cn('w-auto gap-0 overflow-hidden p-0', props.popoverOptions?.class)"
:style="{ width: `${width}px`, minWidth: '320px' }"
>
<Command :class="cn('rounded-xl **:data-[slot=command-input-wrapper]:h-10', props.class)">
<slot />
</Command>
</PopoverContent>
</template>
registry/components/ai/mic-selector/MicSelectorEmpty.vue
<script setup lang="ts">
import { CommandEmpty } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandEmpty>
<slot>No microphone found.</slot>
</CommandEmpty>
</template>
registry/components/ai/mic-selector/MicSelectorInput.vue
<script setup lang="ts">
import { CommandInput } from "@/registry/edmi/ui/command"
</script>
<template>
<CommandInput placeholder="Search microphones..." />
</template>
registry/components/ai/mic-selector/MicSelectorItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Check } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CommandItem } from "@/registry/edmi/ui/command"
import { useMicSelector } from "./context"
const props = defineProps<{
/** The device id. */
value: string
class?: HTMLAttributes["class"]
}>()
const { value: selected, setValue, setOpen } = useMicSelector("MicSelectorItem")
function handleSelect() {
setValue(props.value)
setOpen(false)
}
</script>
<template>
<CommandItem
:value="props.value"
:data-checked="props.value === selected"
:class="cn(props.class)"
@select="handleSelect"
>
<slot />
<Check class="ml-auto opacity-0 group-data-[checked=true]/command-item:opacity-100" />
</CommandItem>
</template>
registry/components/ai/mic-selector/MicSelectorLabel.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{
device: MediaDeviceInfo
/** Show the hardware id (`1a2b:3c4d`) in muted mono after the name. */
showId?: boolean
class?: HTMLAttributes["class"]
}>(), { showId: true })
const deviceIdRegex = /\(([\da-f]{4}:[\da-f]{4})\)$/i
const parsed = computed(() => {
const matches = props.device.label.match(deviceIdRegex)
if (!matches)
return { name: props.device.label, deviceId: null }
return { name: props.device.label.replace(deviceIdRegex, "").trim(), deviceId: matches[1] }
})
</script>
<template>
<span :class="cn('flex min-w-0 flex-1 items-center gap-2', props.class)">
<span class="truncate">{{ parsed.name }}</span>
<span
v-if="parsed.deviceId && showId"
class="ml-auto shrink-0 font-mono text-[11.5px] text-muted-foreground"
>{{ parsed.deviceId }}</span>
</span>
</template>
registry/components/ai/mic-selector/MicSelectorList.vue
<script setup lang="ts">
import { CommandGroup, CommandList } from "@/registry/edmi/ui/command"
import { useMicSelector } from "./context"
const { devices } = useMicSelector("MicSelectorList")
</script>
<template>
<CommandList>
<!-- Command items must sit inside a group (see ui/command). -->
<CommandGroup>
<slot :devices="devices" />
</CommandGroup>
</CommandList>
</template>
registry/components/ai/mic-selector/MicSelectorTrigger.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ChevronsUpDown } from "@lucide/vue"
import { useResizeObserver } from "@vueuse/core"
import { ref } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { PopoverTrigger } from "@/registry/edmi/ui/popover"
import { useMicSelector } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { setWidth } = useMicSelector("MicSelectorTrigger")
const triggerRef = ref<InstanceType<typeof Button> | null>(null)
// Track the trigger width so the list below matches it.
useResizeObserver(triggerRef, (entries) => {
const entry = entries[0]
if (!entry)
return
const next = (entry.target as HTMLElement).offsetWidth
if (next)
setWidth(next)
})
</script>
<template>
<PopoverTrigger as-child>
<Button
ref="triggerRef"
data-slot="ai-mic-selector-trigger"
variant="outline"
:class="cn('justify-start font-normal', props.class)"
>
<slot />
<ChevronsUpDown class="ml-auto size-3.5 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
</template>
registry/components/ai/mic-selector/MicSelectorValue.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useMicSelector } from "./context"
import MicSelectorLabel from "./MicSelectorLabel.vue"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { devices, value } = useMicSelector("MicSelectorValue")
const currentDevice = computed(() => devices.value.find((d) => d.deviceId === value.value))
</script>
<template>
<span
v-if="!currentDevice"
:class="cn('flex-1 truncate text-left text-muted-foreground', props.class)"
>
Select microphone...
</span>
<MicSelectorLabel
v-else
:class="cn('flex-1 truncate text-left', props.class)"
:device="currentDevice"
:show-id="false"
/>
</template>
registry/components/ai/mic-selector/context.ts
import type { InjectionKey, Ref } from "vue";
import { inject } from "vue";
export interface MicSelectorContextValue {
devices: Ref<MediaDeviceInfo[]>;
value: Ref<string | undefined>;
setValue: (value: string | undefined) => void;
open: Ref<boolean>;
setOpen: (open: boolean) => void;
width: Ref<number>;
setWidth: (width: number) => void;
}
export const MicSelectorKey: InjectionKey<MicSelectorContextValue> =
Symbol("MicSelector");
export function useMicSelector(componentName: string): MicSelectorContextValue {
const context = inject(MicSelectorKey);
if (!context) {
throw new Error(`${componentName} must be used within MicSelector`);
}
return context;
}
registry/components/ai/mic-selector/useAudioDevices.ts
import { onMounted, onUnmounted, ref } from "vue";
/** Enumerates the audio inputs; `loadDevices` asks for microphone permission so the labels are filled in. */
export function useAudioDevices() {
const devices = ref<MediaDeviceInfo[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const hasPermission = ref(false);
async function listAudioInputs() {
const deviceList = await navigator.mediaDevices.enumerateDevices();
return deviceList.filter((device) => device.kind === "audioinput");
}
async function loadDevicesWithoutPermission() {
try {
loading.value = true;
error.value = null;
devices.value = await listAudioInputs();
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to get audio devices";
error.value = message;
console.error("Error getting audio devices:", message);
} finally {
loading.value = false;
}
}
async function loadDevicesWithPermission() {
if (loading.value) return;
try {
loading.value = true;
error.value = null;
const tempStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
for (const track of tempStream.getTracks()) track.stop();
devices.value = await listAudioInputs();
hasPermission.value = true;
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to get audio devices";
error.value = message;
console.error("Error getting audio devices:", message);
} finally {
loading.value = false;
}
}
function handleDeviceChange() {
if (hasPermission.value) loadDevicesWithPermission();
else loadDevicesWithoutPermission();
}
onMounted(() => {
loadDevicesWithoutPermission();
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
});
onUnmounted(() => {
navigator.mediaDevices.removeEventListener(
"devicechange",
handleDeviceChange,
);
});
return {
devices,
error,
hasPermission,
loadDevices: loadDevicesWithPermission,
loading,
};
}
registry/components/ai/mic-selector/index.ts
export * from "./context";
export { default as MicSelector } from "./MicSelector.vue";
export { default as MicSelectorContent } from "./MicSelectorContent.vue";
export { default as MicSelectorEmpty } from "./MicSelectorEmpty.vue";
export { default as MicSelectorInput } from "./MicSelectorInput.vue";
export { default as MicSelectorItem } from "./MicSelectorItem.vue";
export { default as MicSelectorLabel } from "./MicSelectorLabel.vue";
export { default as MicSelectorList } from "./MicSelectorList.vue";
export { default as MicSelectorTrigger } from "./MicSelectorTrigger.vue";
export { default as MicSelectorValue } from "./MicSelectorValue.vue";
export * from "./useAudioDevices";
src/lib/registry/ai/mic-selector/mic-selector-content.svelte
<script lang="ts">
import { Command } from "$lib/registry/ui/command/index.js";
import { PopoverContent } from "$lib/registry/ui/popover/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps, Snippet } from "svelte";
import { useMicSelector } from "./use-mic-selector.svelte.js";
let {
class: className,
children,
popoverOptions,
...restProps
}: Omit<ComponentProps<typeof Command>, "children"> & {
children?: Snippet;
popoverOptions?: Omit<ComponentProps<typeof PopoverContent>, "children">;
} = $props();
const ctx = useMicSelector("MicSelectorContent");
</script>
<PopoverContent
data-slot="ai-mic-selector-content"
align="start"
{...popoverOptions}
class={cn("w-auto gap-0 overflow-hidden p-0", popoverOptions?.class)}
style="width: {ctx.width}px; min-width: 320px;"
>
<Command
class={cn("rounded-xl **:data-[slot=command-input-wrapper]:h-10", className)}
{...restProps}
>
{@render children?.()}
</Command>
</PopoverContent>
src/lib/registry/ai/mic-selector/mic-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 microphone found.
{/if}
</CommandEmpty>
src/lib/registry/ai/mic-selector/mic-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 microphones..." {...restProps} />
src/lib/registry/ai/mic-selector/mic-selector-item.svelte
<script lang="ts">
import { CommandItem } from "$lib/registry/ui/command/index.js";
import type { ComponentProps } from "svelte";
import { useMicSelector } from "./use-mic-selector.svelte.js";
// `value` is the device id; the selected item shows the check.
let {
value,
onSelect,
children,
...restProps
}: ComponentProps<typeof CommandItem> = $props();
const ctx = useMicSelector("MicSelectorItem");
</script>
<CommandItem
{value}
data-checked={value !== undefined && value === ctx.value}
onSelect={() => {
ctx.setValue(value);
ctx.setOpen(false);
onSelect?.();
}}
{...restProps}
>
{@render children?.()}
</CommandItem>
src/lib/registry/ai/mic-selector/mic-selector-label.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
device,
showId = true,
class: className,
...restProps
}: HTMLAttributes<HTMLSpanElement> & {
device: MediaDeviceInfo;
/** Show the hardware id (`1a2b:3c4d`) in muted mono after the name. */
showId?: boolean;
} = $props();
const deviceIdRegex = /\(([\da-f]{4}:[\da-f]{4})\)$/i;
const parsed = $derived.by(() => {
const matches = device.label.match(deviceIdRegex);
if (!matches) return { name: device.label, deviceId: null };
return { name: device.label.replace(deviceIdRegex, "").trim(), deviceId: matches[1] };
});
</script>
<span class={cn("flex min-w-0 flex-1 items-center gap-2", className)} {...restProps}>
<span class="truncate">{parsed.name}</span>
{#if parsed.deviceId && showId}
<span class="ml-auto shrink-0 font-mono text-[11.5px] text-muted-foreground">{parsed.deviceId}</span>
{/if}
</span>
src/lib/registry/ai/mic-selector/mic-selector-list.svelte
<script lang="ts">
import { CommandList } from "$lib/registry/ui/command/index.js";
import type { ComponentProps, Snippet } from "svelte";
import { useMicSelector } from "./use-mic-selector.svelte.js";
// `children` receives the device list: `{#snippet children(devices)}…{/snippet}`.
let {
children,
...restProps
}: Omit<ComponentProps<typeof CommandList>, "children"> & {
children?: Snippet<[MediaDeviceInfo[]]>;
} = $props();
const ctx = useMicSelector("MicSelectorList");
</script>
<CommandList {...restProps}>
{@render children?.(ctx.devices)}
</CommandList>
src/lib/registry/ai/mic-selector/mic-selector-trigger.svelte
<script lang="ts">
import CaretUpDownIcon from "phosphor-svelte/lib/CaretUpDown";
import { Button } from "$lib/registry/ui/button/index.js";
import { PopoverTrigger } from "$lib/registry/ui/popover/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps, Snippet } from "svelte";
import { useMicSelector } from "./use-mic-selector.svelte.js";
let {
class: className,
children,
...restProps
}: Omit<ComponentProps<typeof Button>, "children"> & { children?: Snippet } = $props();
const ctx = useMicSelector("MicSelectorTrigger");
let el = $state<HTMLElement | null>(null);
// Track the trigger width so the list below matches it.
$effect(() => {
if (!el) return;
const target = el;
const observer = new ResizeObserver(() => {
if (target.offsetWidth) ctx.setWidth(target.offsetWidth);
});
observer.observe(target);
return () => observer.disconnect();
});
</script>
<PopoverTrigger data-slot="ai-mic-selector-trigger">
{#snippet child({ props })}
<Button
bind:ref={el}
variant="outline"
class={cn("justify-start font-normal", className)}
{...props}
{...restProps}
>
{@render children?.()}
<CaretUpDownIcon
class="ml-auto size-3.5 shrink-0 text-muted-foreground"
/>
</Button>
{/snippet}
</PopoverTrigger>
src/lib/registry/ai/mic-selector/mic-selector-value.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import MicSelectorLabel from "./mic-selector-label.svelte";
import { useMicSelector } from "./use-mic-selector.svelte.js";
let { class: className, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
const ctx = useMicSelector("MicSelectorValue");
const current = $derived(ctx.devices.find((d) => d.deviceId === ctx.value));
</script>
{#if current}
<MicSelectorLabel
device={current}
showId={false}
class={cn("flex-1 truncate text-left", className)}
{...restProps}
/>
{:else}
<span class={cn("flex-1 truncate text-left text-muted-foreground", className)} {...restProps}>
Select microphone...
</span>
{/if}
src/lib/registry/ai/mic-selector/mic-selector.svelte
<script lang="ts">
import { Popover } from "$lib/registry/ui/popover/index.js";
import type { ComponentProps } from "svelte";
import { setMicSelectorContext } from "./use-mic-selector.svelte.js";
import { useAudioDevices } from "./use-audio-devices.svelte.js";
// Built on ui/popover + ui/command (DESIGN §5b). `bind:value` is the selected device id.
let {
value = $bindable(),
open = $bindable(false),
devices: devicesProp,
children,
...restProps
}: ComponentProps<typeof Popover> & {
value?: string;
/**
* ✦ Devices to list. Omit to enumerate the audio inputs (asks for microphone permission when the
* list opens).
*/
devices?: MediaDeviceInfo[];
} = $props();
let width = $state(200);
const audio = useAudioDevices();
const devices = $derived(devicesProp ?? audio.devices);
$effect(() => {
if (!devicesProp && open && !audio.hasPermission && !audio.loading) audio.loadDevices();
});
setMicSelectorContext({
get devices() {
return devices;
},
get value() {
return value;
},
setValue: (next) => {
value = next;
},
setOpen: (next) => {
open = next;
},
get width() {
return width;
},
setWidth: (next) => {
width = next;
},
});
</script>
<Popover bind:open {...restProps}>
{@render children?.()}
</Popover>
src/lib/registry/ai/mic-selector/use-audio-devices.svelte.ts
import { onMount } from "svelte";
/** Enumerates the audio inputs; `loadDevices` asks for microphone permission so the labels are filled in. */
export class AudioDevices {
devices = $state<MediaDeviceInfo[]>([]);
loading = $state(true);
error = $state<string | null>(null);
hasPermission = $state(false);
async #list() {
const deviceList = await navigator.mediaDevices.enumerateDevices();
return deviceList.filter((device) => device.kind === "audioinput");
}
loadWithoutPermission = async () => {
try {
this.loading = true;
this.error = null;
this.devices = await this.#list();
} catch (err) {
this.error =
err instanceof Error ? err.message : "Failed to get audio devices";
console.error("Error getting audio devices:", this.error);
} finally {
this.loading = false;
}
};
loadDevices = async () => {
if (this.loading) return;
try {
this.loading = true;
this.error = null;
const tempStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
for (const track of tempStream.getTracks()) track.stop();
this.devices = await this.#list();
this.hasPermission = true;
} catch (err) {
this.error =
err instanceof Error ? err.message : "Failed to get audio devices";
console.error("Error getting audio devices:", this.error);
} finally {
this.loading = false;
}
};
}
export function useAudioDevices() {
const audio = new AudioDevices();
onMount(() => {
audio.loadWithoutPermission();
const onChange = () => {
if (audio.hasPermission) {
audio.loading = false;
audio.loadDevices();
} else audio.loadWithoutPermission();
};
navigator.mediaDevices.addEventListener("devicechange", onChange);
return () =>
navigator.mediaDevices.removeEventListener("devicechange", onChange);
});
return audio;
}
src/lib/registry/ai/mic-selector/use-mic-selector.svelte.ts
import { getContext, setContext } from "svelte";
/** Getter object so consumers stay reactive when the props change. */
export interface MicSelectorContext {
readonly devices: MediaDeviceInfo[];
readonly value: string | undefined;
setValue: (value: string | undefined) => void;
setOpen: (open: boolean) => void;
readonly width: number;
setWidth: (width: number) => void;
}
const KEY = Symbol("ai-mic-selector");
export function setMicSelectorContext(value: MicSelectorContext) {
return setContext(KEY, value);
}
export function useMicSelector(componentName: string): MicSelectorContext {
const context = getContext<MicSelectorContext | undefined>(KEY);
if (!context) {
throw new Error(`${componentName} must be used within MicSelector`);
}
return context;
}
src/lib/registry/ai/mic-selector/index.ts
import Root from "./mic-selector.svelte";
import Content from "./mic-selector-content.svelte";
import Empty from "./mic-selector-empty.svelte";
import Input from "./mic-selector-input.svelte";
import Item from "./mic-selector-item.svelte";
import Label from "./mic-selector-label.svelte";
import List from "./mic-selector-list.svelte";
import Trigger from "./mic-selector-trigger.svelte";
import Value from "./mic-selector-value.svelte";
export {
AudioDevices,
useAudioDevices,
} from "./use-audio-devices.svelte.js";
export {
Content,
Content as MicSelectorContent,
Empty,
Empty as MicSelectorEmpty,
Input,
Input as MicSelectorInput,
Item,
Item as MicSelectorItem,
Label,
Label as MicSelectorLabel,
List,
List as MicSelectorList,
Root,
//
Root as MicSelector,
Trigger,
Trigger as MicSelectorTrigger,
Value,
Value as MicSelectorValue,
};
Part Prop Type Default
MicSelector value, defaultValue, onValueChange device id
MicSelector open, defaultOpen, onOpenChange boolean false
MicSelector devices ✦ MediaDeviceInfo[]; omit to enumerate the audio inputs (asks for permission when the list opens)
MicSelectorList children (devices: MediaDeviceInfo[]) => ReactNode
MicSelectorItem value device id
MicSelectorLabel device, showId MediaDeviceInfo, boolean true

useAudioDevices() is exported for custom pickers.

  • devices prop, so a demo or a server-provided list needs no permission prompt.
  • MicSelectorLabel prints the (1a2b:3c4d) suffix as a muted mono id on the right; MicSelectorValue shows the name only.
  • The selected item shows the check; selecting closes the popover. The command highlight is no longer tied to the selected value, so arrow keys and hover do not change the selection.
  • Vue: v-model:value / v-model:open, MicSelectorList slot receives { devices }. Svelte: bind:value / bind:open, the list children snippet receives devices. Items must live inside a command group in Vue (handled by MicSelectorList).