Actions

Picker

Selector de opción única para barras de herramientas, sin etiqueta visible

Preview

size
maxVisible

Code

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

import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
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 PickerProps {
  size: Size | ResponsiveSize;
  // Required: the trigger only shows the chosen option, nothing says what it picks.
  "aria-label": string;
  maxVisible: number;
  options: Option[];
  value: string | number;
  onChange: (value: string | number) => void;
}

// MUST match the CSS of .picker_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 Picker({
  size,
  "aria-label": ariaLabel,
  maxVisible,
  options,
  value,
  onChange,
}: PickerProps) {
  const trigger = useRef<HTMLButtonElement>(null);
  const picker = useRef<HTMLDivElement>(null);
  const optionsBox = useRef<HTMLDivElement>(null);

  const id = useId();
  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);

  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 (open) {
      closeList();
      return;
    }
    openList();
    setFocusedIndex(selectedIndex);
  };

  const onKeyDown = (ev: React.KeyboardEvent) => {
    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) => {
    onChange(option.value);
    closeList();
    trigger.current?.focus();
  };

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

    const onOutside = (ev: Event) => {
      if (picker.current && !picker.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={picker}
      className={`
        ${css.picker}
        ${getSizeClasses(size)}
        ${open ? css.open : ""}
        ${openUp ? css.open_up : ""}
      `}
      onKeyDown={onKeyDown}
    >
      <button
        ref={trigger}
        className={css.picker_trigger}
        type="button"
        role="combobox"
        aria-label={ariaLabel}
        aria-expanded={open}
        aria-haspopup="listbox"
        aria-controls={listboxId}
        aria-activedescendant={
          focusedIndex >= 0 ? optionId(focusedIndex) : undefined
        }
        onClick={onClick}
      >
        <span className={css.trigger_value}>{selected?.name}</span>
        <CaretDownIcon 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.picker_options}
        style={{ "--max-visible": maxVisible } as React.CSSProperties}
        role="listbox"
        tabIndex={-1}
        aria-label={ariaLabel}
      >
        {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
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredAltura del disparador (36/40/44/48, la escalera de TextField). El texto se queda en 14. Fijo o responsive.
aria-labelstringrequiredNombre del control para lectores de pantalla. No se muestra, y el disparador solo enseña la opción elegida y nada dice de qué es.
maxVisiblenumberrequiredNº de opciones enteras visibles antes de hacer scroll. Si hay más, asoma parte de la siguiente.
options{ name: string; value: string | number }[]requiredOpciones del desplegable (nombre visible + valor).
valuestring | numberrequiredValor seleccionado (controlado). Siempre hay uno, porque es un control y no un campo que empieza vacío.
onChange(value: string | number) => voidrequiredCallback con el value de la opción elegida.