Skip to content

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>.

Root class 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-menu
npx newtui@latest add select-menu
yarn dlx newtui@latest add select-menu
bunx newtui@latest add select-menu

Copy 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

PropType
options?SelectOption[]

The choices: `{ value, label, description?, icon?, disabled? }`.

TypeSelectOption[]
value?string

The selected value, when you control it.

Typestring
defaultValue?string

The value selected on first render.

Typestring
onValueChange?(value: string) => void

Called with the chosen value.

Type(value: string) => void
placeholder?string

Shown on the trigger while nothing is selected.

Typestring
searchable?boolean

Adds the filter field above the list.

Typeboolean
Defaultfalse
disabled?boolean

Makes the trigger inert.

Typeboolean
Defaultfalse

Every 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.