返回博客

Base UI 没有 Textarea 原语:用 Field.Control render 道绕,但 TypeScript 会咬你

给设计系统写 Textarea 组件时,我发现 @base-ui-components/reactInput 原语,但没有 Textarea。翻了一遍文档和源码,官方推荐的路径是 <Field.Control render={<textarea/>} />——这样 Field.Root 的上下文(id/htmlForaria-describedbyaria-invalid)会自动流入多行输入框。

但一旦我把 refrowsonInput 传到 Field.Control 上,TypeScript 立刻爆红。

现象

FieldControl 的类型签名是 ForwardRefExoticComponent<… & RefAttributes<HTMLInputElement>>。当你试图把 Ref<HTMLTextAreaElement> 或 React 19 里的 InputEvent<HTMLTextAreaElement> 传给它,编译器会提示:

Type 'RefObject<HTMLTextAreaElement>' is not assignable to
  'RefAttributes<HTMLInputElement>'

第一反应是「那我直接用原生 <textarea> 好了」——这个"修法"可以编译通过,但它埋了一个更隐蔽的 a11y 回归。

根因

Field.Control 会消费 FieldRootContext,从中拿到 idaria-describedbyaria-invalid 等 wiring,然后挂到它渲染出来的 DOM 元素上。换成原生 <textarea> 之后,这段上下文消费链就断了:原生元素根本不知道外层有没有 <Field.Root>,所以 a11y 属性静默丢失。

更坑的是,单独 mount <Textarea /> 做快照测试时,两种写法视觉上完全一样——只有放进 <Field.Root> 里之后,a11y 差异才会暴露,而这个场景容易被测试遗漏。

坑:换成原生 textarea 只是把 TS 错误转移成 a11y 静默回归

Field.Control 换成原生 <textarea> 可以消掉 TypeScript 报错,但原生元素不消费 FieldRootContextaria-describedby / aria-invalid / id 全部静默丢失。独立渲染时看不出来,进 Field.Root 里才会暴露。

另外,React 19 里 onInput 的类型从 FormEvent 改成了 InputEvent,也是一个独立的编译报错点,容易和 ref 类型错误混在一起让人困惑。

正解

关键洞察来自 Base UI 源码:Input 组件本质上就是 forwardRef((p, ref) => <Field.Control {...p} ref={ref} />),而 Field.Control 内部用 useRenderElement 把 render 元素的 ref 单独提取出来并合并,并不依赖 Field.Control 自身的泛型约束。

因此,把所有 textarea 专属 props 放到 render 元素上,而不是 Field.Control,就能同时满足类型系统和 a11y wiring:

"use client";
import { useLayoutEffect, useRef } from "react";
import type { InputEvent } from "react"; // React 19: onInput 是 InputEvent,不是 FormEvent
import { Field as BaseField } from "@base-ui-components/react/field";
 
export function Textarea({ size, invalid, autoResize, rows = 3, onInput, className, ...props }) {
  const ref = useRef<HTMLTextAreaElement>(null);
 
  const resize = () => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "auto";              // 先 auto,再读 scrollHeight,否则只涨不缩
    el.style.height = `${el.scrollHeight}px`;
  };
 
  useLayoutEffect(() => {
    if (autoResize) resize();
  }, [autoResize, props.value, rows]);
 
  const handleInput = (e: InputEvent<HTMLTextAreaElement>) => {
    if (autoResize) resize();
    onInput?.(e);
  };
 
  return (
    <BaseField.Control
      render={
        <textarea
          ref={ref}           // ref 放在 <textarea> 上,类型是 HTMLTextAreaElement,不冲突
          rows={rows}
          onInput={handleInput}
          {...props}
          {...(invalid && { "data-invalid": "", "aria-invalid": true })}
          className={cn(
            textareaVariants({ size }),
            autoResize ? "resize-none overflow-hidden" : "resize-y",
            className,
          )}
        />
      }
    />
  );
}

核心要点:

  • Field.Control 保留FieldRootContext 的 a11y wiring 不丢。
  • ref / rows / onInput 全在 render 的 <textarea>,原生元素本身就是 HTMLTextAreaElement,类型自然匹配。
  • invalid prop 必须解构再翻译data-invalid / aria-invalid,不能直接展开到 DOM,否则 React 19 会把非标准的 invalid 属性直接渲染出来。
  • autoResize 必须先 height = 'auto' 再读 scrollHeight,否则高度只会涨不会缩;同时保留 rows 作为最小高度的锚点。
正解:textarea 专属 props 放 render 元素,Field.Control 只传通用 props

Field.Control 的 ref 泛型约束针对的是它自身,render 元素的 ref 由 Base UI 内部单独提取合并。把 ref/rows/onInput/...props 全放到 render={<textarea ... />} 上,TypeScript 通过,a11y wiring 也完整保留。

验证

回归测试直接验证 Field.Control 的上下文是否真正接通——原生 <textarea> 版本会在这里 fail:

import { Field as BaseField } from "@base-ui-components/react/field";
 
const { container } = render(
  <BaseField.Root invalid>
    <Textarea />
  </BaseField.Root>
);
// 由 FieldRootContext 注入,原生 <textarea> 拿不到这个
expect(container.querySelector("textarea")!.getAttribute("data-invalid")).toBe("");

再跑 pnpm typecheck,零 @ts-ignore 通过,才算真正修完。

一句话外卖

当 Base UI 缺少某个原语时,先看官方已有的 render prop 是否能组合出来——类型报错大概率是 props 放错了层,而不是设计本身有问题。