Base UI 没有 Textarea 原语:用 Field.Control render 道绕,但 TypeScript 会咬你
给设计系统写 Textarea 组件时,我发现 @base-ui-components/react 有 Input 原语,但没有 Textarea。翻了一遍文档和源码,官方推荐的路径是 <Field.Control render={<textarea/>} />——这样 Field.Root 的上下文(id/htmlFor、aria-describedby、aria-invalid)会自动流入多行输入框。
但一旦我把 ref、rows、onInput 传到 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,从中拿到 id、aria-describedby、aria-invalid 等 wiring,然后挂到它渲染出来的 DOM 元素上。换成原生 <textarea> 之后,这段上下文消费链就断了:原生元素根本不知道外层有没有 <Field.Root>,所以 a11y 属性静默丢失。
更坑的是,单独 mount <Textarea /> 做快照测试时,两种写法视觉上完全一样——只有放进 <Field.Root> 里之后,a11y 差异才会暴露,而这个场景容易被测试遗漏。
把 Field.Control 换成原生 <textarea> 可以消掉 TypeScript 报错,但原生元素不消费 FieldRootContext,aria-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,类型自然匹配。invalidprop 必须解构再翻译为data-invalid/aria-invalid,不能直接展开到 DOM,否则 React 19 会把非标准的invalid属性直接渲染出来。- autoResize 必须先
height = 'auto'再读scrollHeight,否则高度只会涨不会缩;同时保留rows作为最小高度的锚点。
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 缺少某个原语时,先看官方已有的
renderprop 是否能组合出来——类型报错大概率是 props 放错了层,而不是设计本身有问题。