Forms

Search

Search field with suggestions filtered and highlighted as you type

Preview

Last selection (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" | ResponsiveSizerequiredField height and its text size. 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.
maxVisiblenumberrequiredNumber of whole options visible before scrolling. When there are more, part of the next one peeks in.
options{ name: string; value: string | number }[]requiredSuggestions (visible name + value). Filtered by substring ignoring case and accents, with the match highlighted.
clearAriaLabelstringrequiredName of the clear button for screen readers; only drawn when there is content.
emptyTextstring—Message shown when no suggestion matches. Without it, the list stays closed in that case.
disabledboolean—Disables the field (not editable, reduced opacity).
valuestringrequiredField value (controlled).
onChange(value: string) => voidrequiredCallback with the new value on every change. Escape (with the list closed) and the clear button empty it.
onSelect(value: string | number) => voidrequiredCallback with the chosen suggestion's value; the text becomes its name.