Forms

Textarea

Campo de texto multilínea con label flotante

Preview

Mensaje

Puedes escribir hasta 200 caracteres

size
label
required
error
disabled
counter
counter.max
counter.threshold

Code

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

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

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

interface TextareaCounter {
  max: number;
  threshold?: number;
  remaining: (left: number) => string;
  exceeded: (over: number) => string;
  description: (max: number) => string;
}

interface TextareaProps {
  name: string;
  size: Size | ResponsiveSize;
  label: string;
  required?: boolean;
  error?: boolean;
  disabled?: boolean;
  counter?: TextareaCounter;
  value: string;
  onChange: (value: string) => void;
}

const STATUS_DELAY = 1000;

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 Textarea({
  name,
  size,
  label,
  required,
  error,
  disabled,
  counter,
  value,
  onChange,
}: TextareaProps) {
  const [focused, setFocused] = useState(false);
  const [tabbed, setTabbed] = useState(false);
  const pointer = useRef(false);
  const fieldId = useId();
  const active = focused || value.length > 0;

  const [status, setStatus] = useState("");
  const mounted = useRef(false);
  const descriptionId = `${fieldId}-description`;
  const count = value.length;
  const left = counter ? counter.max - count : 0;
  const over = left < 0;
  const shown =
    counter !== undefined &&
    (!counter.threshold || count >= (counter.max * counter.threshold) / 100);
  const text = counter ? (over ? counter.exceeded(-left) : counter.remaining(left)) : "";

  const onFocus = () => {
    setFocused(true);
    setTabbed(!pointer.current);
  };

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

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

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

  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }

    const timer = setTimeout(() => setStatus(shown ? text : ""), STATUS_DELAY);
    return () => clearTimeout(timer);
  }, [shown, text]);

  return (
    <div
      className={`
        ${css.textarea}
        ${getSizeClasses(size)}
        ${active ? css.active : ""}
        ${tabbed ? css.tabbed : ""}
        ${error ? css.error : ""}
        ${disabled ? css.disabled : ""}
      `}
      onPointerDown={onPointerDown}
    >
      <div className={css.textarea_field}>
        <textarea
          id={fieldId}
          name={name}
          value={value}
          required={required}
          disabled={disabled}
          aria-invalid={error}
          aria-describedby={counter ? descriptionId : undefined}
          onFocus={onFocus}
          onBlur={onBlur}
          onChange={handleChange}
        ></textarea>
        <fieldset className={css.textarea_fieldset}>
          <legend className={css.fieldset_legend}>{label}</legend>
        </fieldset>
        <label htmlFor={fieldId} className={css.textarea_label}>
          {label}
          {required && <span aria-hidden="true">*</span>}
        </label>
      </div>
      {counter && (
        <>
          <p
            className={`${css.textarea_counter} ${shown ? css.shown : ""} ${over ? css.over : ""}`}
            aria-hidden="true"
          >
            {shown ? text : ""}
          </p>
          <p id={descriptionId} className={css.textarea_description}>
            {counter.description(counter.max)}
          </p>
          <p className={css.textarea_status} aria-live="polite">
            {status}
          </p>
        </>
      )}
    </div>
  );
}

Props

PropTypeRequiredDescription
namestringrequiredNombre del campo, para formularios y autofill del navegador.
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredAltura mínima del campo y tamaño de su texto. El usuario puede estirarlo en vertical. Fijo o responsive.
labelstringrequiredEtiqueta flotante que sube al enfocar o con contenido.
requiredboolean—Marca el campo como obligatorio (añade * al label y el required nativo).
errorboolean—Estado de error visual (borde rojo, aria-invalid).
disabledboolean—Desactiva el campo (no editable, opacidad reducida).
counter{ max: number; threshold?: number; remaining: (left) => string; exceeded: (over) => string; description: (max) => string }—Activa el contador entero o nada: el tope, el porcentaje desde el que se ve (sin él, siempre), las frases de lo que queda y lo que sobra y la que dice el tope al lector de pantalla. remaining recibe lo que queda, exceeded lo que sobra y description el tope, y cada una devuelve la frase completa, con el singular y el plural que decida quien la escribe. Deja escribir por encima del tope y pinta la frase en rojo; el borde rojo lo decide el formulario con la prop error.
valuestringrequiredValor del campo (controlado).
onChange(value: string) => voidrequiredCallback con el nuevo valor en cada cambio.