Forms

Search

Búsqueda con sugerencias que se filtran y resaltan al escribir

Preview

Última selección (onSelect): —

size
maxVisible
placeholder
disabled

Code

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

import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/csr/MagnifyingGlass";
import { XIcon } from "@phosphor-icons/react/dist/csr/X";

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 Match {
  option: Option;
  range: [number, number] | null;
}

interface SearchProps {
  size: Size | ResponsiveSize;
  // Named after the attribute it becomes: the magnifier and the placeholder are hints, not a name.
  "aria-label": string;
  placeholder?: string;
  name?: string;
  maxVisible: number;
  options: Option[];
  clearAriaLabel: string;
  // Optional: without it an empty result simply keeps the popover closed.
  emptyText?: string;
  disabled?: boolean;
  value: string;
  onChange: (value: string) => void;
  onSelect: (value: string | number) => void;
}

// MUST match the CSS of .search_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 = 8;

// Accent- and case-insensitive, so "cafe" still finds "Café".
const fold = (text: string) =>
  text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();

// Folds per character and keeps the index map, so the highlight lands on the ORIGINAL text.
function matchRange(name: string, query: string): [number, number] | null {
  const origin: number[] = [];
  let folded = "";
  for (let i = 0; i < name.length; i++) {
    for (const char of fold(name[i])) {
      folded += char;
      origin.push(i);
    }
  }

  const start = folded.indexOf(query);
  if (start < 0) return null;
  return [origin[start], origin[start + query.length - 1] + 1];
}

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 Search({
  size,
  "aria-label": ariaLabel,
  placeholder,
  name,
  maxVisible,
  options,
  clearAriaLabel,
  emptyText,
  disabled,
  value,
  onChange,
  onSelect,
}: SearchProps) {
  const field = useRef<HTMLInputElement>(null);
  const search = useRef<HTMLDivElement>(null);
  const optionsBox = useRef<HTMLDivElement>(null);

  const id = useId();
  // Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
  // identify.
  const fieldId = `${id}-field`;
  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 [tabbed, setTabbed] = useState(false);
  const pointer = useRef(false);
  const touch = useRef(false);

  const findMatches = (text: string): Match[] => {
    const query = fold(text.trim());
    if (!query) return options.map((option) => ({ option, range: null }));
    return options.flatMap((option) => {
      const range = matchRange(option.name, query);
      return range ? [{ option, range }] : [];
    });
  };

  const matches = findMatches(value);
  // No empty message means nothing to show, and an open flag over an empty list would still
  // announce expanded.
  const expanded = open && (matches.length > 0 || !!emptyText);

  const openList = (count: number) => {
    if (search.current) {
      const spaceBelow =
        window.innerHeight - search.current.getBoundingClientRect().bottom;
      // The list as the CSS draws it: natural height up to the cap.
      const optionsHeight = Math.min(
        count * (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 select = (option: Option) => {
    if (disabled) return;
    onChange(option.name);
    onSelect(option.value);

    closeList();
    if (touch.current) {
      field.current?.blur();
    } else {
      field.current?.focus();
    }
  };

  const clear = () => {
    onChange("");
    closeList();
    field.current?.focus();
  };

  const onFocus = () => setTabbed(!pointer.current);

  // Focus moving to the clear button or an option is not leaving the field.
  const onBlur = (ev: React.FocusEvent) => {
    if (search.current?.contains(ev.relatedTarget)) return;
    setTabbed(false);
    pointer.current = false;
  };

  const onPointerDown = (ev: React.PointerEvent) => {
    pointer.current = true;
    touch.current = ev.pointerType === "touch";
  };

  const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
    const next = ev.target.value;
    const count = findMatches(next).length;

    onChange(next);
    setFocusedIndex(-1);

    if (next.trim() && (count || emptyText)) {
      openList(count);
    } else {
      closeList();
    }
  };

  const onKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
    touch.current = false;
    if (disabled) return;

    if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
      ev.preventDefault();
      if (!matches.length) return;

      const last = matches.length - 1;
      if (!expanded) {
        openList(matches.length);
        setFocusedIndex(ev.key === "ArrowDown" ? 0 : last);
      } else if (ev.key === "ArrowDown") {
        setFocusedIndex((i) => (i >= last ? 0 : i + 1));
      } else {
        setFocusedIndex((i) => (i <= 0 ? last : i - 1));
      }
    } else if (ev.key === "Enter") {
      if (expanded && focusedIndex >= 0) {
        ev.preventDefault();
        select(matches[focusedIndex].option);
      }
    } else if (ev.key === "Escape") {
      if (expanded) {
        closeList();
      } else if (value) {
        clear();
      }
    }
  };

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

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

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

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

  return (
    <div
      ref={search}
      onPointerDown={onPointerDown}
      className={`
        ${css.search}
        ${getSizeClasses(size)}
        ${expanded ? css.open : ""}
        ${openUp ? css.open_up : ""}
        ${disabled ? css.disabled : ""}
      `}
    >
      <div
        className={`${css.search_box} ${tabbed ? css.tabbed : ""}`}
      >
        <MagnifyingGlassIcon className={css.box_icon} aria-hidden="true" />
        <input
          ref={field}
          id={fieldId}
          className={css.box_field}
          type="text"
          name={name}
          autoComplete="off"
          role="combobox"
          aria-label={ariaLabel}
          aria-autocomplete="list"
          aria-expanded={expanded}
          aria-controls={listboxId}
          aria-activedescendant={
            expanded && focusedIndex >= 0 ? optionId(focusedIndex) : undefined
          }
          placeholder={placeholder}
          disabled={disabled}
          value={value}
          onFocus={onFocus}
          onBlur={onBlur}
          onKeyDown={onKeyDown}
          onChange={handleChange}
        />
        {value && (
          <button
            type="button"
            className={css.box_clear}
            aria-label={clearAriaLabel}
            onClick={clear}
          >
            <XIcon weight="bold" />
          </button>
        )}
      </div>
      {/* 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.search_options}
        style={{ "--max-visible": maxVisible } as React.CSSProperties}
        role={matches.length ? "listbox" : "status"}
        tabIndex={-1}
        aria-label={matches.length ? ariaLabel : undefined}
      >
        {matches.length === 0 && <p className={css.options_empty}>{emptyText}</p>}
        {matches.map(({ option, range }, index) => (
          <button
            key={option.value}
            id={optionId(index)}
            className={index === focusedIndex ? css.focused : ""}
            type="button"
            tabIndex={-1}
            role="option"
            aria-selected={index === focusedIndex}
            onClick={() => select(option)}
          >
            {range ? (
              <>
                {option.name.slice(0, range[0])}
                <mark className={css.option_match}>
                  {option.name.slice(range[0], range[1])}
                </mark>
                {option.name.slice(range[1])}
              </>
            ) : (
              option.name
            )}
          </button>
        ))}
      </div>
    </div>
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredAltura del campo y tamaño de su texto. Fijo o responsive.
aria-labelstringrequiredNombre del campo para lectores de pantalla; el campo no dibuja label.
placeholderstring—Pista dentro del campo mientras está vacío. No sustituye al nombre.
namestring—Nombre del campo cuando vive en un formulario; sin él no se envía.
maxVisiblenumberrequiredNº de opciones enteras visibles antes de hacer scroll. Si hay más, asoma parte de la siguiente.
options{ name: string; value: string | number }[]requiredSugerencias (nombre visible + valor). Se filtran por subcadena sin distinguir mayúsculas ni acentos, y la coincidencia se resalta.
clearAriaLabelstringrequiredNombre del botón de limpiar para lectores de pantalla; solo se dibuja con contenido.
emptyTextstring—Mensaje cuando ninguna sugerencia coincide. Sin él, en ese caso la lista se queda cerrada.
disabledboolean—Desactiva el campo (no editable, opacidad reducida).
valuestringrequiredValor del campo (controlado).
onChange(value: string) => voidrequiredCallback con el nuevo valor en cada cambio. Escape (con la lista cerrada) y el botón de limpiar lo vacían.
onSelect(value: string | number) => voidrequiredCallback con el value de la sugerencia elegida; el texto pasa a ser su name.