Actions

Segmented

Single-choice control that folds into a dropdown when it can't fit

Preview

Narrow turns on with auto, the only mode that changes with the width.

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[]requiredControl options; the text doubles as the value.
size"xs" | "sm" | "md" | ResponsiveSizerequiredEach option is 24, 28 or 32 tall, and one step taller on touch screens. The text stays at 14 in all three. Fixed or responsive.
mode"pills" | "dropdown" | "auto"requiredShape of the control. With auto it starts as pills and collapses to a dropdown below a 480px viewport (the mobile breakpoint), or when its container drops below 480px (only if the consumer declares container-type).
aria-labelstringrequiredName of the control for screen readers. Not displayed, but once collapsed the trigger only shows the chosen option and nothing says what it belongs to.
valuestringrequiredSelected option (controlled).
onChange(value: string) => voidrequiredCallback with the chosen option.