Select

Single-select dropdown built on Field, with keyboard navigation.

import { Select, SelectOption }from "@/components/ui"

Roles

 

Full source

The complete component, read from src/components/ui/select.tsx.

select.tsx
"use client";

import { useRef, useState, useEffect, useId } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePopoverPosition, useMounted } from "@/hooks";
import { Field, controlBase, controlClasses } from "./field";

export interface SelectOption {
  value: string;
  label: string;
  disabled?: boolean;
}

interface SelectProps {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  disabled?: boolean;
  options: SelectOption[];
  value: string | null;
  onChange: (value: string) => void;
  placeholder?: string;
}

export function Select({
  id,
  label,
  hint,
  error,
  disabled,
  options,
  value,
  onChange,
  placeholder = "Select…",
}: SelectProps) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const position = usePopoverPosition(triggerRef, open, { matchWidth: true, preferredHeight: 260 });
  const mounted = useMounted();
  const reactId = useId();
  const selectId = id ?? reactId;
  const messageId = `${selectId}-message`;

  const selected = options.find((o) => o.value === value) ?? null;

  useEffect(() => {
    if (!open) return;
    const idx = options.findIndex((o) => o.value === value);
    setActive(idx >= 0 ? idx : 0);
  }, [open, options, value]);

  useEffect(() => {
    if (!open) return;
    const onDown = (e: MouseEvent) => {
      const t = e.target as Node;
      if (!triggerRef.current?.contains(t) && !listRef.current?.contains(t)) setOpen(false);
    };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [open]);

  const choose = (opt: SelectOption) => {
    if (opt.disabled) return;
    onChange(opt.value);
    setOpen(false);
    triggerRef.current?.focus();
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (!open && (e.key === "Enter" || e.key === " " || e.key === "ArrowDown")) {
      e.preventDefault();
      setOpen(true);
      return;
    }
    if (!open) return;
    if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(i + 1, options.length - 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
    else if (e.key === "Enter") { e.preventDefault(); choose(options[active]); }
    else if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
  };

  return (
    <Field label={label} hint={hint} error={error} required={false} htmlFor={selectId} messageId={messageId}>
      <button
        ref={triggerRef}
        id={selectId}
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-haspopup="listbox"
        aria-invalid={Boolean(error) || undefined}
        aria-describedby={error || hint ? messageId : undefined}
        disabled={disabled}
        onClick={() => !disabled && setOpen((o) => !o)}
        onKeyDown={onKeyDown}
        className={cn(controlBase, controlClasses(error, disabled), "flex h-10 items-center justify-between gap-2 px-3 text-left")}
      >
        <span className={cn("truncate", selected ? "text-text" : "text-text-tertiary")}>
          {selected ? selected.label : placeholder}
        </span>
        <ChevronDown size={16} className={cn("shrink-0 text-text-tertiary transition-transform", open && "rotate-180")} aria-hidden />
      </button>

      {open && position && mounted && createPortal(
        <div
          ref={listRef}
          role="listbox"
          className="z-50 overflow-auto rounded-lg border border-border bg-surface py-1 shadow-lg"
          style={position.style}
        >
          {options.map((opt, i) => {
            const on = opt.value === value;
            return (
              <div
                key={opt.value}
                role="option"
                aria-selected={on}
                aria-disabled={opt.disabled || undefined}
                onMouseEnter={() => setActive(i)}
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => choose(opt)}
                className={cn(
                  "flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 text-sm text-text transition-colors",
                  opt.disabled ? "cursor-not-allowed opacity-40" : "hover:bg-bg-hover",
                  i === active && !opt.disabled && "bg-bg-secondary"
                )}
              >
                <span className="truncate">{opt.label}</span>
                {on && <Check size={14} className="shrink-0 text-accent" aria-hidden />}
              </div>
            );
          })}
        </div>,
        document.body
      )}
    </Field>
  );
}

Dependencies

To use Select you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  ├─ field.tsx
  └─ select.tsx
src/hooks/
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts