Skip to content

Repository files navigation

react-cascading-input

npm version license types

一个 Headless 的 React 级联输入组件,通过 Canvas 在父子层级间绘制关系线(贝塞尔曲线 / 折线),支持任意嵌套层级。

特性

  • 🎨 Canvas 关系线 — 自动在父子层级间绘制连接线,支持贝塞尔曲线和折线两种样式,可自定义颜色与粗细
  • 🧩 Headless 架构 — 每列通过 render prop 完全自定义渲染,自由集成任意 UI 库
  • 📦 零依赖 — 核心无第三方依赖,体积轻量
  • 无闪烁重绘 — 使用 useLayoutEffect + ResizeObserver,数据变更时关系线无感知更新
  • 🔧 TypeScript — 完整类型支持
  • 🎯 React 18+ — 兼容 React 18 / 19

安装

npm install react-cascading-input
#
pnpm add react-cascading-input

基础用法

renderColumnConfig 的必填字段,由你决定每列渲染什么控件:

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 } }}
/>

API

CascadingInput Props

Prop Type Default Description
columns ColumnConfig[] 列配置(必填)
value TreeNode[] [] 树数据(受控)
onChange (value: TreeNode[]) => void 数据变更回调
line LineConfig {} 连线配置
effects Effects 副作用/联动注册函数(Formily 风格),声明各列依赖与派生状态

列较多、内容超出容器宽度时会自动出现横向滚动条,无需额外配置。

组件支持 refconst ref = useRef<CascadingInputHandle>(null),通过 ref.current.validate() 命令式整树校验(见下方「校验」)。

ColumnConfig

Property Type Required Description
title ReactNode 列标题,支持字符串、带图标的 JSX 等
dataIndex string 数据字段名,纯操作列可不传
width number 列宽度(px)
hasAdd boolean 是否显示"添加"按钮
render (props: CellRenderProps) => ReactNode 自定义单元格渲染
addRender (props: ActionRenderProps) => ReactNode 自定义"添加"按钮渲染,位置固定在单元格下方

CellRenderProps

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[] 从根到父节点的祖先链(不含当前节点)
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 列」,depsdataIndex 表达、沿目标节点的祖先链解析,因此可跨任意层级(依赖必须是更上层的列)。

handler 里可以:

  • return { options, disabled, loading } 作为派生状态简写;异步场景用 ctx.setState({...}) 写入;
  • ctx.setValue(undefined) 清空目标列自身的值;
  • ctx.initial 区分「初始化回显」与「用户改动上级」——详情页带初始值挂载时 handler 也会触发(以便拉取 options),此时 initialtrue,用 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,首次挂载/回显不报)与命令式(拿组件 refvalidate(),强制对所有字段——含未改动的——跑规则、标红并返回聚合结果,提交时用,不必手动遍历 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:
// render: ({ value, onChange, field }) => (
//     <div>
//         <input 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 build

License

MIT

About

react-cascading-input 是一个 Headless 的 React 级联输入组件,通过 Canvas 在父子层级间自动绘制关系线,支持任意嵌套深度。每列通过 render prop 完全自定义渲染。核心零依赖,完整 TypeScript 类型支持,兼容 React 18/19。

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages