Forms

TextField

Compact text field with no visible label, named through aria-label

Preview

size
placeholder
disabled

Code

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

type Size = "xs" | "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface TextFieldProps {
  size: Size | ResponsiveSize;
  // Named after the attribute it becomes: the field carries no visible label, only an accessible
  // one.
  "aria-label": string;
  placeholder?: 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 TextField({
  size,
  "aria-label": ariaLabel,
  placeholder,
  disabled,
  value,
  onChange,
}: TextFieldProps) {
  // Nothing points at it: without an id or a name, Chrome flags the field as one autofill can't
  // identify.
  const fieldId = useId();
  const [tabbed, setTabbed] = useState(false);
  const pointer = useRef(false);

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

  const onBlur = () => {
    setTabbed(false);
    pointer.current = false;
  };

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

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

  return (
    <input
      id={fieldId}
      type="text"
      className={`${css.field} ${getSizeClasses(size)} ${tabbed ? css.tabbed : ""} ${disabled ? css.disabled : ""}`}
      value={value}
      aria-label={ariaLabel}
      placeholder={placeholder}
      disabled={disabled}
      onPointerDown={onPointerDown}
      onFocus={onFocus}
      onBlur={onBlur}
      onChange={handleChange}
    />
  );
}

Props

PropTypeRequiredDescription
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredField height. 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.
disabledboolean—Disables the field (not editable, reduced opacity).
valuestringrequiredField value (controlled).
onChange(value: string) => voidrequiredCallback with the new value on every change.