react-cascading-input
v1.2.0
Published
A headless React component for cascading tree-style input with canvas connection lines
Maintainers
Readme
react-cascading-input
一个 Headless 的 React 级联输入组件,通过 Canvas 在父子层级间绘制关系线(贝塞尔曲线 / 折线),支持任意嵌套层级。
特性
- 🎨 Canvas 关系线 — 自动在父子层级间绘制连接线,支持贝塞尔曲线和折线两种样式,可自定义颜色与粗细
- 🧩 Headless 架构 — 每列通过
renderprop 完全自定义渲染,自由集成任意 UI 库 - 📦 零依赖 — 核心无第三方依赖,体积轻量
- ⚡ 无闪烁重绘 — 使用
useLayoutEffect+ResizeObserver,数据变更时关系线无感知更新 - 🔧 TypeScript — 完整类型支持
- 🎯 React 18+ — 兼容 React 18 / 19
安装
npm install react-cascading-input
# 或
pnpm add react-cascading-input基础用法
render 是 ColumnConfig 的必填字段,由你决定每列渲染什么控件:
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } from 'react-cascading-input';
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 120,
hasAdd: true,
render: ({ value, onChange }) => (
<input
placeholder={`请输入`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 120,
hasAdd: true,
render: ({ value, onChange }) => (
<input
placeholder={`请输入`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '框架版本',
dataIndex: 'spec',
width: 180,
hasAdd: false,
render: ({ value, onChange }) => (
<input
placeholder={`请输入`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
];
function App() {
const [value, setValue] = useState([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} />;
}自定义渲染
通过 render 可以渲染任意控件,比如 select,亦或者第三方 UI 库中的组件。
import type { ColumnConfig } from 'react-cascading-input';
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 160,
hasAdd: true,
render: ({ value, onChange }) => (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`请输入`}
/>
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 140,
hasAdd: true,
render: ({ value, onChange }) => (
<select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%' }}
>
<option value="">请选择</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="guangzhou">广州</option>
</select>
),
},
];连线样式
所有连线相关配置通过 line prop 统一管理:
{/* 折线风格 */}
<CascadingInput columns={columns} value={value} onChange={setValue} line={{ style: 'straight' }} />
{/* 自定义连线颜色和粗细 */}
<CascadingInput
columns={columns}
value={value}
onChange={setValue}
line={{ color: '#1890ff', width: 2 }}
/>
{/* 开启溯源动画(水滴沿连线流向父节点) */}
<CascadingInput
columns={columns}
value={value}
onChange={setValue}
line={{ showSource: { color: '#1890ff', size: 6, tailLength: 28 } }}
/>连线锚点(data-line-anchor)
连线锚在控件自身的垂直中心,多选等控件变高时连接点仍在框正中。行内为顶端对齐,父节点钉在它子树的第一行(增删兄弟节点时位置不动);两列控件高度不一致时组件会自动补偿外边距,把父控件中心压到第一行子控件的中心,第一段连线保持水平。控件高度变化由 ResizeObserver 监听后自动重算。
默认锚点是内部控件容器 .tree-cell-content。若 render 在控件之外还渲染了别的内容(最常见的是错误提示文案),容器会被撑高、连接点随之下移,此时在真正的控件上加 data-line-anchor 把锚点固定在它身上(一个单元格只需标记一个):
render: ({ value, onChange, field }) => (
<div>
<input data-line-anchor value={value} onChange={(e) => onChange(e.target.value)} />
{field.error && <div className="err">{field.error}</div>}
</div>
)API
CascadingInput Props
| Prop | Type | Default | Description |
| ----------- | ----------------------------- | ----------- | ---------------- |
| columns | ColumnConfig[] | — | 列配置(必填) |
| value | TreeNode[] | [] | 树数据(受控) |
| onChange | (value: TreeNode[]) => void | — | 数据变更回调 |
| line | LineConfig | {} | 连线配置 |
| effects | Effects | — | 副作用/联动注册函数(Formily 风格),声明各列依赖与派生状态 |
列较多、内容超出容器宽度时会自动出现横向滚动条,无需额外配置。
组件支持
ref:const ref = useRef<CascadingInputHandle>(null),通过ref.current.validate()命令式整树校验(见下方「校验」)。
ColumnConfig
| Property | Type | Required | Description |
| ----------- | --------------------------------------- | -------- | ------------------------------------ |
| title | ReactNode | ✓ | 列标题,支持字符串、带图标的 JSX 等 |
| dataIndex | string | | 数据字段名,纯操作列可不传 |
| width | number | ✓ | 列宽度(px) |
| hasAdd | boolean \| ((ctx: CellContext) => boolean) | ✓ | 是否显示"添加"按钮,传函数可按分支动态决定 |
| render | (props: CellRenderProps) => ReactNode | ✓ | 自定义单元格渲染 |
| addRender | (props: ActionRenderProps) => ReactNode | | 自定义"添加"按钮渲染,位置固定在单元格下方 |
某条分支不允许再分叉时用函数形式:
hasAdd: ({ values }) => values.taskType !== '独占整机'。values是沿本节点路径按dataIndex解析出的各列值(含自身层),与effects里的deps同一口径——不要用ancestors[0]?.xxx数下标,列顺序一调就静默失效。它只控制入口,不会裁剪切换分支前已添加的兄弟节点。
CellRenderProps
以下字段中 node / path / parent / ancestors / level / values 同时也是 CellContext(hasAdd 函数形式接收的参数)。
| Property | Type | Description |
| ----------- | --------------------------- | ---------------------------- |
| value | string | 当前单元格值 |
| onChange | (val: string) => void | 值变更回调(已绑定 path + dataIndex) |
| node | TreeNode | 当前树节点,可访问 node.children 等 |
| path | string[] | 从根到当前节点的完整路径(节点 id 数组) |
| parent | TreeNode \| null | 直接父节点,根层为 null,可读取父级选中值以请求联动 options |
| ancestors | TreeNode[] | 从根到父节点的祖先链(不含当前节点) |
| values | Record<string, any> | 沿本节点路径解析出的各列值,按 dataIndex 取(含自身层),判断分支用它 |
| level | number | 当前层级索引(0 开始) |
| dataIndex | string \| undefined | 数据字段名(纯操作列为 undefined) |
| onAdd | () => void | 添加同级节点回调 |
| onDelete | () => void | 删除当前行回调(在"操作"列的 render 里调用) |
| isLeaf | boolean | 是否为叶子层级 |
| width | number | 列宽度(px) |
| field | FieldState | 由 effects 计算出的派生状态(options / disabled / loading 等),无 effects 时为空对象 |
TreeNode
| Property | Type | Description |
| --------------- | ------------ | ---------------------------------------- |
| id | string | 唯一标识 |
| children | TreeNode[] | 子节点列表 |
| [key: string] | any | 由 ColumnConfig.dataIndex 决定的动态字段 |
LineConfig
| Property | Type | Default | Description |
| ------------ | --------------------------------------- | ----------- | ------------------------------------ |
| style | 'straight' \| 'curve' | 'curve' | 连线风格 |
| color | string | '#d9d9d9' | 连线颜色 |
| width | number | 1.5 | 连线粗细 |
| showSource | boolean \| SourceAnimationOptions | false | 溯源动画,水滴从子节点流向父节点 |
LineStyle
type LineStyle = 'straight' | 'curve';SourceAnimationOptions
溯源动画是一颗水滴沿连线从子节点流向父节点(圆头 + 收成尖的拖尾)。line.showSource 设为 true 用默认配置,设为对象时可自定义:
| Property | Type | Default | Description |
| ----------------- | -------- | --------------- | ---------------- |
| color | string | 跟随 line.color | 水滴颜色。默认连线色偏浅,建议单独指定较深/高饱和度颜色以便可见 |
| size | number | 6 | 水滴头部大小(px) |
| tailLength | number | 28 | 拖尾长度(px),按像素计算,短连线上也保持一致大小 |
| speed | number | 0.004 | 流动速度 |
| breatheAmplitude| number | 1 | 亮度呼吸幅度 0~1 |
| breatheCycle | number | 400 | 呼吸周期(ms) |
联动 / 副作用(effects)
跨层依赖、异步取数、禁用、清空这类联动逻辑,统一通过 effects 声明——类似 Formily 的 effects。用 $.onValueChange(target, deps, handler) 声明「target 列依赖 deps 列」,deps 用 dataIndex 表达、沿目标节点的祖先链解析,因此可跨任意层级(依赖必须是更上层的列)。
handler 里可以:
return { options, disabled, loading }作为派生状态简写;异步场景用ctx.setState({...})写入;ctx.setValue(undefined)清空目标列自身的值;ctx.initial区分「初始化回显」与「用户改动上级」——详情页带初始值挂载时 handler 也会触发(以便拉取 options),此时initial为true,用if (!initial) setValue(undefined)即可保留回显值、只在用户真正改动上级时才清空;ctx.isActive()判断异步返回时本次回调是否仍最新,避免竞态覆盖。
field是 patch 合并,不是替换。 只要某条路径置起了loading: true,每一条出口都必须把它关掉(包括提前return的分支和catch分支),否则残留的loading会让该单元格永久停在"加载中"且被禁用。同理,置过disabled: true后要在恢复分支显式写disabled: false。handler 内未捕获的异常引擎会兜底:记
console.error并自动复位该单元格的loading,不会变成 unhandled rejection。但业务上的失败降级(比如失败时清空 options、给用户提示)仍应自己try/catch。
render 通过 field 读取这些派生状态。每个受影响的单元格以节点 id 为 key 独立存储自己的 field。
import type { Effects } from 'react-cascading-input';
const effects: Effects = ($) => {
// 训练集群依赖训练任务:切换任务 → 清空集群、置 loading、按新入参异步拉取
$.onValueChange('region', ['product'], async ({ deps, setValue, setState, isActive, initial }) => {
if (!initial) setValue(undefined); // 初始化回显时保留已有值,仅用户改动才清空
// 每条出口都要复位 loading:field 是 patch 合并,残留的 loading 会让格子永久禁用
if (!deps.product) return { options: [], disabled: true, loading: false };
setState({ loading: true, disabled: false });
try {
const options = await fetchRegions(deps.product);
if (isActive()) setState({ options, loading: false });
} catch {
if (isActive()) setState({ options: [], loading: false });
}
});
// 框架版本依赖训练集群,同理级联
$.onValueChange('spec', ['region'], async ({ deps, setValue, setState, isActive, initial }) => {
if (!initial) setValue(undefined);
if (!deps.region) return { options: [], disabled: true, loading: false };
setState({ loading: true, disabled: false });
try {
const options = await fetchSpecs(deps.region);
if (isActive()) setState({ options, loading: false });
} catch {
if (isActive()) setState({ options: [], loading: false });
}
});
};
// 列的 render 消费 field:
// render: ({ value, onChange, field }) => (
// <select value={value} disabled={field.disabled || field.loading} onChange={(e) => onChange(e.target.value)}>
// <option value="">{field.loading ? '加载中…' : '请选择'}</option>
// {(field.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}
// </select>
// )
<CascadingInput columns={columns} value={value} onChange={setValue} effects={effects} />校验(onValidate + 命令式 validate())
校验用 $.onValidate(target, rule, deps?) 声明,一份规则同时驱动两种时机:反应式(值变即重算,写入 field.error,首次挂载/回显不报)与命令式(拿组件 ref 调 validate(),强制对所有字段——含未改动的——跑规则、标红并返回聚合结果,提交时用,不必手动遍历 value)。rule 返回错误字符串即不通过;deps 省略只关注自身值,声明后可跨列校验。
import { useRef, useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import type { CascadingInputHandle, Effects, TreeNode } from 'react-cascading-input';
const effects: Effects = ($) => {
// 必填 + 格式:规则内可写多重判断
$.onValidate('spec', ({ value }) => {
const s = (value ?? '').trim();
if (!s) return '框架版本不能为空';
if (!/\d/.test(s)) return '需包含版本号,如 PyTorch 2.1';
});
// 跨列校验:声明 deps,沿祖先链取依赖值判定
$.onValidate('spec', ({ value, deps }) => (value && value === deps.product ? '规格不能与产品同名' : undefined), ['product']);
};
function App() {
const [value, setValue] = useState<TreeNode[]>([]);
const ref = useRef<CascadingInputHandle>(null);
const handleSubmit = () => {
const { valid, errors } = ref.current!.validate(); // 整树校验,未改动字段也标红
if (valid) submit(value);
else console.log(errors); // [{ path, dataIndex, message }]
};
return <CascadingInput ref={ref} columns={columns} value={value} onChange={setValue} effects={effects} />;
}
// render 消费 field.error:错误文案会撑高单元格,给控件标 data-line-anchor 固定连线锚点
// render: ({ value, onChange, field }) => (
// <div>
// <input data-line-anchor value={value} onChange={(e) => onChange(e.target.value)}
// style={{ borderColor: field.error ? '#ff4d4f' : undefined }} />
// {field.error && <span style={{ color: '#ff4d4f', fontSize: 12 }}>{field.error}</span>}
// </div>
// )
validate()返回{ valid: boolean, errors: ValidateError[] },ValidateError为{ path, dataIndex, message }。校验onValidate与取数onValueChange可并存,各自setState/写入会 patch 合并进同一个field,互不覆盖。建议每列一条onValidate(多重判断写在同一 rule 内),避免多条规则争抢同一个error槽。
| Property | Type | Description |
| ---------- | --------- | ------------------------------- |
| options | any | 供 select 等控件使用的候选项 |
| disabled | boolean | 是否禁用该单元格 |
| loading | boolean | 是否处于异步加载中 |
| error | string | 校验错误信息,由 effects 写入、render 展示;空/undefined 视为通过,patch 合并(通过时需显式写 error: undefined 清除) |
| [key] | any | 允许挂载任意自定义派生字段 |
EffectContext(onValueChange handler 参数)
| Property | Type | Description |
| -------------- | ---------------------------------------- | ---------------------------------------- |
| value | any | 目标节点当前值 |
| deps | Record<string, any> | 依赖字段当前值(按 dataIndex,沿祖先链解析) |
| node | TreeNode | 目标节点,其 id 即派生状态的 key |
| path | string[] | 目标节点完整路径 |
| initial | boolean | 是否为该节点首次求值(详情页初始化回显时为 true),用于区分「初始化」与「用户改动上级」,避免误清空回显值 |
| setValue | (val: any) => void | 设置目标节点自身值(清空传 undefined) |
| setState | (patch: FieldState) => void | 合并写入派生状态,异步友好 |
| setTreeValue | (dataIndex: string, val: any) => void | 设置本分支上某祖先/自身字段的值 |
| isActive | () => boolean | 异步返回后判断本次回调是否仍最新(防竞态) |
开发
# 安装依赖
pnpm install
# 启动文档开发服务器(含在线演示)
pnpm dev
# 运行测试
pnpm test
# 构建
pnpm buildLicense
MIT
