Select Menu
A Discord-inspired string select: combobox trigger, keyboard-driven listbox and a portalled panel.
A complete combobox: a trigger, a search field, a keyboard-driven listbox and a panel that flips when it runs out of room below. This is the option picker from the client, not a styled <select>.
newt-select-menu"use client"
import * as React from "react"
import { SelectMenu } from "@/registry/bases/newt/ui/select-menu"
/*
* Drawn here rather than pulled from an icon package: the registry ships no
* runtime dependency, and a device picker needs exactly two glyphs.
*/
const Monitor = () => (
<svg viewBox="0 0 24 24" className="h-full w-full" aria-hidden="true">
<path
fill="currentColor"
d="M4 5h16a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-6v2h3a1 1 0 1 1 0 2H7a1 1 0 1 1 0-2h3v-2H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm1 2v7h14V7H5Z"
/>
</svg>
)
const Headset = () => (
<svg viewBox="0 0 24 24" className="h-full w-full" aria-hidden="true">
<path
fill="currentColor"
d="M12 3a8 8 0 0 0-8 8v6a3 3 0 0 0 3 3h1a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H6v-1a6 6 0 1 1 12 0v1h-2a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1a3 3 0 0 0 3-3v-6a8 8 0 0 0-8-8Z"
/>
</svg>
)
const OPTIONS = [
{
value: "default",
label: "System Default",
description: "Speakers (Built-in Output)",
note: "(Speakers)",
icon: <Monitor />,
},
{
value: "speakers",
label: "Speakers",
description: "Built-in Output",
icon: <Monitor />,
},
{
value: "headset",
label: "Headset",
description: "USB Audio Device",
icon: <Headset />,
},
{
value: "bluetooth",
label: "Bluetooth Headset",
description: "Not connected",
icon: <Headset />,
disabled: true,
},
]
export default function SelectMenuDemo() {
const [value, setValue] = React.useState<string>("default")
return (
<SelectMenu
options={OPTIONS}
value={value}
onValueChange={setValue}
label="Output device"
/>
)
}<script setup lang="ts">
import { h, markRaw, ref } from "vue"
import {
SelectMenu,
type SelectMenuOption,
} from "~~/registry/bases/newt/ui/select-menu"
/*
* Drawn here rather than pulled from an icon package: the registry ships no
* runtime dependency, and a device picker needs exactly two glyphs.
*/
const Monitor = markRaw({
render: () =>
h(
"svg",
{ viewBox: "0 0 24 24", class: "h-full w-full", "aria-hidden": "true" },
[
h("path", {
fill: "currentColor",
d: "M4 5h16a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-6v2h3a1 1 0 1 1 0 2H7a1 1 0 1 1 0-2h3v-2H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm1 2v7h14V7H5Z",
}),
]
),
})
const Headset = markRaw({
render: () =>
h(
"svg",
{ viewBox: "0 0 24 24", class: "h-full w-full", "aria-hidden": "true" },
[
h("path", {
fill: "currentColor",
d: "M12 3a8 8 0 0 0-8 8v6a3 3 0 0 0 3 3h1a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1H6v-1a6 6 0 1 1 12 0v1h-2a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1a3 3 0 0 0 3-3v-6a8 8 0 0 0-8-8Z",
}),
]
),
})
const OPTIONS: SelectMenuOption[] = [
{
value: "default",
label: "System Default",
description: "Speakers (Built-in Output)",
note: "(Speakers)",
icon: Monitor,
},
{
value: "speakers",
label: "Speakers",
description: "Built-in Output",
icon: Monitor,
},
{
value: "headset",
label: "Headset",
description: "USB Audio Device",
icon: Headset,
},
{
value: "bluetooth",
label: "Bluetooth Headset",
description: "Not connected",
icon: Headset,
disabled: true,
},
]
const value = ref("default")
</script>
<template>
<SelectMenu v-model="value" :options="OPTIONS" label="Output device" />
</template>Installation
pnpm dlx newtui@latest add select-menunpx newtui@latest add select-menuyarn dlx newtui@latest add select-menubunx newtui@latest add select-menuCopy the source into your project
"use client"
import * as React from "react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
export interface SelectMenuOption {
value: string
label: string
/** Secondary line under the label. */
description?: string
/**
* Rendered before the label, in the row and on the trigger once chosen —
* what the client's device pickers use to tell a microphone from a monitor
* at a glance. Sized by the menu, so pass the glyph and nothing else.
*/
icon?: React.ReactNode
/**
* A muted aside after the label on the trigger, for what the option resolves
* to. It is the first thing allowed to truncate.
*/
note?: string
disabled?: boolean
}
/**
* What a select attached to a message may carry. The platform rejects a
* payload with more, so a menu that rendered them would be showing something
* that cannot exist.
*/
export const SELECT_MENU_MAX_OPTIONS = 25
/**
* Where the menu is: in a message, or in a settings panel. The two are not the
* same control — a message select caps its list, shows the scrollbar it needs
* to say the list continues, and marks nothing as chosen, because the choice
* is submitted rather than kept. A settings select has no cap and keeps a tick
* against the row that is in force.
*/
export type SelectMenuVariant = "settings" | "message"
export interface SelectMenuProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"onSelect" | "defaultValue"
> {
options: readonly SelectMenuOption[]
variant?: SelectMenuVariant
/** Controlled selection. Leave undefined to let the menu own it. */
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
placeholder?: string
disabled?: boolean
/** Accessible name of the trigger and the listbox. */
label?: string
}
/** Panel geometry, measured from the trigger each time the menu opens. */
interface PanelBox {
left: number
width: number
top?: number
bottom?: number
}
const SelectMenu = React.forwardRef<HTMLDivElement, SelectMenuProps>(
(
{
className,
options: given,
variant = "settings",
value,
defaultValue,
onValueChange,
placeholder = "Make a selection",
disabled = false,
label = "Select an option",
...props
},
ref
) => {
/*
* The cap is applied here rather than left to the caller: everything below
* — the keyboard, the active index, the ids — counts rows, and a row the
* platform would reject should not be one of them.
*/
const options = React.useMemo(
() =>
variant === "message" ? given.slice(0, SELECT_MENU_MAX_OPTIONS) : given,
[given, variant]
)
const id = React.useId()
const listboxId = `${id}-listbox`
const optionId = (index: number) => `${id}-option-${index}`
const triggerRef = React.useRef<HTMLButtonElement>(null)
const panelRef = React.useRef<HTMLDivElement>(null)
const [open, setOpen] = React.useState(false)
const [active, setActive] = React.useState(-1)
const [box, setBox] = React.useState<PanelBox | null>(null)
const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
const selected = value ?? uncontrolled
const selectable = React.useCallback(
(index: number) => Boolean(options[index]) && !options[index].disabled,
[options]
)
const firstSelectable = React.useCallback(
() => options.findIndex((option) => !option.disabled),
[options]
)
const lastSelectable = React.useCallback(() => {
for (let i = options.length - 1; i >= 0; i--) {
if (!options[i].disabled) return i
}
return -1
}, [options])
const step = React.useCallback(
(from: number, direction: 1 | -1) => {
const count = options.length
for (let i = 1; i <= count; i++) {
const index = (from + direction * i + count * i) % count
if (selectable(index)) return index
}
return from
},
[options.length, selectable]
)
/*
* The panel is portalled to the body so an `overflow: hidden` ancestor
* cannot clip it, which means it is positioned from measured coordinates
* and flipped above the trigger when the space below runs out.
*/
const measure = React.useCallback(() => {
const trigger = triggerRef.current
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const below = window.innerHeight - rect.bottom
const height = panelRef.current?.offsetHeight ?? 0
setBox(
height > below && rect.top > below
? {
left: rect.left,
width: rect.width,
bottom: window.innerHeight - rect.top + 4,
}
: { left: rect.left, width: rect.width, top: rect.bottom + 4 }
)
}, [])
React.useLayoutEffect(() => {
if (!open) return
measure()
}, [open, measure])
React.useEffect(() => {
if (!open) return
const update = () => measure()
/* Capture phase: a nested scroller never fires a window scroll event. */
document.addEventListener("scroll", update, {
capture: true,
passive: true,
})
window.addEventListener("resize", update)
return () => {
document.removeEventListener("scroll", update, { capture: true })
window.removeEventListener("resize", update)
}
}, [open, measure])
React.useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (
triggerRef.current?.contains(target) ||
panelRef.current?.contains(target)
) {
return
}
setOpen(false)
}
document.addEventListener("mousedown", onPointerDown)
return () => document.removeEventListener("mousedown", onPointerDown)
}, [open])
React.useEffect(() => {
if (active < 0) return
panelRef.current
?.querySelector(`#${CSS.escape(optionId(active))}`)
?.scrollIntoView({ block: "nearest" })
// eslint-disable-next-line react-hooks/exhaustive-deps -- optionId is derived from a stable id
}, [active])
const openMenu = () => {
if (disabled || open) return
setOpen(true)
const current = options.findIndex((option) => option.value === selected)
setActive(
current >= 0 && selectable(current) ? current : firstSelectable()
)
}
const closeMenu = () => {
setOpen(false)
setActive(-1)
}
const select = (index: number) => {
const option = options[index]
if (!option || option.disabled) return
if (value === undefined) setUncontrolled(option.value)
onValueChange?.(option.value)
closeMenu()
triggerRef.current?.focus()
}
const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
event.preventDefault()
if (!open) {
openMenu()
return
}
setActive((current) =>
step(current, event.key === "ArrowDown" ? 1 : -1)
)
return
}
case "Home":
if (!open) return
event.preventDefault()
setActive(firstSelectable())
return
case "End":
if (!open) return
event.preventDefault()
setActive(lastSelectable())
return
case "Enter":
case " ":
event.preventDefault()
if (open) select(active)
else openMenu()
return
case "Escape":
if (!open) return
event.preventDefault()
closeMenu()
}
}
const selectedOption = options.find((option) => option.value === selected)
const panel =
open && box ? (
<div
ref={panelRef}
style={{
position: "fixed",
left: box.left,
width: box.width,
top: box.top,
bottom: box.bottom,
zIndex: 1002,
}}
/* Keep focus on the trigger: aria-activedescendant drives the list. */
onMouseDown={(event) => event.preventDefault()}
className="rounded-md border border-newt-border bg-newt-bg-floating p-1.5 shadow-elevation-high"
>
<ul
id={listboxId}
role="listbox"
aria-label={label}
className={cn(
"m-0 list-none overflow-y-auto p-0",
/*
* A message select shows its scrollbar rather than hiding it
* until the pointer moves: the list is capped, so the bar is
* what says there is more of it below the fold.
*/
variant === "message"
? cn(
"max-h-[300px]",
"[scrollbar-width:thin] [scrollbar-color:var(--newt-bg-active)_transparent]",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-2 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-newt-bg-active [&::-webkit-scrollbar-thumb]:bg-clip-padding",
"[&::-webkit-scrollbar-thumb:hover]:bg-newt-text-muted [&::-webkit-scrollbar-thumb:hover]:bg-clip-padding"
)
: "max-h-80"
)}
>
{options.map((option, index) => (
<li
key={option.value}
id={optionId(index)}
role="option"
aria-selected={option.value === selected}
aria-disabled={option.disabled || undefined}
onMouseEnter={() => {
if (!option.disabled) setActive(index)
}}
onClick={() => select(index)}
className={cn(
"flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2 text-sm text-newt-text-secondary",
index === active &&
!option.disabled &&
"bg-newt-brand text-white",
option.value === selected && "text-newt-text-primary",
option.disabled &&
"cursor-not-allowed bg-transparent text-newt-text-muted opacity-50"
)}
>
{option.icon ? (
<span
aria-hidden="true"
className={cn(
"flex h-[18px] w-[18px] shrink-0 items-center justify-center text-newt-text-secondary",
index === active && !option.disabled && "text-white"
)}
>
{option.icon}
</span>
) : null}
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description ? (
<span
className={cn(
"truncate text-xs text-newt-text-muted",
index === active && !option.disabled && "text-white"
)}
>
{option.description}
</span>
) : null}
</span>
{/*
* Only the settings menu ticks a row. In a message the choice
* is submitted rather than kept, so there is no standing
* selection for a tick to be about — `aria-selected` still
* carries it for anything that asks.
*/}
{option.value === selected && variant === "settings" ? (
<span
aria-hidden="true"
className={cn(
"ms-auto flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full",
// On the highlighted row the disc would dissolve into the
// background, so it swaps with the tick.
index === active && !option.disabled
? "bg-white text-newt-brand"
: "bg-newt-brand text-white"
)}
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5">
<path
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
d="m6 12.5 4 4 8-9"
/>
</svg>
</span>
) : null}
</li>
))}
</ul>
</div>
) : null
return (
<div
ref={ref}
className={cn("relative w-full max-w-[400px]", className)}
{...props}
>
<button
ref={triggerRef}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && active >= 0 ? optionId(active) : undefined
}
aria-label={label}
disabled={disabled}
onClick={() => (open ? closeMenu() : openMenu())}
onKeyDown={onKeyDown}
className="flex min-h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-sm border border-newt-border bg-newt-bg-input px-2 font-sans text-sm font-medium text-newt-text-primary hover:border-newt-bg-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{selectedOption?.icon ? (
<span
aria-hidden="true"
className="flex h-[18px] w-[18px] shrink-0 items-center justify-center text-newt-text-secondary"
>
{selectedOption.icon}
</span>
) : null}
<span
className={cn(
"truncate",
!selectedOption && "text-newt-text-muted"
)}
>
{selectedOption?.label ?? placeholder}
</span>
{selectedOption?.note ? (
<span className="truncate font-normal text-newt-text-muted">
{selectedOption.note}
</span>
) : null}
</span>
{/* Points down when closed, flips when the listbox opens. */}
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
className={cn("h-[18px] w-[18px] shrink-0", open && "rotate-180")}
>
<path
fill="currentColor"
d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
/>
</svg>
</button>
{panel ? createPortal(panel, document.body) : null}
</div>
)
}
)
SelectMenu.displayName = "SelectMenu"
export { SelectMenu }<script setup lang="ts">
import type { CSSProperties, HTMLAttributes } from "vue"
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from "vue"
import { cn } from "@/lib/utils"
import {
SELECT_MENU_MAX_OPTIONS,
type SelectMenuOption,
type SelectMenuVariant,
} from "."
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
options: readonly SelectMenuOption[]
variant?: SelectMenuVariant
placeholder?: string
disabled?: boolean
/** Accessible name of the trigger and the listbox. */
label?: string
}>(),
{
variant: "settings",
placeholder: "Make a selection",
disabled: false,
label: "Select an option",
}
)
/*
* The cap is applied here rather than left to the caller: everything below —
* the keyboard, the active index, the ids — counts rows, and a row the
* platform would reject should not be one of them.
*/
const options = computed(() =>
props.variant === "message"
? props.options.slice(0, SELECT_MENU_MAX_OPTIONS)
: props.options
)
const emit = defineEmits<{ select: [value: string] }>()
const selected = defineModel<string | undefined>({ default: undefined })
const id = useId()
const listboxId = `${id}-listbox`
const optionId = (index: number) => `${id}-option-${index}`
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const open = ref(false)
const active = ref(-1)
const box = ref<CSSProperties | null>(null)
const selectedOption = computed(() =>
options.value.find((option) => option.value === selected.value)
)
const selectable = (index: number) => {
const option = options.value[index]
return Boolean(option) && !option?.disabled
}
const firstSelectable = () => options.value.findIndex((o) => !o.disabled)
const lastSelectable = () => {
for (let i = options.value.length - 1; i >= 0; i--) {
if (!options.value[i]?.disabled) return i
}
return -1
}
const step = (from: number, direction: 1 | -1) => {
const count = options.value.length
for (let i = 1; i <= count; i++) {
const index = (from + direction * i + count * i) % count
if (selectable(index)) return index
}
return from
}
/*
* The panel is teleported to the body so an `overflow: hidden` ancestor cannot
* clip it, which means it is positioned from measured coordinates and flipped
* above the trigger when the space below runs out.
*/
function measure() {
const trigger = triggerRef.value
if (!trigger) return
const rect = trigger.getBoundingClientRect()
const below = window.innerHeight - rect.bottom
const height = panelRef.value?.offsetHeight ?? 0
box.value =
height > below && rect.top > below
? {
position: "fixed",
left: `${rect.left}px`,
width: `${rect.width}px`,
bottom: `${window.innerHeight - rect.top + 4}px`,
zIndex: 1002,
}
: {
position: "fixed",
left: `${rect.left}px`,
width: `${rect.width}px`,
top: `${rect.bottom + 4}px`,
zIndex: 1002,
}
}
function scrollActiveIntoView() {
void nextTick(() => {
if (active.value < 0) return
panelRef.value
?.querySelector(`#${CSS.escape(optionId(active.value))}`)
?.scrollIntoView({ block: "nearest" })
})
}
function openMenu() {
if (props.disabled || open.value) return
open.value = true
const current = options.value.findIndex(
(option) => option.value === selected.value
)
active.value =
current >= 0 && selectable(current) ? current : firstSelectable()
void nextTick(() => {
measure()
scrollActiveIntoView()
})
}
function closeMenu() {
if (!open.value) return
open.value = false
active.value = -1
}
function select(index: number) {
const option = options.value[index]
if (!option || option.disabled) return
selected.value = option.value
emit("select", option.value)
closeMenu()
triggerRef.value?.focus()
}
function onKeydown(event: KeyboardEvent) {
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
event.preventDefault()
if (!open.value) {
openMenu()
return
}
active.value = step(active.value, event.key === "ArrowDown" ? 1 : -1)
scrollActiveIntoView()
return
}
case "Home":
if (!open.value) return
event.preventDefault()
active.value = firstSelectable()
scrollActiveIntoView()
return
case "End":
if (!open.value) return
event.preventDefault()
active.value = lastSelectable()
scrollActiveIntoView()
return
case "Enter":
case " ":
event.preventDefault()
if (open.value) select(active.value)
else openMenu()
return
case "Escape":
if (!open.value) return
event.preventDefault()
closeMenu()
}
}
function onPointerDown(event: MouseEvent) {
const target = event.target
if (!(target instanceof Node)) return
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) {
return
}
closeMenu()
}
/* Capture phase: a nested scroller never fires a window scroll event. */
function onScroll() {
if (open.value) measure()
}
watch(open, (isOpen) => {
if (isOpen) {
document.addEventListener("mousedown", onPointerDown)
document.addEventListener("scroll", onScroll, {
capture: true,
passive: true,
})
window.addEventListener("resize", onScroll)
return
}
document.removeEventListener("mousedown", onPointerDown)
document.removeEventListener("scroll", onScroll, { capture: true })
window.removeEventListener("resize", onScroll)
})
onBeforeUnmount(() => {
document.removeEventListener("mousedown", onPointerDown)
document.removeEventListener("scroll", onScroll, { capture: true })
window.removeEventListener("resize", onScroll)
})
</script>
<template>
<div :class="cn('relative w-full max-w-[400px]', props.class)">
<button
ref="triggerRef"
type="button"
role="combobox"
aria-haspopup="listbox"
:aria-expanded="open"
:aria-controls="listboxId"
:aria-activedescendant="
open && active >= 0 ? optionId(active) : undefined
"
:aria-label="props.label"
:disabled="props.disabled"
class="flex min-h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-sm border border-newt-border bg-newt-bg-input px-2 font-sans text-sm font-medium text-newt-text-primary hover:border-newt-bg-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50"
@click="open ? closeMenu() : openMenu()"
@keydown="onKeydown"
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
v-if="selectedOption?.icon"
aria-hidden="true"
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center text-newt-text-secondary"
>
<component :is="selectedOption.icon" />
</span>
<span
:class="cn('truncate', !selectedOption && 'text-newt-text-muted')"
>
{{ selectedOption?.label ?? props.placeholder }}
</span>
<span
v-if="selectedOption?.note"
class="truncate font-normal text-newt-text-muted"
>
{{ selectedOption.note }}
</span>
</span>
<!-- Points down when closed, flips when the listbox opens. -->
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
:class="cn('h-[18px] w-[18px] shrink-0', open && 'rotate-180')"
>
<path
fill="currentColor"
d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"
/>
</svg>
</button>
<Teleport to="body">
<div
v-if="open"
ref="panelRef"
:style="box ?? undefined"
class="rounded-md border border-newt-border bg-newt-bg-floating p-1.5 shadow-elevation-high"
@mousedown.prevent
>
<ul
:id="listboxId"
role="listbox"
:aria-label="props.label"
:class="
cn(
'm-0 list-none overflow-y-auto p-0',
/*
* A message select shows its scrollbar rather than hiding it
* until the pointer moves: the list is capped, so the bar is
* what says there is more of it below the fold.
*/
props.variant === 'message'
? cn(
'max-h-[300px]',
'[scrollbar-width:thin] [scrollbar-color:var(--newt-bg-active)_transparent]',
'[&::-webkit-scrollbar]:w-2',
'[&::-webkit-scrollbar-track]:bg-transparent',
'[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-2 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-newt-bg-active [&::-webkit-scrollbar-thumb]:bg-clip-padding',
'[&::-webkit-scrollbar-thumb:hover]:bg-newt-text-muted [&::-webkit-scrollbar-thumb:hover]:bg-clip-padding'
)
: 'max-h-80'
)
"
>
<li
v-for="(option, index) of options"
:id="optionId(index)"
:key="option.value"
role="option"
:aria-selected="option.value === selected"
:aria-disabled="option.disabled || undefined"
:class="
cn(
'flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2 text-sm text-newt-text-secondary',
index === active &&
!option.disabled &&
'bg-newt-brand text-white',
option.value === selected && 'text-newt-text-primary',
option.disabled &&
'cursor-not-allowed bg-transparent text-newt-text-muted opacity-50'
)
"
@mouseenter="!option.disabled && (active = index)"
@click="select(index)"
>
<span
v-if="option.icon"
aria-hidden="true"
:class="
cn(
'flex h-[18px] w-[18px] shrink-0 items-center justify-center text-newt-text-secondary',
index === active && !option.disabled && 'text-white'
)
"
>
<component :is="option.icon" />
</span>
<span class="flex min-w-0 flex-col">
<span class="truncate">{{ option.label }}</span>
<span
v-if="option.description"
:class="
cn(
'truncate text-xs text-newt-text-muted',
index === active && !option.disabled && 'text-white'
)
"
>
{{ option.description }}
</span>
</span>
<!--
Only the settings menu ticks a row. In a message the choice is
submitted rather than kept, so there is no standing selection for
a tick to be about — `aria-selected` still carries it for
anything that asks.
-->
<span
v-if="option.value === selected && props.variant === 'settings'"
aria-hidden="true"
:class="
cn(
'ms-auto flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full',
index === active && !option.disabled
? 'bg-white text-newt-brand'
: 'bg-newt-brand text-white'
)
"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5">
<path
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
d="m6 12.5 4 4 8-9"
/>
</svg>
</span>
</li>
</ul>
</div>
</Teleport>
</div>
</template>import type { Component } from "vue"
export { default as SelectMenu } from "./SelectMenu.vue"
export interface SelectMenuOption {
value: string
label: string
/** Secondary line under the label. */
description?: string
/**
* Rendered before the label, in the row and on the trigger once chosen —
* what the client's device pickers use to tell a microphone from a monitor
* at a glance. The Vue analogue of React's node: pass a component, and the
* menu sizes it.
*/
icon?: Component
/**
* A muted aside after the label on the trigger, for what the option resolves
* to. It is the first thing allowed to truncate.
*/
note?: string
disabled?: boolean
}
/**
* What a select attached to a message may carry. The platform rejects a
* payload with more, so a menu that rendered them would be showing something
* that cannot exist.
*/
export const SELECT_MENU_MAX_OPTIONS = 25
/**
* Where the menu is: in a message, or in a settings panel. The two are not the
* same control — a message select caps its list, shows the scrollbar it needs
* to say the list continues, and marks nothing as chosen, because the choice
* is submitted rather than kept. A settings select has no cap and keeps a tick
* against the row that is in force.
*/
export type SelectMenuVariant = "settings" | "message"Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { SelectMenu } from "@/components/ui/select-menu"
export function Example() {
return <SelectMenu />
}<script setup lang="ts">
import { SelectMenu } from "@/components/ui/select-menu"
</script>
<template>
<SelectMenu />
</template>API reference
SelectMenu
options?SelectOption[]
The choices: `{ value, label, description?, icon?, disabled? }`.
SelectOption[]value?string
The selected value, when you control it.
stringdefaultValue?string
The value selected on first render.
stringonValueChange?(value: string) => void
Called with the chosen value.
(value: string) => voidplaceholder?string
Shown on the trigger while nothing is selected.
stringsearchable?boolean
Adds the filter field above the list.
booleanfalsedisabled?boolean
Makes the trigger inert.
booleanfalseEvery part also accepts the native attributes of the element it renders, and a className that is merged rather than replaced.
Accessibility
The trigger is a combobox with aria-expanded and aria-controls; the panel is a listbox whose options carry aria-selected. Up and Down move the active option, Home and End jump to the ends, Enter selects, Escape closes and returns focus to the trigger. The active option is tracked with aria-activedescendant, so focus never leaves the trigger.
Tokens
Select Menu reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-select-menu.