From 4c117bd37aa6ca537ad5336c627ec0394ea71569 Mon Sep 17 00:00:00 2001 From: Linzp Date: Tue, 17 Mar 2026 16:39:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B8=8D=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...5\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" | 1 - "prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" | 1 - ...7\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" | 1 - 3 files changed, 3 deletions(-) delete mode 100644 "prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" delete mode 100644 "prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" delete mode 100644 "prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" diff --git "a/prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" "b/prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" deleted file mode 100644 index 96ca14b..0000000 --- "a/prompts/\345\221\275\345\220\215\347\244\272\344\276\213\347\274\226\345\206\231\347\244\272\344\276\213\346\217\217\350\277\260.md" +++ /dev/null @@ -1 +0,0 @@ -根据doc/example.json中code引用的代码实现内容完善doc/example.json中的title和description字段 \ No newline at end of file diff --git "a/prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" "b/prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" deleted file mode 100644 index 99d1a29..0000000 --- "a/prompts/\347\224\237\346\210\220\346\226\207\346\241\243.md" +++ /dev/null @@ -1 +0,0 @@ -根据代码帮我完成项目概述输出到doc/summary.md,api文档输出到doc/api.md markdown不要使用h1,h2对应的标签,doc/summary.md不需要项目概述标题,不需要依赖项说明,可以适当描述一下项目的特点吸引用户使用,doc/api.md不需要API文档标题,使用h3及之后,api文档优先使用table格式,api部分不需要示例代码,请严格遵守格式要求 \ No newline at end of file diff --git "a/prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" "b/prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" deleted file mode 100644 index 1aafab8..0000000 --- "a/prompts/\347\224\237\346\210\220\350\257\255\350\250\200\345\214\205.md" +++ /dev/null @@ -1 +0,0 @@ -将代码中的中文文案抽取到src/locale/zh-CN.js中,输入一个locale对象,locale对象中为key和文案的键值对,然后再翻译到en-US.js中,不要修改原始文件的任何内容 \ No newline at end of file From f2cff67c409de15c047a25b3a944a247e6580100 Mon Sep 17 00:00:00 2001 From: Linzp Date: Wed, 18 Mar 2026 10:33:45 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E5=88=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 395 ++++++++++++++++++++++++++++++++---------- doc/advanced.js | 134 ++++++++++++++ doc/api.md | 70 +++++--- doc/base.js | 99 ++++------- doc/controlled.js | 71 ++++++++ doc/example.json | 44 ++++- package.json | 3 +- src/index.js | 185 +++++++++++++------- src/style.module.scss | 8 + src/utils.js | 204 ++++++++++++++++++++++ src/utils.test.js | 240 +++++++++++++++++++++++++ 12 files changed, 1209 insertions(+), 245 deletions(-) create mode 100644 doc/advanced.js create mode 100644 doc/controlled.js create mode 100644 src/utils.js create mode 100644 src/utils.test.js diff --git a/.gitignore b/.gitignore index caef309..9078792 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,4 @@ build pnpm-lock.yaml package-lock.json example +prompts \ No newline at end of file diff --git a/README.md b/README.md index 4d47261..0632631 100644 --- a/README.md +++ b/README.md @@ -29,78 +29,49 @@ column-split 是一个轻量级的 React 组件,用于通过拖动分隔条动 #### 示例代码 - 基础示例 -- 展示 column-split 组件的基本用法,包括正常状态、只读状态和禁用状态。 -- _ColumnSplit(@kne/current-lib_column-split)[import * as _ColumnSplit from "@kne/column-split"],(@kne/current-lib_column-split/dist/index.css) +- 展示 ColumnSplit 组件的基本用法,包括正常、只读、禁用三种状态。 +- _ColumnSplit(@kne/current-lib_column-split)[import * as _ColumnSplit from "@kne/column-split"],(@kne/current-lib_column-split/dist/index.css),antd(antd) ```jsx const { default: ColumnSplit } = _ColumnSplit; +const { Flex, Divider } = antd; const BaseExample = () => { + const columns = [ + { + name: 'frontend', + title: '前端开发', + color: '#5386FA' + }, + { + name: 'backend', + title: '后端开发', + color: '#8B5CF6' + }, + { + name: 'testing', + title: '测试', + color: '#FCD34D' + } + ]; + return ( -
-
正常状态:
- -
只读状态:
- -
禁用状态:
- -
+ + + 正常状态 + + + + + 只读状态 + + + + + 禁用状态 + + + ); }; @@ -108,29 +79,275 @@ render(); ``` +- 受控模式 +- 展示 ColumnSplit 的受控模式用法,通过 value 和 onChange 控制列宽分配。 +- _ColumnSplit(@kne/current-lib_column-split)[import * as _ColumnSplit from "@kne/column-split"],(@kne/current-lib_column-split/dist/index.css),antd(antd) + +```jsx +const { default: ColumnSplit } = _ColumnSplit; +const { Flex, Divider, Button, Space } = antd; +const { useState } = React; + +const ControlledExample = () => { + const columns = [ + { + name: 'must', + title: '必修课', + color: '#EF4444' + }, + { + name: 'elective', + title: '选修课', + color: '#3B82F6' + }, + { + name: 'practice', + title: '实践课', + color: '#10B981' + } + ]; + + const [value, setValue] = useState({ + must: 0.5, + elective: 0.3, + practice: 0.2 + }); + + const handleReset = () => { + setValue({ + must: 1 / 3, + elective: 1 / 3, + practice: 1 / 3 + }); + }; + + const handleEqualDistribution = () => { + setValue({ + must: 0.4, + elective: 0.4, + practice: 0.2 + }); + }; + + return ( + + 受控模式 - 课程学分占比分配 + + + + + + + + + +
当前值:
+
+          {JSON.stringify(value, null, 2)}
+        
+
+
+ ); +}; + +render(); + +``` + +- 高级功能 +- 展示 ColumnSplit 的高级功能,包括最小/最大占比限制、自定义渲染、allowZero 等特性。 +- _ColumnSplit(@kne/current-lib_column-split)[import * as _ColumnSplit from "@kne/column-split"],(@kne/current-lib_column-split/dist/index.css),antd(antd) + +```jsx +const { default: ColumnSplit } = _ColumnSplit; +const { Flex, Divider, Space, Switch, Typography } = antd; +const { useState } = React; + +const AdvancedExample = () => { + const [allowZero, setAllowZero] = useState(false); + + // 带最小/最大占比限制的列配置 + const columnsWithLimits = [ + { + name: 'core', + title: '核心业务', + color: '#EF4444', + min: 0.3, + max: 0.7, + render: ({ value }) => `${Math.round(value * 100)}% (核心)` + }, + { + name: 'growth', + title: '增长业务', + color: '#3B82F6', + min: 0.2, + max: 0.5 + }, + { + name: 'exploration', + title: '探索业务', + color: '#10B981', + min: 0.1, + max: 0.3 + } + ]; + + // 点击隐藏列示例 - 更多列便于演示 + const columnsForAllowZero = [ + { + name: 'frontend', + title: '前端', + color: '#3B82F6' + }, + { + name: 'backend', + title: '后端', + color: '#8B5CF6' + }, + { + name: 'testing', + title: '测试', + color: '#10B981' + }, + { + name: 'devops', + title: '运维', + color: '#F59E0B' + }, + { + name: 'design', + title: '设计', + color: '#EC4899' + } + ]; + + // 自定义渲染列内容的示例 + const columnsWithRenderItem = [ + { + name: 'revenue', + title: '营收', + color: '#8B5CF6' + }, + { + name: 'profit', + title: '利润', + color: '#F59E0B' + } + ]; + + return ( + + + 最小/最大占比限制 + + 核心业务:30%-70% | 增长业务:20%-50% | 探索业务:10%-30% + + + + + + 自定义 render 函数 + + 核心业务列使用自定义 render 显示格式化文本 + + + + + + allowZero - 点击隐藏列 + + 允许隐藏列: + + + + 开启后可点击列将其隐藏(占比变为0),隐藏的列会显示在底部,点击可恢复。拖动调整占比始终可用。 + + + + + + 自定义 renderItem + + 使用 renderItem 完全自定义列内容渲染 + + ( + +
{item.title}
+
+ {valueStr} +
+
+ 序号:{index + 1} +
+
+ )} + /> +
+
+ ); +}; + +render(); + +``` + ### API -### API 文档 - -| 属性 | 类型 | 默认值 | 说明 | -|----------------|-----------------|--------------------------------------------------------|----| -| `columns` | `Array` | 列配置数组,每列包含 `name`、`title`、`min`、`max`、`color` 等属性。 | -| `className` | `string` | 自定义组件类名。 | -| `renderItem` | `Function` | 自定义列内容渲染函数,参数为 `{ item, value, valueStr, el, index }`。 | -| `readOnly` | `boolean` | 是否只读模式,禁止调整列宽。 | -| `disabled` | `boolean` | 是否禁用模式,禁止调整列宽。 | -| `defaultValue` | `Object` | 当前列宽比例值,键为列名,值为比例(0-1)非受控模式。 | -| `value` | `Object` | 当前列宽比例值,键为列名,值为比例(0-1)。 | -| `onChange` | `Function` | 列宽调整回调函数,参数为调整后的 `value` 对象。 | - -### 列配置 (`columns`) - -| 属性 | 类型 | 默认值 | 说明 | -|----------|------------|----------------------------|-------------------| -| `name` | `string` | - | 列的唯一标识。 | -| `title` | `string` | - | 列的标题。 | -| `color` | `string` | - | 列的颜色(支持 CSS 颜色值)。 | -| `min` | `number` | - | 列的最小占比(0-1)。 | -| `max` | `number` | - | 列的最大占比(0-1)。 | -| `render` | `Function` | 自定义列值渲染函数,参数为 `{ value }`。 | +### ColumnSplit + +通过拖动分隔条动态分配元素占比的 React 组件,支持多种状态和丰富的自定义选项。 + +#### 属性 + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|-------|------| +| `columns` | `Array` | `[]` | 列配置数组,详见下方列配置说明 | +| `className` | `string` | - | 自定义组件类名 | +| `renderItem` | `Function` | - | 自定义列内容渲染函数,参数为 `{ item, value, valueStr, el, index }` | +| `readOnly` | `boolean` | `false` | 是否只读模式,禁止调整列宽 | +| `disabled` | `boolean` | `false` | 是否禁用模式,禁止调整列宽 | +| `allowZero` | `boolean` | `false` | 是否允许点击列将其隐藏(占比为0),隐藏的列显示在底部可恢复 | +| `defaultValue` | `Object` | - | 当前列宽比例值(非受控模式),键为列名,值为比例(0-1) | +| `value` | `Object` | - | 当前列宽比例值(受控模式),键为列名,值为比例(0-1) | +| `onChange` | `Function` | - | 列宽调整回调函数,参数为调整后的 `value` 对象 | + +#### 列配置 (`columns`) + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|-------|------| +| `name` | `string` | - | 列的唯一标识(必填) | +| `title` | `string` | - | 列的标题 | +| `color` | `string` | - | 列的颜色(支持 CSS 颜色值) | +| `min` | `number` | - | 列的最小占比(0-1) | +| `max` | `number` | - | 列的最大占比(0-1) | +| `render` | `Function` | - | 自定义列值渲染函数,参数为 `{ value }`,返回显示的占比文本 | + +#### renderItem 参数说明 + +`renderItem` 函数接收一个对象参数,包含以下属性: + +| 属性 | 类型 | 描述 | +|------|------|------| +| `item` | `Object` | 当前列的配置对象 | +| `value` | `number` | 当前列的占比值(0-1) | +| `valueStr` | `string` | 格式化后的占比文本(如 "50%") | +| `el` | `ReactElement` | 默认渲染的元素 | +| `index` | `number` | 当前列的索引 | + +#### 使用场景 + +- 课程学分占比分配 +- 业务资源分配 +- 时间分配管理 +- 投资组合配置 + diff --git a/doc/advanced.js b/doc/advanced.js new file mode 100644 index 0000000..54572df --- /dev/null +++ b/doc/advanced.js @@ -0,0 +1,134 @@ +const { default: ColumnSplit } = _ColumnSplit; +const { Flex, Divider, Space, Switch, Typography } = antd; +const { useState } = React; + +const AdvancedExample = () => { + const [allowZero, setAllowZero] = useState(false); + + // 带最小/最大占比限制的列配置 + const columnsWithLimits = [ + { + name: 'core', + title: '核心业务', + color: '#EF4444', + min: 0.3, + max: 0.7, + render: ({ value }) => `${Math.round(value * 100)}% (核心)` + }, + { + name: 'growth', + title: '增长业务', + color: '#3B82F6', + min: 0.2, + max: 0.5 + }, + { + name: 'exploration', + title: '探索业务', + color: '#10B981', + min: 0.1, + max: 0.3 + } + ]; + + // 点击隐藏列示例 - 更多列便于演示 + const columnsForAllowZero = [ + { + name: 'frontend', + title: '前端', + color: '#3B82F6' + }, + { + name: 'backend', + title: '后端', + color: '#8B5CF6' + }, + { + name: 'testing', + title: '测试', + color: '#10B981' + }, + { + name: 'devops', + title: '运维', + color: '#F59E0B' + }, + { + name: 'design', + title: '设计', + color: '#EC4899' + } + ]; + + // 自定义渲染列内容的示例 + const columnsWithRenderItem = [ + { + name: 'revenue', + title: '营收', + color: '#8B5CF6' + }, + { + name: 'profit', + title: '利润', + color: '#F59E0B' + } + ]; + + return ( + + + 最小/最大占比限制 + + 核心业务:30%-70% | 增长业务:20%-50% | 探索业务:10%-30% + + + + + + 自定义 render 函数 + + 核心业务列使用自定义 render 显示格式化文本 + + + + + + allowZero - 点击隐藏列 + + 允许隐藏列: + + + + 开启后可点击列将其隐藏(占比变为0),隐藏的列会显示在底部,点击可恢复。拖动调整占比始终可用。 + + + + + + 自定义 renderItem + + 使用 renderItem 完全自定义列内容渲染 + + ( + +
{item.title}
+
+ {valueStr} +
+
+ 序号:{index + 1} +
+
+ )} + /> +
+
+ ); +}; + +render(); diff --git a/doc/api.md b/doc/api.md index 3966c89..45aa075 100644 --- a/doc/api.md +++ b/doc/api.md @@ -1,23 +1,47 @@ -### API 文档 - -| 属性 | 类型 | 默认值 | 说明 | -|----------------|-----------------|--------------------------------------------------------|----| -| `columns` | `Array` | 列配置数组,每列包含 `name`、`title`、`min`、`max`、`color` 等属性。 | -| `className` | `string` | 自定义组件类名。 | -| `renderItem` | `Function` | 自定义列内容渲染函数,参数为 `{ item, value, valueStr, el, index }`。 | -| `readOnly` | `boolean` | 是否只读模式,禁止调整列宽。 | -| `disabled` | `boolean` | 是否禁用模式,禁止调整列宽。 | -| `defaultValue` | `Object` | 当前列宽比例值,键为列名,值为比例(0-1)非受控模式。 | -| `value` | `Object` | 当前列宽比例值,键为列名,值为比例(0-1)。 | -| `onChange` | `Function` | 列宽调整回调函数,参数为调整后的 `value` 对象。 | - -### 列配置 (`columns`) - -| 属性 | 类型 | 默认值 | 说明 | -|----------|------------|----------------------------|-------------------| -| `name` | `string` | - | 列的唯一标识。 | -| `title` | `string` | - | 列的标题。 | -| `color` | `string` | - | 列的颜色(支持 CSS 颜色值)。 | -| `min` | `number` | - | 列的最小占比(0-1)。 | -| `max` | `number` | - | 列的最大占比(0-1)。 | -| `render` | `Function` | 自定义列值渲染函数,参数为 `{ value }`。 | \ No newline at end of file +### ColumnSplit + +通过拖动分隔条动态分配元素占比的 React 组件,支持多种状态和丰富的自定义选项。 + +#### 属性 + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|-------|------| +| `columns` | `Array` | `[]` | 列配置数组,详见下方列配置说明 | +| `className` | `string` | - | 自定义组件类名 | +| `renderItem` | `Function` | - | 自定义列内容渲染函数,参数为 `{ item, value, valueStr, el, index }` | +| `readOnly` | `boolean` | `false` | 是否只读模式,禁止调整列宽 | +| `disabled` | `boolean` | `false` | 是否禁用模式,禁止调整列宽 | +| `allowZero` | `boolean` | `false` | 是否允许点击列将其隐藏(占比为0),隐藏的列显示在底部可恢复 | +| `defaultValue` | `Object` | - | 当前列宽比例值(非受控模式),键为列名,值为比例(0-1) | +| `value` | `Object` | - | 当前列宽比例值(受控模式),键为列名,值为比例(0-1) | +| `onChange` | `Function` | - | 列宽调整回调函数,参数为调整后的 `value` 对象 | + +#### 列配置 (`columns`) + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|-------|------| +| `name` | `string` | - | 列的唯一标识(必填) | +| `title` | `string` | - | 列的标题 | +| `color` | `string` | - | 列的颜色(支持 CSS 颜色值) | +| `min` | `number` | - | 列的最小占比(0-1) | +| `max` | `number` | - | 列的最大占比(0-1) | +| `render` | `Function` | - | 自定义列值渲染函数,参数为 `{ value }`,返回显示的占比文本 | + +#### renderItem 参数说明 + +`renderItem` 函数接收一个对象参数,包含以下属性: + +| 属性 | 类型 | 描述 | +|------|------|------| +| `item` | `Object` | 当前列的配置对象 | +| `value` | `number` | 当前列的占比值(0-1) | +| `valueStr` | `string` | 格式化后的占比文本(如 "50%") | +| `el` | `ReactElement` | 默认渲染的元素 | +| `index` | `number` | 当前列的索引 | + +#### 使用场景 + +- 课程学分占比分配 +- 业务资源分配 +- 时间分配管理 +- 投资组合配置 diff --git a/doc/base.js b/doc/base.js index 9a64e7c..df6b0fd 100644 --- a/doc/base.js +++ b/doc/base.js @@ -1,71 +1,42 @@ const { default: ColumnSplit } = _ColumnSplit; +const { Flex, Divider } = antd; const BaseExample = () => { + const columns = [ + { + name: 'frontend', + title: '前端开发', + color: '#5386FA' + }, + { + name: 'backend', + title: '后端开发', + color: '#8B5CF6' + }, + { + name: 'testing', + title: '测试', + color: '#FCD34D' + } + ]; + return ( -
-
正常状态:
- -
只读状态:
- -
禁用状态:
- -
+ + + 正常状态 + + + + + 只读状态 + + + + + 禁用状态 + + + ); }; diff --git a/doc/controlled.js b/doc/controlled.js new file mode 100644 index 0000000..f7768cd --- /dev/null +++ b/doc/controlled.js @@ -0,0 +1,71 @@ +const { default: ColumnSplit } = _ColumnSplit; +const { Flex, Divider, Button, Space } = antd; +const { useState } = React; + +const ControlledExample = () => { + const columns = [ + { + name: 'must', + title: '必修课', + color: '#EF4444' + }, + { + name: 'elective', + title: '选修课', + color: '#3B82F6' + }, + { + name: 'practice', + title: '实践课', + color: '#10B981' + } + ]; + + const [value, setValue] = useState({ + must: 0.5, + elective: 0.3, + practice: 0.2 + }); + + const handleReset = () => { + setValue({ + must: 1 / 3, + elective: 1 / 3, + practice: 1 / 3 + }); + }; + + const handleEqualDistribution = () => { + setValue({ + must: 0.4, + elective: 0.4, + practice: 0.2 + }); + }; + + return ( + + 受控模式 - 课程学分占比分配 + + + + + + + + + +
当前值:
+
+          {JSON.stringify(value, null, 2)}
+        
+
+
+ ); +}; + +render(); diff --git a/doc/example.json b/doc/example.json index c7122fb..cff931a 100644 --- a/doc/example.json +++ b/doc/example.json @@ -3,7 +3,7 @@ "list": [ { "title": "基础示例", - "description": "展示 column-split 组件的基本用法,包括正常状态、只读状态和禁用状态。", + "description": "展示 ColumnSplit 组件的基本用法,包括正常、只读、禁用三种状态。", "code": "./base.js", "scope": [ { @@ -13,6 +13,48 @@ }, { "packageName": "@kne/current-lib_column-split/dist/index.css" + }, + { + "name": "antd", + "packageName": "antd" + } + ] + }, + { + "title": "受控模式", + "description": "展示 ColumnSplit 的受控模式用法,通过 value 和 onChange 控制列宽分配。", + "code": "./controlled.js", + "scope": [ + { + "name": "_ColumnSplit", + "packageName": "@kne/current-lib_column-split", + "importStatement": "import * as _ColumnSplit from \"@kne/column-split\"" + }, + { + "packageName": "@kne/current-lib_column-split/dist/index.css" + }, + { + "name": "antd", + "packageName": "antd" + } + ] + }, + { + "title": "高级功能", + "description": "展示 ColumnSplit 的高级功能,包括最小/最大占比限制、自定义渲染、allowZero 等特性。", + "code": "./advanced.js", + "scope": [ + { + "name": "_ColumnSplit", + "packageName": "@kne/current-lib_column-split", + "importStatement": "import * as _ColumnSplit from \"@kne/column-split\"" + }, + { + "packageName": "@kne/current-lib_column-split/dist/index.css" + }, + { + "name": "antd", + "packageName": "antd" } ] } diff --git a/package.json b/package.json index 54de813..7ea9347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kne/column-split", - "version": "1.0.2", + "version": "1.0.3", "description": "通过拖动分隔条分配元素占比", "syntax": { "esmodules": true @@ -90,6 +90,7 @@ }, "dependencies": { "@kne/use-control-value": "^0.1.9", + "@kne/use-ref-callback": "^0.1.2", "classname": "^0.0.0", "lodash": "^4.17.21" } diff --git a/src/index.js b/src/index.js index aa77851..741e2ca 100644 --- a/src/index.js +++ b/src/index.js @@ -1,81 +1,132 @@ -import { Splitter, Flex } from 'antd'; +import { Splitter, Flex, Space, Button } from 'antd'; import classnames from 'classnames'; import get from 'lodash/get'; -import transform from 'lodash/transform'; import useControlValue from '@kne/use-control-value'; +import useRefCallback from '@kne/use-ref-callback'; import { MoreOutlined } from '@ant-design/icons'; import style from './style.module.scss'; +import { Fragment, useEffect } from 'react'; +import { calculateResizeValues, hideColumn, showColumn, calculateInitialValue, getColumnMin, getColumnMax } from './utils'; -const ColumnSplit = ({ columns = [], className, renderItem, readOnly, disabled, ...props }) => { +const ColumnSplit = ({ columns = [], className, renderItem, readOnly, disabled, allowZero = false, ...props }) => { const [value, onChange] = useControlValue(props); + const initValue = useRefCallback(() => { + if (value) { + return; + } + onChange(calculateInitialValue(columns)); + }); + useEffect(() => { + initValue(); + }, [initValue]); if (columns.length === 0) { return null; } + const activeColumns = columns.filter(column => { + return get(value, column.name) !== 0; + }); + const disabledColumns = columns.filter(column => { + return get(value, column.name) === 0; + }); return ( - { - if (readOnly || disabled) { - return; - } - const total = sizes.reduce((sum, target) => sum + target, 0); - const last = columns[columns.length - 1]; - let otherValue = 0; - const value = transform( - columns, - (result, { name }, index) => { - result[name] = Math.round((100 * sizes[index]) / total) / 100; - if (name !== last.name) { - otherValue += result[name]; - } - }, - {} - ); - //修正value最后一个值; - value[last.name] = Math.round(100 * (1 - otherValue)) / 100; - onChange(value); - }} - > - {columns.map((column, index) => { - const itemValue = get(value, column.name) || 1 / columns.length; - const valueStr = typeof column.render === 'function' ? column.render({ value: itemValue }) : `${Math.round(100 * itemValue)}%`; - const el = ( - - {column.title} - {valueStr} - - ); - return ( - -
- {typeof renderItem === 'function' ? renderItem({ item: column, value: itemValue, valueStr, el, index }) : el} - {index < columns.length - 1 && ( - - - - )} - {index > 0 && ( - - - - )} -
-
- ); - })} -
+ + { + if (readOnly || disabled) { + return; + } + const newValue = calculateResizeValues(activeColumns, sizes, value, disabledColumns); + onChange(newValue); + }} + > + {activeColumns.map((column, index) => { + const itemValue = get(value, column.name) || 1 / activeColumns.length; + const valueStr = typeof column.render === 'function' ? column.render({ value: itemValue }) : `${Math.round(100 * itemValue)}%`; + const el = ( + + {column.title} + {valueStr} + + ); + return ( + +
1 + })} + > +
{ + if (readOnly || disabled || !allowZero) { + return; + } + if (activeColumns.length <= 1) { + return; + } + const newValue = hideColumn(value, column, activeColumns); + if (newValue) { + onChange(newValue); + } + }} + > + {typeof renderItem === 'function' ? renderItem({ item: column, value: itemValue, valueStr, el, index }) : el} +
+ {activeColumns.length > 1 && index < activeColumns.length - 1 && ( + + + + )} + {activeColumns.length > 1 && index > 0 && ( + + + + )} +
+
+ ); + })} +
+ + {disabledColumns.map((column, index) => { + const itemValue = 0; + const valueStr = typeof column.render === 'function' ? column.render({ value: itemValue }) : `${Math.round(100 * itemValue)}%`; + const el = ( + + ); + return {typeof renderItem === 'function' ? renderItem({ item: column, value: itemValue, valueStr, el, index }) : el}; + })} + +
); }; diff --git a/src/style.module.scss b/src/style.module.scss index a649455..2a3208f 100644 --- a/src/style.module.scss +++ b/src/style.module.scss @@ -38,6 +38,10 @@ } } +.zero-item { + color: var(--color, var(--font-color, '#222')); +} + .column-item-content { height: 100%; width: 100%; @@ -48,6 +52,10 @@ white-space: nowrap; overflow: hidden; + &.can-hide { + cursor: pointer; + } + &:before { pointer-events: none; content: ''; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..fcc6f26 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,204 @@ +import get from 'lodash/get'; +import transform from 'lodash/transform'; + +/** + * 根据拖动大小计算新的列宽值 + * @param {Array} activeColumns - 当前活跃的列配置数组 + * @param {Array} sizes - 拖动后的各列大小 + * @param {Object} currentValue - 当前的值对象 + * @param {Array} disabledColumns - 当前隐藏的列配置数组 + * @returns {Object} 新的值对象 + */ +export const calculateResizeValues = (activeColumns, sizes, currentValue, disabledColumns = []) => { + if (!activeColumns || activeColumns.length === 0) { + return currentValue; + } + + const total = sizes.reduce((sum, target) => sum + target, 0); + const last = activeColumns[activeColumns.length - 1]; + let otherValue = 0; + + const newValue = transform( + activeColumns, + (result, { name }, index) => { + result[name] = Math.round((100 * sizes[index]) / total) / 100; + if (name !== last.name) { + otherValue += result[name]; + } + }, + {} + ); + + // 修正最后一个值确保总和为1 + newValue[last.name] = Math.round(100 * (1 - otherValue)) / 100; + + // 赋值其他隐藏列的值 + disabledColumns.forEach(column => { + newValue[column.name] = 0; + }); + + return newValue; +}; + +/** + * 计算隐藏某列后的新值 + * @param {Object} currentValue - 当前的值对象 + * @param {Object} columnToHide - 要隐藏的列配置 + * @param {Array} activeColumns - 当前活跃的列配置数组 + * @returns {Object|null} 新的值对象,如果无法隐藏则返回 null + */ +export const hideColumn = (currentValue, columnToHide, activeColumns) => { + if (!activeColumns || activeColumns.length <= 1) { + return null; + } + + const columnValue = get(currentValue, columnToHide.name) || 1 / activeColumns.length; + const otherColumns = activeColumns.filter(col => col.name !== columnToHide.name); + + // 计算每列可接受的最大额外宽度 + const availableSpace = otherColumns.map(col => { + const currentVal = get(currentValue, col.name) || 1 / activeColumns.length; + const maxVal = typeof col.max === 'number' ? col.max : 1; + return Math.max(0, maxVal - currentVal); + }); + const totalAvailable = availableSpace.reduce((sum, v) => sum + v, 0); + + // 如果没有足够空间,则不执行隐藏 + if (totalAvailable < columnValue - 0.001) { + return null; + } + + // 按可用空间比例分配 + const newValue = Object.assign({}, currentValue); + newValue[columnToHide.name] = 0; + + if (totalAvailable > 0) { + otherColumns.forEach((col, index) => { + const currentVal = get(currentValue, col.name) || 1 / activeColumns.length; + const addValue = (availableSpace[index] / totalAvailable) * columnValue; + newValue[col.name] = Math.min(1, currentVal + addValue); + }); + } + + // 修正确保总和为1 + const total = Object.keys(newValue).reduce((sum, key) => { + return activeColumns.find(col => col.name === key) ? sum + newValue[key] : sum; + }, 0); + const lastActiveCol = otherColumns[otherColumns.length - 1]; + if (lastActiveCol && Math.abs(total - 1) > 0.001) { + newValue[lastActiveCol.name] = Math.round((1 - (total - newValue[lastActiveCol.name])) * 100) / 100; + } + + return newValue; +}; + +/** + * 计算恢复某列后的新值 + * @param {Object} currentValue - 当前的值对象 + * @param {Object} columnToShow - 要恢复的列配置 + * @param {Array} activeColumns - 当前活跃的列配置数组 + * @returns {Object} 新的值对象 + */ +export const showColumn = (currentValue, columnToShow, activeColumns) => { + // 计算每列可释放的最小额外宽度(当前值 - min) + const reducibleSpace = activeColumns.map(col => { + const currentVal = get(currentValue, col.name) || 1 / activeColumns.length; + const minVal = typeof col.min === 'number' ? col.min : 0; + return Math.max(0, currentVal - minVal); + }); + const totalReducible = reducibleSpace.reduce((sum, v) => sum + v, 0); + + // 计算恢复列需要的宽度 + const targetMin = typeof columnToShow.min === 'number' ? columnToShow.min : 0; + const targetMax = typeof columnToShow.max === 'number' ? columnToShow.max : 1; + // 恢复列的目标宽度,取平均分配的值或可用空间中的较小者 + const targetValue = Math.min(targetMax, Math.max(targetMin, 1 / (activeColumns.length + 1))); + + // 实际分配给恢复列的宽度 + const actualTarget = Math.min(targetValue, totalReducible); + + const newValue = Object.assign({}, currentValue); + newValue[columnToShow.name] = actualTarget; + + if (totalReducible > 0 && actualTarget > 0) { + // 按比例分配,确保各列不低于 min + let remainingToDistribute = actualTarget; + + // 先尝试按比例分配 + activeColumns.forEach((col, idx) => { + if (remainingToDistribute <= 0) return; + + const currentVal = get(currentValue, col.name) || 1 / activeColumns.length; + const minVal = typeof col.min === 'number' ? col.min : 0; + const maxReduce = Math.max(0, currentVal - minVal); + const reduceValue = Math.min(maxReduce, (reducibleSpace[idx] / totalReducible) * actualTarget); + + newValue[col.name] = currentVal - reduceValue; + remainingToDistribute -= reduceValue; + }); + + // 如果还有未分配的宽度,从有空间的列中继续分配 + if (remainingToDistribute > 0.001) { + activeColumns.forEach(col => { + if (remainingToDistribute <= 0) return; + + const currentVal = newValue[col.name]; + const minVal = typeof col.min === 'number' ? col.min : 0; + const canReduce = Math.max(0, currentVal - minVal); + const reduceAmount = Math.min(canReduce, remainingToDistribute); + + newValue[col.name] = currentVal - reduceAmount; + remainingToDistribute -= reduceAmount; + }); + } + } + + // 修正确保总和为1 + const total = Object.keys(newValue).reduce((sum, key) => sum + newValue[key], 0); + if (Math.abs(total - 1) > 0.001) { + // 找到第一个有足够宽度的列来修正 + for (const col of activeColumns) { + const adjustAmount = 1 - total; + if (newValue[col.name] + adjustAmount >= (typeof col.min === 'number' ? col.min : 0)) { + newValue[col.name] = Math.round((newValue[col.name] + adjustAmount) * 100) / 100; + break; + } + } + } + + return newValue; +}; + +/** + * 计算初始值 + * @param {Array} columns - 列配置数组 + * @returns {Object} 初始值对象 + */ +export const calculateInitialValue = columns => { + return transform( + columns, + (result, { name }) => { + result[name] = 1 / columns.length; + }, + {} + ); +}; + +/** + * 计算列的最小值 + * @param {Object} column - 列配置 + * @param {number} defaultMin - 默认最小值 + * @returns {number} 最小值 + */ +export const getColumnMin = (column, defaultMin = 0.01) => { + return typeof column.min === 'number' ? Math.max(column.min, defaultMin) : defaultMin; +}; + +/** + * 计算列的最大值 + * @param {Object} column - 列配置 + * @returns {number} 最大值 + */ +export const getColumnMax = column => { + return typeof column.max === 'number' ? column.max : 1; +}; diff --git a/src/utils.test.js b/src/utils.test.js new file mode 100644 index 0000000..542b6ff --- /dev/null +++ b/src/utils.test.js @@ -0,0 +1,240 @@ +import { calculateResizeValues, hideColumn, showColumn, calculateInitialValue, getColumnMin, getColumnMax } from './utils'; + +describe('utils', () => { + describe('calculateResizeValues', () => { + const activeColumns = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B' }, + { name: 'c', title: 'C' } + ]; + const disabledColumns = [{ name: 'd', title: 'D' }]; + + it('should calculate new values based on sizes', () => { + const sizes = [100, 200, 200]; // total: 500 + const currentValue = { a: 0.2, b: 0.4, c: 0.4, d: 0 }; + const result = calculateResizeValues(activeColumns, sizes, currentValue, disabledColumns); + + expect(result.a).toBeCloseTo(0.2, 2); + expect(result.b).toBeCloseTo(0.4, 2); + expect(result.c).toBeCloseTo(0.4, 2); + expect(result.d).toBe(0); + }); + + it('should ensure total equals 1', () => { + const sizes = [150, 150, 200]; // total: 500 + const currentValue = { a: 0.3, b: 0.3, c: 0.4 }; + const result = calculateResizeValues(activeColumns, sizes, currentValue); + + const total = Object.values(result).reduce((sum, v) => sum + v, 0); + expect(total).toBeCloseTo(1, 2); + }); + + it('should return current value if activeColumns is empty', () => { + const currentValue = { a: 0.5, b: 0.5 }; + const result = calculateResizeValues([], [100, 100], currentValue); + expect(result).toBe(currentValue); + }); + + it('should handle two columns', () => { + const twoColumns = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B' } + ]; + const sizes = [300, 200]; // total: 500 + const currentValue = { a: 0.5, b: 0.5 }; + const result = calculateResizeValues(twoColumns, sizes, currentValue); + + expect(result.a).toBeCloseTo(0.6, 2); + expect(result.b).toBeCloseTo(0.4, 2); + }); + }); + + describe('hideColumn', () => { + const activeColumns = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B' }, + { name: 'c', title: 'C' } + ]; + + it('should hide column and distribute its value to others', () => { + const currentValue = { a: 0.33, b: 0.33, c: 0.34 }; + const result = hideColumn(currentValue, activeColumns[0], activeColumns); + + expect(result.a).toBe(0); + expect(result.b).toBeGreaterThan(0.33); + expect(result.c).toBeGreaterThan(0.34); + }); + + it('should return null if only one active column', () => { + const currentValue = { a: 1 }; + const singleColumn = [{ name: 'a', title: 'A' }]; + const result = hideColumn(currentValue, singleColumn[0], singleColumn); + + expect(result).toBeNull(); + }); + + it('should return null if no active columns', () => { + const currentValue = { a: 1 }; + const result = hideColumn(currentValue, { name: 'a' }, []); + + expect(result).toBeNull(); + }); + + it('should respect max constraint when hiding', () => { + const columnsWithMax = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B', max: 0.6 }, + { name: 'c', title: 'C', max: 0.6 } + ]; + const currentValue = { a: 0.5, b: 0.25, c: 0.25 }; + const result = hideColumn(currentValue, columnsWithMax[0], columnsWithMax); + + expect(result.b).toBeLessThanOrEqual(0.6); + expect(result.c).toBeLessThanOrEqual(0.6); + }); + + it('should return null if not enough space to distribute', () => { + const columnsWithMax = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B', max: 0.51 }, + { name: 'c', title: 'C', max: 0.51 } + ]; + const currentValue = { a: 0.5, b: 0.5, c: 0 }; // b already at max + const result = hideColumn(currentValue, columnsWithMax[0], columnsWithMax); + + // Not enough space to hide 'a' + expect(result).toBeNull(); + }); + + it('should ensure total equals 1 after hiding', () => { + const currentValue = { a: 0.4, b: 0.35, c: 0.25 }; + const result = hideColumn(currentValue, activeColumns[0], activeColumns); + + const total = Object.values(result).reduce((sum, v) => sum + v, 0); + expect(total).toBeCloseTo(1, 2); + }); + }); + + describe('showColumn', () => { + const activeColumns = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B' } + ]; + + it('should show column and take space from active columns', () => { + const currentValue = { a: 0.6, b: 0.4, c: 0 }; + const columnToShow = { name: 'c', title: 'C' }; + const result = showColumn(currentValue, columnToShow, activeColumns); + + expect(result.c).toBeGreaterThan(0); + expect(result.a).toBeLessThan(0.6); + expect(result.b).toBeLessThan(0.4); + }); + + it('should respect min constraint when showing', () => { + const columnsWithMin = [ + { name: 'a', title: 'A', min: 0.3 }, + { name: 'b', title: 'B', min: 0.3 } + ]; + const currentValue = { a: 0.5, b: 0.5, c: 0 }; + const columnToShow = { name: 'c', title: 'C' }; + const result = showColumn(currentValue, columnToShow, columnsWithMin); + + expect(result.a).toBeGreaterThanOrEqual(0.3); + expect(result.b).toBeGreaterThanOrEqual(0.3); + }); + + it('should respect target column min/max constraints', () => { + const currentValue = { a: 0.5, b: 0.5, c: 0 }; + const columnToShow = { name: 'c', title: 'C', min: 0.2, max: 0.3 }; + const result = showColumn(currentValue, columnToShow, activeColumns); + + expect(result.c).toBeGreaterThanOrEqual(0.2); + expect(result.c).toBeLessThanOrEqual(0.3); + }); + + it('should ensure total equals 1 after showing', () => { + const currentValue = { a: 0.5, b: 0.5, c: 0 }; + const columnToShow = { name: 'c', title: 'C' }; + const result = showColumn(currentValue, columnToShow, activeColumns); + + const total = Object.values(result).reduce((sum, v) => sum + v, 0); + expect(total).toBeCloseTo(1, 2); + }); + + it('should handle empty active columns', () => { + const currentValue = { c: 0 }; + const columnToShow = { name: 'c', title: 'C' }; + const result = showColumn(currentValue, columnToShow, []); + + expect(result.c).toBeDefined(); + }); + }); + + describe('calculateInitialValue', () => { + it('should calculate equal distribution', () => { + const columns = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B' }, + { name: 'c', title: 'C' } + ]; + const result = calculateInitialValue(columns); + + expect(result.a).toBeCloseTo(1 / 3, 10); + expect(result.b).toBeCloseTo(1 / 3, 10); + expect(result.c).toBeCloseTo(1 / 3, 10); + }); + + it('should handle two columns', () => { + const columns = [ + { name: 'a', title: 'A' }, + { name: 'b', title: 'B' } + ]; + const result = calculateInitialValue(columns); + + expect(result.a).toBe(0.5); + expect(result.b).toBe(0.5); + }); + + it('should handle single column', () => { + const columns = [{ name: 'a', title: 'A' }]; + const result = calculateInitialValue(columns); + + expect(result.a).toBe(1); + }); + }); + + describe('getColumnMin', () => { + it('should return column min if set and greater than default', () => { + const column = { name: 'a', min: 0.1 }; + expect(getColumnMin(column, 0.01)).toBe(0.1); + }); + + it('should return default min if column min is smaller', () => { + const column = { name: 'a', min: 0.001 }; + expect(getColumnMin(column, 0.01)).toBe(0.01); + }); + + it('should return default min if column min is not set', () => { + const column = { name: 'a' }; + expect(getColumnMin(column, 0.01)).toBe(0.01); + }); + + it('should use default defaultMin', () => { + const column = { name: 'a' }; + expect(getColumnMin(column)).toBe(0.01); + }); + }); + + describe('getColumnMax', () => { + it('should return column max if set', () => { + const column = { name: 'a', max: 0.8 }; + expect(getColumnMax(column)).toBe(0.8); + }); + + it('should return 1 if column max is not set', () => { + const column = { name: 'a' }; + expect(getColumnMax(column)).toBe(1); + }); + }); +});