Actions

Picker

Single-choice picker for toolbars, with no visible label

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" | ResponsiveSizerequiredTrigger height (36/40/44/48, the TextField ladder). The text stays at 14. Fixed or responsive.
aria-labelstringrequiredName of the control for screen readers. Not displayed, and the trigger only shows the chosen option and nothing says what it belongs to.
maxVisiblenumberrequiredNumber of whole options visible before scrolling. When there are more, part of the next one peeks in.
options{ name: string; value: string | number }[]requiredDropdown options (visible name + value).
valuestring | numberrequiredSelected value (controlled). There is always one, because it is a control, not a field that starts empty.
onChange(value: string | number) => voidrequiredCallback with the value of the chosen option.