Cadenza
EN

单行文本框 —— 自带边框、自成一体;普通的 htmlFor 通道,外加 Base UI 的 onValueChange

Base UI 的 Input 装进 shadcn 的 base-nova 盒子。字段就只是一个字段的时候用它 —— 边框、圆角、焦点环都长在它自己身上。

使用

import { Field, FieldDescription, FieldLabel, Input } from '@gedatou/cadenza-ui'
<Input id="title" name="title" placeholder="夜之加斯帕" />

Field

Field + FieldLabel htmlFor + Input id + FieldDescription,就是 hero 里那个形态:

<Field>
  <FieldLabel htmlFor="title">作品名</FieldLabel>
  <Input id="title" name="title" placeholder="夜之加斯帕" />
  <FieldDescription>公开显示,之后可以改。</FieldDescription>
</Field>

标签通道是最普通的那条id 落在真正的 <input> 上,FieldLabel htmlFor 指过去, 原生 <label for> 一次给出无障碍名和「点标签聚焦」。没有 box-only 控件( Checkbox / Switch)那种 「id 其实在隐藏 input 上」的绕路,也不用补 aria-label。全库四条通道的总表在 Field

一批字段排成一列、要不要套语义编组,都是 Field 家族 (FieldGroup / FieldSet)的事,这一页不重复。

禁用

disabled 只管这一个输入框:忽略交互、变灰底、半透明,同时渲染 data-disabled。要让 整列文字跟着变灰,按 Field 的状态约定在 Field手动挂 data-disabled —— 纯 DOM 线不会替你联动。

readOnly 是另一档:能聚焦、能选中复制,但改不动;和 disabled 不同,它照样参与提交。

无效态

控件挂 aria-invalid,字段那一列挂 data-invalid —— 两个属性各管一头:

挂在哪效果
Inputaria-invalid输入框画出 destructive 边框与环
Fielddata-invalid整列文字变 destructive 色,见 Field

皮肤画的错误态挂在 aria-invalid 上而不是 data-invalid(和 InputGroup 一致):手动传 aria-invalid 就有红框红环, 不需要 Base UI 的校验参与 —— 在本库里那条 data-invalid 线根本不会自己亮,原因见 状态与 className。错误消息交给同一个 Field 里的 FieldError

文件

type="file" 就是原生的文件选择器,皮肤只替它把「选择文件」那个按钮收进输入框里 —— file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground: 去掉浏览器默认的灰块边框,和输入框同高、同字色。

<Field>
  <FieldLabel htmlFor="score">总谱</FieldLabel>
  <Input id="score" name="score" type="file" accept=".pdf,.musicxml" />
</Field>

文件输入只能非受控value 塞不进去(原生限制),文件从 onChangeevent.target.files 取。onValueChange 在这里也没用 —— 它的第一参给的是 input.value, 对文件而言是 C:\fakepath\… 那个字符串。

值变化

原生 onChange 照常可用,Base UI 在它之外另给一个 onValueChange(value, details): 第一参直接是字符串,省掉 event.target.value 那一跳。

两个回调同时触发,不是二选一 —— Base UI 合并事件处理器(mergeProps 把内部与外部 的 on* 串起来依次调用),你传的 onChange 不会顶掉部件内部那个。

第二参 InputChangeEventDetails 里:

  • reason 恒为 'none' —— 上游给字段控件就派了这一种,别指望靠它分辨来源。
  • event 是原生事件。
  • cancel() 在这里没有接收方:部件读完值就往下走,从不检查 isCanceled。 要拦下一次输入,只能受控着不把新值写回。

什么时候用 InputGroup

要往输入框里加图标、文本或按钮时不改这一个,换 InputGroup一旦有东西要和输入框共用边框就换 —— 前置图标、行尾按钮、单位后缀、快捷键提示,任何一个都算。

因为边框的归属变了:在 InputGroup 里,拥有边框和焦点环的是那一行InputGroupInput 自己一点边框都没有。所以这不是「给 Input 加个附加物」, 而是换一个部件 —— 把附加物塞进 Input 旁边只会得到两个盒子。搭好的搜索框直接用 SearchField,不必自己拼。

反过来说,只有一个光秃秃的输入框时不必上 InputGroupInput 本身就是完整的; 多行文本改用 Textarea

表单

序列化走原生,封装层不插手:给了 name 就进 FormDatadisabled 的字段不提交, required / readOnly / autoComplete 也都是原生那一套。控件渲染在 <form> 之外时, 用原生 form 属性指出它属于哪张表单。

提交前的校验同样是浏览器那套 —— Base UI 的字段校验只在它自己的 Field.Root 里跑, 本库的 Field 是纯 DOM 线,见下节。

状态与 className

className 通到的是 Base UI 的 slot,所以函数形态成立:(state) => string | undefined, 参数是 InputState

data-*InputState 里的名字出现时机
data-disableddisableddisabled
data-valid / data-invalidvalid校验通过 / 失败。初值是 null,两个都不出现
data-touchedtouched失焦过一次
data-dirtydirty值和初值不同
data-filledfilled有值
data-focusedfocused聚焦中

data-disabled 外,这一组只在 Base UI 的 Field.Root 里才会动 —— 本库的 Field 不是它(那是纯 DOM 布局),所以在这里 valid 恒为 null、其余四个恒为 false。错误态因此改挂 aria-invalid,见无效态

data-slot="input" 挂在真 <input> 上,需要从外部定位时当选择器用。函数 className 为什么真能用,见下节。

为什么是一次 cast

封装层没有包一层组件,只做了一次类型 cast —— 和 InputGroupInput 需要的是同一次。

转正之前的 vendored 文件把 props 写成了 ComponentProps<'input'>,这一压平就抹掉了两样 底下的 Input 其实认得的东西:函数形态的 className、和带 details 的 onValueChangedefaultValue 不在其中 —— BaseUIComponentProps 确实先把原生那个 摘掉了,但 Input 紧接着又声明回来,类型就是原生的 ComponentProps<'input'>['defaultValue'], 压平它不损失什么。每个 prop 本来就是原样展开传下去的,运行时一直通着, 只有类型需要还原

函数 className 是真能用的:本库的 cn 遇到函数会返回一个函数、等 state 到了再解析 (clsx 是直接吞掉),所以你的类名照样排在封装层那组之后,冲突时你的赢。

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className

Prop类型默认值说明
idstring落在真 <input> 上,FieldLabel htmlFor 指这里
defaultValuestring | number | readonly string[]非受控初值。Base UI 重新声明的那个
valuestring | number | readonly string[]受控值
onValueChange(value: string, eventDetails: InputChangeEventDetails) => void值变化,第一参就是字符串。reason 恒为 'none'cancel() 无效
onChange(event: ChangeEvent<HTMLInputElement>) => void原生回调,和 onValueChange 一起触发
disabledbooleanfalse忽略用户交互,同时渲染 data-disabled
namestring表单字段名
typestring原生 <input> 类型;不给就是浏览器默认的 text
placeholderstring占位符
renderReactElement | (props, state) => ReactElement换掉渲染的元素
classNamestring | ((state: InputState) => string | undefined)类名。排在封装层那组之后,冲突时你的赢
其余Base UI Input 的 props(原生 input 属性 + ref透传,readOnly / required / autoComplete / aria-invalid / form 都在里面

三个类型一并导出:InputProps / InputState / InputChangeEventDetails