npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-cascading-input

v1.2.0

Published

A headless React component for cascading tree-style input with canvas connection lines

Readme

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

基础用法

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 build

License

MIT