Forms

SearchField

Búsqueda sin sugerencias, para filtrar lo que ya está en pantalla

Preview

No trae lista. Sirve para filtrar lo que ya está en pantalla, como las filas de una tabla. Para elegir entre sugerencias, el componente es Search.

size
placeholder
disabled

Code

import css from "./SearchField.module.css";
import { 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 SearchFieldProps {
  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;
  clearAriaLabel: string;
  disabled?: boolean;
  value: 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 SearchField({
  size,
  "aria-label": ariaLabel,
  placeholder,
  name,
  clearAriaLabel,
  disabled,
  value,
  onChange,
}: SearchFieldProps) {
  // Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
  // identify.
  const fieldId = useId();
  const field = useRef<HTMLInputElement>(null);
  const searchField = useRef<HTMLDivElement>(null);
  const [tabbed, setTabbed] = useState(false);
  const pointer = useRef(false);

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

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

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

  const onPointerDown = () => {
    pointer.current = true;
  };

  const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
    onChange(ev.target.value);
  };

  const onKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => {
    if (ev.key === "Escape" && value) {
      ev.preventDefault();
      clear();
    }
  };

  return (
    <div
      ref={searchField}
      onPointerDown={onPointerDown}
      className={`
        ${css.searchfield}
        ${getSizeClasses(size)}
        ${tabbed ? css.tabbed : ""}
        ${disabled ? css.disabled : ""}
      `}
    >
      <MagnifyingGlassIcon className={css.searchfield_icon} aria-hidden="true" />
      <input
        ref={field}
        id={fieldId}
        className={css.searchfield_input}
        type="search"
        name={name}
        autoComplete="off"
        aria-label={ariaLabel}
        placeholder={placeholder}
        disabled={disabled}
        value={value}
        onFocus={onFocus}
        onBlur={onBlur}
        onKeyDown={onKeyDown}
        onChange={handleChange}
      />
      {value && (
        <button
          type="button"
          className={css.searchfield_clear}
          aria-label={clearAriaLabel}
          onClick={clear}
        >
          <XIcon weight="bold" />
        </button>
      )}
    </div>
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredAltura del campo (36/40/44/48, la escalera de Search). El texto se queda en 14, como en TextField y Picker. 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.
clearAriaLabelstringrequiredNombre del botón de limpiar para lectores de pantalla; solo se dibuja con contenido.
disabledboolean—Desactiva el campo (no editable, opacidad reducida).
valuestringrequiredValor del campo (controlado).
onChange(value: string) => voidrequiredCallback con el nuevo valor en cada cambio. Escape y el botón de limpiar lo vacían.