Cadenza
EN

带前后缀的输入框 —— 图标、文本、按钮和输入框共处一个边框,焦点环画在整组上

一个输入框加若干附加物(图标、单位、按钮、快捷键提示),共处同一个边框,焦点环 画在整组而不是输入框上。shadcn 的组合,原样收录 —— SearchField 就是拿它搭的。

输入框底下是 Base UI 的 Input,把它放进 Base UI 的 Field.Root 里就自动接线, 不用往下传 props;按钮是 Base UI 的 Button,行与文本域是纯 DOM。

使用

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
  InputGroupTextarea,
} from '@gedatou/cadenza-ui'
<InputGroup>
  <InputGroupInput aria-label="搜索" placeholder="搜索..." />
  <InputGroupAddon>
    <IconSearch aria-hidden />
  </InputGroupAddon>
</InputGroup>

组成

一行只有两类角色:一个控件,若干 addon —— 按钮、文本、图标都装在 addon 里。

InputGroup
├── InputGroupInput 或 InputGroupTextarea
└── InputGroupAddon          # 可以有多个,align 决定摆在哪
    ├── InputGroupButton
    ├── InputGroupText
    └── 图标 / <kbd> / 任意节点

对齐

InputGroupAddonalign 决定它摆在哪,默认 inline-start

align位置配谁示例
inline-start(默认)行首InputGroupInput图标文本
inline-end行尾InputGroupInput按钮快捷键提示
block-start上方整行InputGroupTextarea文本域与底部工具条
block-end下方整行InputGroupTextarea同上

InputGroupInputinline-*,配 InputGroupTextareablock-* block-* 会让 InputGroup 从一行变成竖排、高度随之放开,行内的两个值则不动布局。

摆位置靠的是 CSS order,不是 DOM 顺序 —— 所以装了按钮的 addon 写在控件之后, Tab 进这一组才先落到输入框。纯图标、纯文本的 addon 不可聚焦,写在前面也一样。

图标

InputGroupAddon 里直接放图标;默认在行首,align="inline-end" 换到行尾:

文本

InputGroupText 用来嵌固定的协议头、单位、域名后缀 —— 用户就不用自己敲了:

按钮

InputGroupButton 底下是 Base UI 的 Buttonsize 默认 xs,纯图标按钮用 icon-xs

快捷键提示

addon 里直接放 <kbd>InputGroup 会替它对齐并收掉多余外边距:

文本域与底部工具条

align="block-end" 的 addon 横跨整行、排到下方,InputGroup 随之从一行变成竖向布局 (block-start 则排到上方)。配 InputGroupTextarea 就是一个带工具条的输入区:

焦点环怎么来的

环画在 InputGroup 上,触发条件是内部某个 data-slot="input-group-control" 的元素 处于 :focus-visible

has-[[data-slot=input-group-control]:focus-visible]:border-ring
has-[[data-slot=input-group-control]:focus-visible]:ring-3

InputGroupInput / InputGroupTextarea 自带这个 slot,同时用 focus-visible:ring-0 把控件自身的环压掉,所以整组只有一圈。

这个 slot 值是接线契约,不是标记。 别给控件传 data-slot —— 组件是先写属性、 后展开 props,外面传一个同名的会把契约值顶掉,焦点环就此静默消失:不报错,只是不再出现。 要加自己的标记,另起一个属性名。

文本输入框在鼠标点击时也会命中 :focus-visible(浏览器认定它预期要打字),所以点一下 就有环 —— 这是原生行为,不是额外加的。

自定义控件

反过来说:环认的是那个属性,不是具体哪个组件。自己的控件挂上 data-slot="input-group-control" 就接进同一套焦点管理 —— 第三方的自适应高度文本域 这类都能这么塞进来:

import TextareaAutosize from 'react-textarea-autosize'
 
<InputGroup>
  <TextareaAutosize
    className="flex-1 resize-none border-0 bg-transparent focus-visible:ring-0"
    data-slot="input-group-control"
  />
  <InputGroupAddon align="block-end">
    <InputGroupButton variant="default">发送</InputGroupButton>
  </InputGroupAddon>
</InputGroup>

自带的两个控件除了这个属性还压掉了自己的边框和环(border-0 / focus-visible:ring-0), 自定义控件也得照做,否则会看到两圈。

什么时候用 InputGroup

场景
输入框上什么都不附加Input,多行用 Textarea
图标、单位、按钮、快捷键提示要和输入框共用边框InputGroup
搜索框SearchField —— 拿这套部件搭的,清除按钮和 Escape 清空都已经接好

边框归这一行所有,所以 InputGroupInput 自己是没有边框的 —— 单独拿出来用会是个 裸输入框。一旦有东西要和输入框共用边框,再换过来。

状态与 className

InputGroup 没有自己的状态 props:状态挂在里面的控件上,样式画在外面的组上,中间 靠 :has() 连起来。

组上的选择器效果
has-[[data-slot=input-group-control]:focus-visible]画焦点环
has-disabled整组置灰 + 变底色
has-[[data-slot][aria-invalid=true]]校验失败环
has-[>[data-align=block-start|block-end]]从一行变竖排

要整组禁用就禁用里面的控件(或者把整组放进禁用的 Field.Root,Base UI 的 Input 会自己继承)。

has-disabled 这条是坑:组里只要有任何一个 :disabled 元素,整组就按禁用渲染。 SearchField 因此在只读时不渲染清除按钮;hidden 没用,display: none 的元素 仍然会被 :has(:disabled) 命中。

三个 data-slot 可以当选择器用:

data-slot挂在
input-group组根,那个 role="group"<div>
input-group-addon每个 addon;旁边还有个 data-align 回显当前 align
input-group-controlInputGroupInput / InputGroupTextarea焦点环的接线点

Props

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

只有 InputGroupInputclassName 是双形态(字符串,或 (state) => string)—— 它底下是 Base UI 的 Input。其余部件的 className 都是字符串:多数是纯 DOM 元素,而 InputGroupButton 虽然底下是 Base UI 的 Button,类名却要穿过 cva (它会把函数直接丢掉),所以类型也如实收窄成字符串。

InputGroup

一个 role="group"<div>。边框、圆角、高度和焦点环都在它身上,没有自己的状态 props —— 它靠 :has() 观察后代,四条选择器见状态与 className

Prop类型默认值说明
classNamestring类名
其余原生 <div> 的 props透传

InputGroupAddon

Prop类型默认值说明
align'inline-start' | 'inline-end' | 'block-start' | 'block-end''inline-start'行首 / 行尾 / 上方整行 / 下方整行
classNamestring类名

一个 addon 里可以塞多个按钮和图标,它们横向排开:

<InputGroupAddon align="inline-end">
  <InputGroupButton>撤销</InputGroupButton>
  <InputGroupButton>重做</InputGroupButton>
</InputGroupAddon>

点击 addon 的空白处会把焦点送给组内的输入框(点在按钮上时不会)—— 这样整个边框范围内哪里都能点出焦点。为了焦点顺序,装了按钮的 addon 要写在控件之后, 用 align 摆位置,见对齐

InputGroupButton

Prop类型默认值说明
onClick(event) => void点击
size'xs' | 'sm' | 'icon-xs' | 'icon-sm''xs'纯图标用 icon-*
variantButton 的变体'ghost'默认是幽灵按钮,融进输入框
disabledbooleanfalse禁用
type'button' | 'submit' | 'reset''button'表单里需要提交时改它
classNamestring类名

纯图标按钮必给 aria-label

<InputGroupButton>订阅</InputGroupButton>
<InputGroupButton aria-label="复制链接" size="icon-xs">
  <IconCopy aria-hidden />
</InputGroupButton>

InputGroupInput

Prop类型默认值说明
placeholderstring占位符
onValueChange(value, details) => voidBase UI 的受控回调,拿到的是字符串而不是事件
classNamestring | (state) => string类名,参数是 Base UI 的字段控件状态(disabled / valid / dirty / touched…)
其余Base UI Input 的 props透传

自带 data-slot="input-group-control"(见上文),去掉了自己的边框和 环,交给外层的组来画。没有可见 label 时记得给 aria-label

InputGroupTextarea

Prop类型默认值说明
rowsnumber初始行数
classNamestring类名
其余原生 <textarea> 的 props透传

同样带 input-group-control。组里有它时高度自动放开,不再固定成一行。

InputGroupText

Prop类型默认值说明
classNamestring类名

一段静音色的文本,用于单位、协议头、计数。它是 <span>,不接收焦点。

六个部件的 props 类型也一并导出:InputGroupProps / InputGroupAddonProps / InputGroupButtonProps / InputGroupInputProps / InputGroupTextProps / InputGroupTextareaProps