Forms

Select

Desplegable personalizado con navegación por teclado

Preview

size
maxVisible
label
required
error
disabled

Code

import css from "./Select.module.css";
import { useEffect, useId, useRef, useState } from "react";

import { CaretRightIcon } from "@phosphor-icons/react/dist/csr/CaretRight";
import { CheckIcon } from "@phosphor-icons/react/dist/csr/Check";

type Size = "xs" | "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface Option {
  name: string;
  value: string | number;
}

interface SelectProps {
  label: string;
  size: Size | ResponsiveSize;
  maxVisible: number;
  required?: boolean;
  error?: boolean;
  disabled?: boolean;
  options: Option[];
  value: string | number;
  onChange: (value: string | number) => void;
}

// MUST match the CSS of .select_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 12;

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function Select({
  label,
  size,
  maxVisible,
  required,
  error,
  disabled,
  options,
  value,
  onChange,
}: SelectProps) {
  const trigger = useRef<HTMLButtonElement>(null);
  const select = useRef<HTMLDivElement>(null);
  const optionsBox = useRef<HTMLDivElement>(null);

  const id = useId();
  const labelId = `${id}-label`;
  const listboxId = `${id}-listbox`;
  const optionId = (i: number) => `${id}-option-${i}`;

  const [open, setOpen] = useState(false);
  const [openUp, setOpenUp] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(-1);

  // 0 is the empty value: the label stays in the field until something is chosen.
  const active = value !== 0;
  const selectedIndex = options.findIndex((option) => option.value === value);
  const selected = options[selectedIndex];

  const openList = () => {
    if (trigger.current) {
      const spaceBelow =
        window.innerHeight - trigger.current.getBoundingClientRect().bottom;
      // The list as the CSS draws it: natural height up to the cap.
      const optionsHeight = Math.min(
        options.length * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
        PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
      );
      setOpenUp(spaceBelow < OFFSET + optionsHeight);
    }
    setOpen(true);
  };

  const closeList = () => {
    setOpen(false);
    setFocusedIndex(-1);
  };

  const onClick = () => {
    if (disabled) return;

    if (open) {
      closeList();
      return;
    }
    openList();
    setFocusedIndex(selectedIndex);
  };

  const onKeyDown = (ev: React.KeyboardEvent) => {
    if (disabled) return;

    if (!open) {
      if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown") {
        ev.preventDefault();
        openList();
        setFocusedIndex(selectedIndex >= 0 ? selectedIndex : 0);
      }
      return;
    }

    if (ev.key === "ArrowDown") {
      ev.preventDefault();
      setFocusedIndex((i) => (i + 1) % options.length);
    } else if (ev.key === "ArrowUp") {
      ev.preventDefault();
      setFocusedIndex((i) => (i - 1 + options.length) % options.length);
    } else if (ev.key === "Home") {
      ev.preventDefault();
      setFocusedIndex(0);
    } else if (ev.key === "End") {
      ev.preventDefault();
      setFocusedIndex(options.length - 1);
    } else if (ev.key === "PageDown") {
      ev.preventDefault();
      setFocusedIndex((i) => Math.min(i + maxVisible, options.length - 1));
    } else if (ev.key === "PageUp") {
      ev.preventDefault();
      setFocusedIndex((i) => Math.max(i - maxVisible, 0));
    } else if (ev.key === "Enter" || ev.key === " ") {
      ev.preventDefault();
      if (focusedIndex >= 0) onSelect(options[focusedIndex]);
    } else if (ev.key === "Escape") {
      ev.preventDefault();
      closeList();
      trigger.current?.focus();
    }
  };

  const onSelect = (option: Option) => {
    if (disabled) return;
    onChange(option.value);
    closeList();
    trigger.current?.focus();
  };

  useEffect(() => {
    if (!open) return;

    const onOutside = (ev: Event) => {
      if (select.current && !select.current.contains(ev.target as Node)) closeList();
    };

    document.addEventListener("click", onOutside);
    document.addEventListener("focusin", onOutside);
    return () => {
      document.removeEventListener("click", onOutside);
      document.removeEventListener("focusin", onOutside);
    };
  }, [open]);

  useEffect(() => {
    if (!open || focusedIndex < 0 || !optionsBox.current) return;
    const option = optionsBox.current.children[focusedIndex] as HTMLElement;
    option?.scrollIntoView({ block: "nearest" });
  }, [focusedIndex, open]);

  return (
    <div
      ref={select}
      className={`
        ${css.select}
        ${getSizeClasses(size)}
        ${open ? css.open : ""}
        ${active ? css.active : ""}
        ${openUp ? css.open_up : ""}
        ${error ? css.error : ""}
        ${disabled ? css.disabled : ""}
      `}
      onKeyDown={onKeyDown}
    >
      <button
        ref={trigger}
        className={css.select_trigger}
        type="button"
        role="combobox"
        disabled={disabled}
        aria-labelledby={labelId}
        aria-expanded={open}
        aria-haspopup="listbox"
        aria-controls={listboxId}
        aria-activedescendant={
          focusedIndex >= 0 ? optionId(focusedIndex) : undefined
        }
        aria-required={required}
        aria-invalid={error}
        onClick={onClick}
      >
        <span>{selected?.name}</span>
        <fieldset className={css.trigger_fieldset}>
          <legend className={css.fieldset_legend}>{label}</legend>
        </fieldset>
        <span className={css.trigger_label} aria-hidden="true">
          {/* aria-labelledby reads hidden text too: the id holds the label alone, so the * stays
              out of the name. */}
          <span id={labelId}>{label}</span>
          {required && "*"}
        </span>
        <CaretRightIcon className={css.trigger_caret} aria-hidden="true" />
      </button>
      {/* A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
          the focus never enters the list. */}
      <div
        ref={optionsBox}
        id={listboxId}
        className={css.select_options}
        style={{ "--max-visible": maxVisible } as React.CSSProperties}
        role="listbox"
        tabIndex={-1}
        aria-labelledby={labelId}
      >
        {options.map((option, index) => (
          <button
            key={option.value}
            id={optionId(index)}
            className={`
              ${option.value === value ? css.selected : ""}
              ${index === focusedIndex ? css.focused : ""}
            `}
            type="button"
            tabIndex={-1}
            role="option"
            aria-selected={option.value === value}
            onClick={() => onSelect(option)}
          >
            {option.name}
            {option.value === value && (
              <CheckIcon className={css.option_check} aria-hidden="true" />
            )}
          </button>
        ))}
      </div>
    </div>
  );
}

Props

PropTypeRequiredDescription
labelstringrequiredEtiqueta flotante del campo (centrada vacía, sube al abrir o con selección).
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredAltura del disparador y tamaño del texto, que hereda la lista. Fijo o responsive.
maxVisiblenumberrequiredNº de opciones enteras visibles antes de hacer scroll. Si hay más, asoma parte de la siguiente.
requiredboolean—Marca el campo como obligatorio (añade * al label y aria-required).
errorboolean—Estado de error visual (borde rojo, aria-invalid).
disabledboolean—Desactiva el campo (no se abre, opacidad reducida).
options{ name: string; value: string | number }[]requiredOpciones del desplegable (nombre visible + valor).
valuestring | numberrequiredValor seleccionado (controlado). 0 = sin selección.
onChange(value: string | number) => voidrequiredCallback con el value de la opción elegida.