Forms

SearchField

Search field without suggestions, for filtering what is already on screen

Preview

It has no list. It is for filtering what is already on screen, like the rows of a table. To pick from suggestions, the component is 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" | ResponsiveSizerequiredField height (36/40/44/48, the Search ladder). The text stays at 14, as in TextField and Picker. Fixed or responsive.
aria-labelstringrequiredName of the field for screen readers; the field draws no label.
placeholderstring—Hint inside the field while it is empty. Not a substitute for the name.
namestring—Field name when it lives in a form; without it, it is not submitted.
clearAriaLabelstringrequiredName of the clear button for screen readers; only drawn when there is content.
disabledboolean—Disables the field (not editable, reduced opacity).
valuestringrequiredField value (controlled).
onChange(value: string) => voidrequiredCallback with the new value on every change. Escape and the clear button empty it.