Actions

Segmented

Selector de opción única que se pliega en un desplegable si no cabe

Preview

Estrechar se activa con auto, el único modo que cambia según el ancho.

size
mode
options

Code

import css from "./Segmented.module.css";
import { useRef } from "react";

import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";

import Popover from "@/components/library/react/Popover/Popover";

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

// Three values, not two booleans: "forced dropdown that also collapses" must not be expressible.
type Mode = "pills" | "dropdown" | "auto";

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

interface SegmentedProps {
  options: string[];
  value: string;
  size: Size | ResponsiveSize;
  mode: Mode;
  // Required: collapsed, the trigger shows only the value, nothing says of what.
  "aria-label": string;
  onChange: (value: string) => void;
}

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 Segmented({
  options,
  value,
  size,
  mode,
  "aria-label": ariaLabel,
  onChange,
}: SegmentedProps) {
  const collapsed = useRef<HTMLDivElement>(null);

  const auto = mode === "auto";

  const select = (option: string, close?: () => void) => {
    onChange(option);
    close?.();
  };

  // Popover takes over the trigger's ref, so the keys are read from the collapsed box.
  const onKeyDown = (ev: React.KeyboardEvent) => {
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) return;

    const box = collapsed.current!;
    const trigger = box.querySelector<HTMLElement>(`.${css.collapsed_trigger}`)!;
    const items = [...box.querySelectorAll<HTMLElement>(`.${css.menu_item}`)];
    if (!items.length) return;
    const chosen = Math.max(0, options.indexOf(value));

    if (trigger.getAttribute("aria-expanded") !== "true") {
      if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
      ev.preventDefault();
      trigger.click();
      // The box only shows after the re-render; focus waits for that frame.
      requestAnimationFrame(() => items[chosen].focus());
      return;
    }

    ev.preventDefault();

    const at = items.indexOf(document.activeElement as HTMLElement);
    const last = items.length - 1;
    let next = chosen;

    if (ev.key === "Home") {
      next = 0;
    } else if (ev.key === "End") {
      next = last;
    } else if (at >= 0 && ev.key === "ArrowDown") {
      next = at === last ? 0 : at + 1;
    } else if (at >= 0 && ev.key === "ArrowUp") {
      next = at === 0 ? last : at - 1;
    }

    items[next].focus();
  };

  return (
    <div
      className={`${css.segmented} ${getSizeClasses(size)} ${auto ? css.auto : ""}`}
    >
      {(mode === "pills" || auto) && (
        <div className={css.segmented_pills} role="group" aria-label={ariaLabel}>
          {options.map((option) => {
            const active = option === value;

            return (
              <button
                key={option}
                type="button"
                className={`${css.pills_option} ${active ? css.active : ""}`}
                aria-pressed={active}
                onClick={() => select(option)}
              >
                {option}
              </button>
            );
          })}
        </div>
      )}

      {(mode === "dropdown" || auto) && (
        <div ref={collapsed} className={css.segmented_collapsed} onKeyDown={onKeyDown}>
          <Popover
            trigger={
              <button
                type="button"
                className={css.collapsed_trigger}
                aria-label={`${ariaLabel}: ${value}`}
              >
                {value}
                <CaretDownIcon className={css.trigger_caret} weight="bold" />
              </button>
            }
            side="bottom"
            align="end"
          >
            {(close) => (
              <ul className={css.collapsed_menu}>
                {options.map((option) => (
                  <li key={option}>
                    <button
                      type="button"
                      className={`${css.menu_item} ${option === value ? css.active : ""}`}
                      aria-pressed={option === value}
                      onClick={() => select(option, close)}
                    >
                      {option}
                    </button>
                  </li>
                ))}
              </ul>
            )}
          </Popover>
        </div>
      )}
    </div>
  );
}

Props

PropTypeRequiredDescription
optionsstring[]requiredOpciones del control; el texto es a la vez el valor.
size"xs" | "sm" | "md" | ResponsiveSizerequiredCada opción mide 24, 28 o 32 de alto, y un peldaño más en pantallas táctiles. El texto va a 14 en las tres. Fijo o responsive.
mode"pills" | "dropdown" | "auto"requiredForma del control. Con auto empieza como pills y colapsa a desplegable por debajo de 480px de viewport (el breakpoint mobile), o cuando su contenedor baja de 480px (solo si el consumidor declara container-type).
aria-labelstringrequiredNombre del control para lectores de pantalla. No se muestra, pero al colapsar el disparador solo enseña la opción elegida y nada dice de qué es.
valuestringrequiredOpción seleccionada (controlada).
onChange(value: string) => voidrequiredCallback con la opción elegida.