diff --git a/specs/033-tree-select-demo-alignment/acceptance.md b/specs/033-tree-select-demo-alignment/acceptance.md new file mode 100644 index 000000000..abdd30e06 --- /dev/null +++ b/specs/033-tree-select-demo-alignment/acceptance.md @@ -0,0 +1,57 @@ +# 验收记录 + +- Flutter 原 PR 基线:`6a69569c104f6576fd6b39253eedbe48faa2b75c` +- 当前 `develop` 基线:`6433854a142a5ca7ee528aba9a1a8e7d72bea3ca` +- 小程序基线:`b60cdc8a1dce1f06dd45cb4e41eefd31c674e514` +- 公开运行页:`https://tdesign.tencent.com/miniprogram/live/m2w/program/miniprogram/#!pages/tree-select/tree-select.html` +- Figma 文件:`TDesign for mobile`,页面 `TreeSelect 树形选择器`。 +- Figma Demo 节点:`28591:40742`,尺寸 `378 x 1510`。 +- Figma 两列组件变体:`27387:23982`,尺寸 `375 x 336`。 +- Figma 一级列变体:`27449:25981`,宽度 `103`。 +- API Review:完整路径值、`value + onChanged` 受控模式和数据深度驱动列数已收敛; + 不增加公开参数。 + +## 验证结果 + +- 最新本地 merge commit:`c9beb6f3`,完成目标分支与 `origin/develop` 的冲突整合; + 字体说明同时保留 TreeSelect 与 Stepper,并补齐 TreeSelect 上游来源与子集工具版本。 +- 三个场景已改为页面真实使用的独立示例 Widget;生成片段包含 imports、完整 options、 + 受控 value 与 `onChanged/setState`,不再依赖页面私有成员。 +- Flutter 3.32.0:TreeSelect 组件与生成器测试 18 项、Demo 测试 7 项全部通过; + 组件包和 example 包 `flutter analyze` 均为 `No issues found`。 +- Flutter 3.47.0(latest):TreeSelect 组件与生成器测试 18 项、Demo 测试 7 项全部通过; + 组件包和 example 包 `flutter analyze` 均为 `No issues found`。 +- 组件生产代码覆盖率:`286/288 = 99.31%`,高于 95% 门槛。 +- 路径契约收敛后,Flutter 3.32.0 与 Flutter 3.47.0 的 TreeSelect 组件测试 + 19 项全部通过;覆盖单选数量、重复路径、同级字符串/null/嵌套值重复,以及 + `==` 相等但 `hashCode` 不同的自定义值。 +- Flutter 3.32.0 的组件包与 example 包完整 `flutter analyze --fatal-infos`、 + Flutter 3.47.0 的 TreeSelect 实现与测试静态分析均为 `No issues found`; + 组件生产代码覆盖率为 `311/313 = 99.36%`。 +- Flutter 3.32.0 与 Flutter 3.47.0 的 example release Web 构建均通过; + debug 唯一性校验不进入 release 执行路径。 +- iPhone 16(iOS 18.2 模拟器)真实点击通过:单选切换分支并选择叶子、多选新增 + 与取消叶子均正确更新;本轮仅增加契约文档和 debug 校验,不涉及视觉变化,未更新 Golden。 +- API 生成配置已登记真实公开类型 `TTreeSelectOption`,生成文档同时包含选项值唯一性、 + 完整路径、单选数量和重复路径约束。 +- 同级选项唯一性改用与路径解析一致的 `==` 线性比较;自定义值在 `==` 相等但 + `hashCode` 不同的情况下仍会被 debug 校验拒绝,不依赖业务类型的哈希实现。 +- `dart run tool/generate_example_code.dart --check` 通过。 +- Linux `amd64` + Flutter 3.32.0 重新生成明暗两张 Demo Golden,并在同一容器 + 去掉 `--update-goldens` 复跑,2 项全部通过。 +- 合并最新 develop 后再次在固定 Linux `amd64` + Flutter 3.32.0 容器中仅比较 + 明暗 Golden,2 项全部通过,未更新基线。 +- 本地 Flutter Web 页面真实点击通过:单选切换到 `广东省 / 云浮市`、多选新增 + `广东省 / 深圳市`、三列切换到 `广东省 / 深圳市 / 盐田区`,服务端无异常输出。 +- “查看代码”仅在非 Web 端展示;Widget 级真实入口点击已逐个打开三个底部面板, + 并核对面板内容与对应生成片段完全一致。 +- TreeSelect 专用字体子集已覆盖页面可见文案,人工查看明暗 Golden 无缺字符号; + 子集 SHA-256 为 + `1cbb5418c4cd91a103deb2a28f68dacb1e895a6efcb8f2f5e8198d67d38ac7ca`。 +- 原 PR 在 2026-09-01 的 CI 结果不代表当前合并与修改后的结果;推送后的远端 CI + 与 CodeBuddy Review 须以新 head 的实际结果为准。 + +## 未验证项 + +- Android/iOS 系统字体逐像素差异不由 Linux Golden 证明。 +- 小程序只作为交互流程参考,Flutter 不引入小程序的 `keys` 映射或非受控默认值 API。 diff --git a/specs/033-tree-select-demo-alignment/evidence/miniprogram-top.png b/specs/033-tree-select-demo-alignment/evidence/miniprogram-top.png new file mode 100644 index 000000000..0a2ba48a8 Binary files /dev/null and b/specs/033-tree-select-demo-alignment/evidence/miniprogram-top.png differ diff --git a/specs/033-tree-select-demo-alignment/plan.md b/specs/033-tree-select-demo-alignment/plan.md new file mode 100644 index 000000000..ca80a9941 --- /dev/null +++ b/specs/033-tree-select-demo-alignment/plan.md @@ -0,0 +1,8 @@ +# Plan + +1. 固定 Figma 设计节点、小程序版本和 Flutter 实现基线。 +2. 合并最新 `develop`,把 TreeSelect 登记迁移到集中测试 manifest。 +3. 按设计稿修正 Demo 数据、初始状态、默认列宽和三列溢出。 +4. 补齐单选、多选、三列、Theme 插值、查看代码、覆盖率和明暗 Golden 证据。 +5. 在 Flutter 3.32.0 与 latest 运行严格 analyze 和非视觉回归,Golden 只在 + Flutter 3.32.0 Linux 更新和复验。 diff --git a/specs/033-tree-select-demo-alignment/spec.md b/specs/033-tree-select-demo-alignment/spec.md new file mode 100644 index 000000000..1191407a6 --- /dev/null +++ b/specs/033-tree-select-demo-alignment/spec.md @@ -0,0 +1,29 @@ +# TreeSelect 设计与公开 Demo 对齐 + +## 目标 + +以 TDesign for mobile 设计稿为视觉和文案权威基线,对齐基础、多选和三列公开示例; +小程序只用于校验逐级切换、单选替换和多选切换流程。 + +## API Review + +- `TTreeSelectOption` 以不可变递归节点表达任意深度,不增加小程序 keys 映射层。 +- 同一层级的 `TTreeSelectOption.value` 必须按 `==` 语义保持唯一,debug 模式拒绝歧义配置; + 校验不依赖业务值的 `hashCode`,与路径解析和选中比较保持一致。 +- `value` 使用根到叶的完整路径列表;单选和多选共享同一稳定值类型,`multiple` 只改变选择数量。 +- 单选模式 debug 校验最多一条路径;暂时无法解析到叶子的受控路径不显示选中态,并回退到首个可用分支。 +- `value + onChanged` 为严格受控模式,空 callback 表达禁用,不引入 `defaultValue`。 +- 三列是数据深度产生的布局结果,不增加列数参数。 +- 设计稿的 375 px 容器中,两列宽度为 `103 + 272`,三列宽度为 + `103 + 103 + 169`;默认不产生水平滚动。 +- 修正默认列宽会改变既有默认布局,属于 breaking change;公开构造参数和值类型不变。 + +## 行为契约 + +- Demo 顺序、标题、说明、六行省市数据、初始选中项与设计稿一致。 +- 基础与多选初始路径均为 `广东省 / 汕尾市`;多选继续点选其他城市时追加完整路径,再次点选时移除。 +- 三列初始路径为 `广东省 / 深圳市 / 南山区`,展示六个区级选项。 +- 三个公开实例各自持有受控状态,并作为页面真实渲染与“查看代码”的共同来源。 +- “查看代码”片段包含 imports、完整 options、value 与回调,可脱离 Demo 页面独立复用。 +- 组件面板在 Demo 容器中无外层左缩进;内部文字保留 16 px 间距。 +- 一级和中间分支的活动文字使用主色,叶子文字保持主文字色并使用主色勾选图标。 diff --git a/specs/033-tree-select-demo-alignment/tasks.md b/specs/033-tree-select-demo-alignment/tasks.md new file mode 100644 index 000000000..9e72899d0 --- /dev/null +++ b/specs/033-tree-select-demo-alignment/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +- [x] DONE 固定 Figma、小程序与 Flutter 基线 +- [x] DONE 合并最新 develop 并迁移集中测试 manifest +- [x] DONE 修正 Demo 数据、初始状态和生产组件默认列宽 +- [x] DONE 更新生成片段、功能测试和 Linux 3.32 Golden +- [x] DONE 完成双版本 analyze、组件/Demo 回归与覆盖率 +- [x] DONE 创建本地 develop merge commit +- [x] DONE 提交并推送 TreeSelect 对齐改动 +- [x] DONE 请求 CNB CodeBuddy 聚焦 Review 并跟进结果 +- [x] DONE 合并最新 develop 并解决 TreeSelect/Stepper 字体说明冲突 +- [x] DONE 按 Review 修复三个“查看代码”片段的自包含契约与字体溯源 +- [x] DONE 复跑双版本测试、严格 analyze、Linux Golden 与浏览器点击验收 diff --git a/specs/033-tree-select-demo-alignment/visual-comparison.md b/specs/033-tree-select-demo-alignment/visual-comparison.md new file mode 100644 index 000000000..967cc0eff --- /dev/null +++ b/specs/033-tree-select-demo-alignment/visual-comparison.md @@ -0,0 +1,30 @@ +# TreeSelect 视觉与交互对齐 + +## 权威顺序 + +1. Figma `TDesign for mobile` / `TreeSelect 树形选择器`。 +2. Figma Demo 直接使用的 TreeSelect 组件变体和 item 变体。 +3. 小程序公开 Demo 只用于验证操作流程。 + +## 逐项对比 + +| 契约 | Figma 目标 | Flutter 处理 | +| --- | --- | --- | +| 页面文案 | `TreeSelect 树形选择器`;`用于多层级数据的逐级选择。` | 直接使用设计稿文案 | +| 公开分组 | `01 组件类型`:基础、多选;`02 组件状态`:三列 | 三个 ExampleItem 顺序与标题对齐 | +| 两列数据 | 六个省级行,广东省展示六个市级行 | Demo 使用同一列表和顺序 | +| 初始选中 | 基础和多选均为 `广东省 / 汕尾市` | 页面持有完整路径 | +| 三列数据 | `广东省 / 深圳市 / 南山区`,每列在目标状态可见 6 行 | 递归 options 自然产生第三列 | +| 容器 | 375 px 宽、336 px 高,行高 56 px | 保持 6 行高度 | +| 两列宽度 | `103 + 272` | 默认宽度总和为 375 | +| 三列宽度 | `103 + 103 + 169` | 默认不产生水平滚动 | +| 活动分支 | 一级和中间列文字为主色 | 组件内部统一解析 | +| 选中叶子 | 主文字色 + 24 px 主色勾选 | 保持叶子文字色,使用 `TIcons.check` | +| Demo 外边界 | 组件面板贴齐页面 Demo 容器 | 复用 `ExamplePage.compactDemo` | + +## 小程序交互取舍 + +- 合理复用:点击分支只切换下一列;单选点击叶子替换路径;多选点击叶子切换该完整路径。 +- Flutter 调整:使用声明式 `value + onChanged`,不复制小程序的非受控 `defaultValue`、 + `keys` 映射和事件层级参数。 +- 深度由数据决定;设计稿推荐 2-3 层,不新增公开列数开关。 diff --git a/tdesign-component/example/assets/api/tree-select_api.md b/tdesign-component/example/assets/api/tree-select_api.md index ba7397509..04d9975b7 100644 --- a/tdesign-component/example/assets/api/tree-select_api.md +++ b/tdesign-component/example/assets/api/tree-select_api.md @@ -5,7 +5,18 @@ | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | key | Key? | - | 组件标识,用于区分或保留组件状态。 | -| multiple | bool | false | 是否允许选择多个叶子节点。 | +| multiple | bool | false | 是否允许选择多个叶子节点。 为 false 时,`value` 最多包含一条路径。 | | onChanged | ValueChanged>>? | - | 选中路径变化回调;为 null 时禁用。 | | options | List | - | 根选项。 | -| value | List> | - | 受控选中路径。 | +| value | List> | - | 受控选中路径。 每一项应为从根到叶子的完整 `TTreeSelectOption.value` 路径。 暂时无法在 `options` 中解析到叶子的路径不会显示选中态。 组件会回退到首个可用分支。单选模式最多传入一条,多选模式可传入多条且不得重复。 | + + +### TTreeSelectOption +#### 默认构造方法 + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| children | List | const [] | 子选项。 | +| disabled | bool | false | 是否禁用。 | +| label | String | - | 展示文案。 | +| value | Object? | - | 业务值;同一层级的选项必须保持唯一。 值可为 null,但同一层级最多只能有一个 null 值。 | diff --git a/tdesign-component/example/assets/code/tree-select.TreeSelectMultipleExample.txt b/tdesign-component/example/assets/code/tree-select.TreeSelectMultipleExample.txt new file mode 100644 index 000000000..b1cb87168 --- /dev/null +++ b/tdesign-component/example/assets/code/tree-select.TreeSelectMultipleExample.txt @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `TreeSelectMultipleExample()`。 +class TreeSelectMultipleExample extends StatefulWidget { + const TreeSelectMultipleExample({super.key}); + + @override + State createState() => + _TreeSelectMultipleExampleState(); +} + +class _TreeSelectMultipleExampleState extends State { + static const _options = [ + TTreeSelectOption( + label: '甘肃省', + value: 'gansu', + children: [TTreeSelectOption(label: '兰州市', value: 'lanzhou')], + ), + TTreeSelectOption( + label: '广东省', + value: 'guangdong', + children: [ + TTreeSelectOption(label: '汕头市', value: 'shantou'), + TTreeSelectOption(label: '汕尾市', value: 'shanwei'), + TTreeSelectOption(label: '韶关市', value: 'shaoguan'), + TTreeSelectOption(label: '深圳市', value: 'shenzhen'), + TTreeSelectOption(label: '阳江市', value: 'yangjiang'), + TTreeSelectOption(label: '云浮市', value: 'yunfu'), + ], + ), + TTreeSelectOption( + label: '贵州省', + value: 'guizhou', + children: [TTreeSelectOption(label: '贵阳市', value: 'guiyang')], + ), + TTreeSelectOption( + label: '海南省', + value: 'hainan', + children: [TTreeSelectOption(label: '海口市', value: 'haikou')], + ), + TTreeSelectOption( + label: '河北省', + value: 'hebei', + children: [TTreeSelectOption(label: '石家庄市', value: 'shijiazhuang')], + ), + TTreeSelectOption( + label: '黑龙江省', + value: 'heilongjiang', + children: [TTreeSelectOption(label: '哈尔滨市', value: 'haerbin')], + ), + ]; + + List> _value = const [ + ['guangdong', 'shanwei'], + ]; + + @override + Widget build(BuildContext context) => TTreeSelect( + key: const ValueKey('tree-select-multiple'), + options: _options, + value: _value, + multiple: true, + onChanged: (value) => setState(() => _value = value), + ); +} diff --git a/tdesign-component/example/assets/code/tree-select.TreeSelectSingleExample.txt b/tdesign-component/example/assets/code/tree-select.TreeSelectSingleExample.txt new file mode 100644 index 000000000..f8b09ae5c --- /dev/null +++ b/tdesign-component/example/assets/code/tree-select.TreeSelectSingleExample.txt @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `TreeSelectSingleExample()`。 +class TreeSelectSingleExample extends StatefulWidget { + const TreeSelectSingleExample({super.key}); + + @override + State createState() => + _TreeSelectSingleExampleState(); +} + +class _TreeSelectSingleExampleState extends State { + static const _options = [ + TTreeSelectOption( + label: '甘肃省', + value: 'gansu', + children: [TTreeSelectOption(label: '兰州市', value: 'lanzhou')], + ), + TTreeSelectOption( + label: '广东省', + value: 'guangdong', + children: [ + TTreeSelectOption(label: '汕头市', value: 'shantou'), + TTreeSelectOption(label: '汕尾市', value: 'shanwei'), + TTreeSelectOption(label: '韶关市', value: 'shaoguan'), + TTreeSelectOption(label: '深圳市', value: 'shenzhen'), + TTreeSelectOption(label: '阳江市', value: 'yangjiang'), + TTreeSelectOption(label: '云浮市', value: 'yunfu'), + ], + ), + TTreeSelectOption( + label: '贵州省', + value: 'guizhou', + children: [TTreeSelectOption(label: '贵阳市', value: 'guiyang')], + ), + TTreeSelectOption( + label: '海南省', + value: 'hainan', + children: [TTreeSelectOption(label: '海口市', value: 'haikou')], + ), + TTreeSelectOption( + label: '河北省', + value: 'hebei', + children: [TTreeSelectOption(label: '石家庄市', value: 'shijiazhuang')], + ), + TTreeSelectOption( + label: '黑龙江省', + value: 'heilongjiang', + children: [TTreeSelectOption(label: '哈尔滨市', value: 'haerbin')], + ), + ]; + + List> _value = const [ + ['guangdong', 'shanwei'], + ]; + + @override + Widget build(BuildContext context) => TTreeSelect( + key: const ValueKey('tree-select-single'), + options: _options, + value: _value, + onChanged: (value) => setState(() => _value = value), + ); +} diff --git a/tdesign-component/example/assets/code/tree-select.TreeSelectThreeColumnsExample.txt b/tdesign-component/example/assets/code/tree-select.TreeSelectThreeColumnsExample.txt new file mode 100644 index 000000000..f48ba6c6f --- /dev/null +++ b/tdesign-component/example/assets/code/tree-select.TreeSelectThreeColumnsExample.txt @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `TreeSelectThreeColumnsExample()`。 +class TreeSelectThreeColumnsExample extends StatefulWidget { + const TreeSelectThreeColumnsExample({super.key}); + + @override + State createState() => + _TreeSelectThreeColumnsExampleState(); +} + +class _TreeSelectThreeColumnsExampleState + extends State { + static const _options = [ + TTreeSelectOption( + label: '甘肃省', + value: 'gansu', + children: [ + TTreeSelectOption( + label: '兰州市', + value: 'lanzhou', + children: [TTreeSelectOption(label: '城关区', value: 'chengguan')], + ), + ], + ), + TTreeSelectOption( + label: '广东省', + value: 'guangdong', + children: [ + TTreeSelectOption( + label: '汕头市', + value: 'shantou', + children: [TTreeSelectOption(label: '金平区', value: 'jinping')], + ), + TTreeSelectOption( + label: '汕尾市', + value: 'shanwei', + children: [TTreeSelectOption(label: '城区', value: 'chengqu')], + ), + TTreeSelectOption( + label: '韶关市', + value: 'shaoguan', + children: [TTreeSelectOption(label: '浈江区', value: 'zhenjiang')], + ), + TTreeSelectOption( + label: '深圳市', + value: 'shenzhen', + children: [ + TTreeSelectOption(label: '龙华区', value: 'longhua'), + TTreeSelectOption(label: '罗湖区', value: 'luohu'), + TTreeSelectOption(label: '南山区', value: 'nanshan'), + TTreeSelectOption(label: '坪山区', value: 'pingshan'), + TTreeSelectOption(label: '其它区', value: 'other'), + TTreeSelectOption(label: '盐田区', value: 'yantian'), + ], + ), + TTreeSelectOption( + label: '阳江市', + value: 'yangjiang', + children: [TTreeSelectOption(label: '江城区', value: 'jiangcheng')], + ), + TTreeSelectOption( + label: '云浮市', + value: 'yunfu', + children: [TTreeSelectOption(label: '云城区', value: 'yuncheng')], + ), + ], + ), + TTreeSelectOption( + label: '贵州省', + value: 'guizhou', + children: [ + TTreeSelectOption( + label: '贵阳市', + value: 'guiyang', + children: [TTreeSelectOption(label: '南明区', value: 'nanming')], + ), + ], + ), + TTreeSelectOption( + label: '海南省', + value: 'hainan', + children: [ + TTreeSelectOption( + label: '海口市', + value: 'haikou', + children: [TTreeSelectOption(label: '秀英区', value: 'xiuying')], + ), + ], + ), + TTreeSelectOption( + label: '河北省', + value: 'hebei', + children: [ + TTreeSelectOption( + label: '石家庄市', + value: 'shijiazhuang', + children: [TTreeSelectOption(label: '长安区', value: 'changan')], + ), + ], + ), + TTreeSelectOption( + label: '黑龙江省', + value: 'heilongjiang', + children: [ + TTreeSelectOption( + label: '哈尔滨市', + value: 'haerbin', + children: [TTreeSelectOption(label: '道里区', value: 'daoli')], + ), + ], + ), + ]; + + List> _value = const [ + ['guangdong', 'shenzhen', 'nanshan'], + ]; + + @override + Widget build(BuildContext context) => TTreeSelect( + key: const ValueKey('tree-select-three-columns'), + options: _options, + value: _value, + onChanged: (value) => setState(() => _value = value), + ); +} diff --git a/tdesign-component/example/assets/code/tree-select._buildDisabled.txt b/tdesign-component/example/assets/code/tree-select._buildDisabled.txt deleted file mode 100644 index f7cdccbf3..000000000 --- a/tdesign-component/example/assets/code/tree-select._buildDisabled.txt +++ /dev/null @@ -1,3 +0,0 @@ -Widget _buildDisabled(BuildContext context) { - return TTreeSelect(options: _options, value: _singleValue); - } diff --git a/tdesign-component/example/assets/code/tree-select._buildMultiple.txt b/tdesign-component/example/assets/code/tree-select._buildMultiple.txt deleted file mode 100644 index b1b26daca..000000000 --- a/tdesign-component/example/assets/code/tree-select._buildMultiple.txt +++ /dev/null @@ -1,8 +0,0 @@ -Widget _buildMultiple(BuildContext context) { - return TTreeSelect( - options: _options, - value: _multipleValue, - multiple: true, - onChanged: (value) => setState(() => _multipleValue = value), - ); - } diff --git a/tdesign-component/example/assets/code/tree-select._buildPopup.txt b/tdesign-component/example/assets/code/tree-select._buildPopup.txt deleted file mode 100644 index 22d952eeb..000000000 --- a/tdesign-component/example/assets/code/tree-select._buildPopup.txt +++ /dev/null @@ -1,50 +0,0 @@ -Widget _buildPopup(BuildContext context) { - return TCell( - title: const TText('选择分类'), - trailing: TText( - '已选择 ${_popupValue.length} 项', - style: Theme.of(context).textTheme.bodyMedium, - ), - arrow: true, - onTap: () { - var draft = [for (final path in _popupValue) List.of(path)]; - TPopup.show( - context, - options: TPopupOptions.bottom( - height: MediaQuery.sizeOf(context).height * 0.6, - headerBuilder: (_, close) => TPopupHeader( - cancelButton: TextButton( - onPressed: close, - child: const TText('取消'), - ), - title: const TText('选择分类'), - confirmButton: TextButton( - onPressed: () { - if (mounted) { - setState(() { - _popupValue = [ - for (final path in draft) List.of(path), - ]; - }); - } - close(); - }, - child: const TText('确定'), - ), - ), - child: StatefulBuilder( - builder: (context, setPopupState) => TTreeSelect( - key: const Key('tree-select-popup'), - options: _options, - value: draft, - multiple: true, - onChanged: (value) { - setPopupState(() => draft = value); - }, - ), - ), - ), - ); - }, - ); - } diff --git a/tdesign-component/example/assets/code/tree-select._buildSingle.txt b/tdesign-component/example/assets/code/tree-select._buildSingle.txt deleted file mode 100644 index 13c8a0803..000000000 --- a/tdesign-component/example/assets/code/tree-select._buildSingle.txt +++ /dev/null @@ -1,8 +0,0 @@ -Widget _buildSingle(BuildContext context) { - return TTreeSelect( - key: const Key('tree-select-single'), - options: _options, - value: _singleValue, - onChanged: (value) => setState(() => _singleValue = value), - ); - } diff --git a/tdesign-component/example/lib/page/t_tree_select_page.dart b/tdesign-component/example/lib/page/t_tree_select_page.dart index 81148a71b..3e9325d76 100644 --- a/tdesign-component/example/lib/page/t_tree_select_page.dart +++ b/tdesign-component/example/lib/page/t_tree_select_page.dart @@ -1,187 +1,44 @@ import 'package:flutter/material.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; -import '../annotation/example_code.dart'; import '../base/example_widget.dart'; +import 'tree_select/tree_select_examples.dart'; -/// TTreeSelect 演示。 -class TTreeSelectPage extends StatefulWidget { +class TTreeSelectPage extends StatelessWidget { const TTreeSelectPage({super.key}); @override - State createState() => _TTreeSelectPageState(); -} - -class _TTreeSelectPageState extends State { - static const _options = [ - TTreeSelectOption( - label: '水果', - value: 'fruit', - children: [ - TTreeSelectOption(label: '苹果', value: 'apple'), - TTreeSelectOption(label: '香蕉', value: 'banana'), - TTreeSelectOption(label: '橙子', value: 'orange'), - TTreeSelectOption(label: '草莓', value: 'strawberry'), - TTreeSelectOption(label: '芒果', value: 'mango'), - TTreeSelectOption(label: '榴莲(暂不可选)', value: 'durian', disabled: true), - ], - ), - TTreeSelectOption( - label: '城市', - value: 'city', - children: [ - TTreeSelectOption( - label: '广东', - value: 'guangdong', - children: [ - TTreeSelectOption(label: '深圳', value: 'shenzhen'), - TTreeSelectOption(label: '广州', value: 'guangzhou'), - TTreeSelectOption( - label: '珠海(暂不可选)', - value: 'zhuhai', - disabled: true, - ), - TTreeSelectOption(label: '东莞', value: 'dongguan'), - ], - ), - TTreeSelectOption( - label: '浙江', - value: 'zhejiang', - children: [ - TTreeSelectOption(label: '杭州', value: 'hangzhou'), - TTreeSelectOption(label: '宁波', value: 'ningbo'), - TTreeSelectOption(label: '温州', value: 'wenzhou'), - ], - ), - ], - ), - TTreeSelectOption( - label: '动物', - value: 'animal', - children: [ - TTreeSelectOption(label: '猫', value: 'cat'), - TTreeSelectOption(label: '狗', value: 'dog'), - TTreeSelectOption(label: '兔子', value: 'rabbit'), - TTreeSelectOption(label: '鹦鹉', value: 'parrot'), - ], - ), - TTreeSelectOption( - label: '数码', - value: 'digital', - children: [ - TTreeSelectOption(label: '手机', value: 'phone'), - TTreeSelectOption(label: '电脑', value: 'computer'), - TTreeSelectOption(label: '平板', value: 'tablet'), - TTreeSelectOption(label: '相机', value: 'camera'), - ], - ), - ]; - - List> _singleValue = [ - ['fruit', 'apple'], - ]; - List> _multipleValue = []; - List> _popupValue = [ - ['fruit', 'banana'], - ]; - - @override - Widget build(BuildContext context) { - return ExamplePage( - title: tTitle(), - desc: '用于从层级数据中选择一个或多个叶子节点。', - exampleCodeGroup: 'tree-select', - children: [ - ExampleModule( - title: '弹出层用法', - children: [ExampleItem(desc: '底部弹出多选', builder: _buildPopup)], - ), - ExampleModule( - title: '基础能力', - children: [ - ExampleItem(desc: '单选(包含局部禁用项)', builder: _buildSingle), - ExampleItem(desc: '多选', builder: _buildMultiple), - ExampleItem(desc: '整体禁用', builder: _buildDisabled), - ], - ), - ], - ); - } - - @ExampleCode(group: 'tree-select') - Widget _buildSingle(BuildContext context) { - return TTreeSelect( - key: const Key('tree-select-single'), - options: _options, - value: _singleValue, - onChanged: (value) => setState(() => _singleValue = value), - ); - } - - @ExampleCode(group: 'tree-select') - Widget _buildMultiple(BuildContext context) { - return TTreeSelect( - options: _options, - value: _multipleValue, - multiple: true, - onChanged: (value) => setState(() => _multipleValue = value), - ); - } - - @ExampleCode(group: 'tree-select') - Widget _buildDisabled(BuildContext context) { - return TTreeSelect(options: _options, value: _singleValue); - } - - @ExampleCode(group: 'tree-select') - Widget _buildPopup(BuildContext context) { - return TCell( - title: const TText('选择分类'), - trailing: TText( - '已选择 ${_popupValue.length} 项', - style: Theme.of(context).textTheme.bodyMedium, + Widget build(BuildContext context) => ExamplePage( + title: tTitle(context), + desc: '用于多层级数据的逐级选择。', + exampleCodeGroup: 'tree-select', + compactDemo: true, + showTestModule: false, + children: [ + ExampleModule( + title: '组件类型', + children: [ + ExampleItem( + desc: '基础树形选择器', + methodName: 'TreeSelectSingleExample', + builder: (_) => const TreeSelectSingleExample(), + ), + ExampleItem( + desc: '多选树形选择器', + methodName: 'TreeSelectMultipleExample', + builder: (_) => const TreeSelectMultipleExample(), + ), + ], ), - arrow: true, - onTap: () { - var draft = [for (final path in _popupValue) List.of(path)]; - TPopup.show( - context, - options: TPopupOptions.bottom( - height: MediaQuery.sizeOf(context).height * 0.6, - headerBuilder: (_, close) => TPopupHeader( - cancelButton: TextButton( - onPressed: close, - child: const TText('取消'), - ), - title: const TText('选择分类'), - confirmButton: TextButton( - onPressed: () { - if (mounted) { - setState(() { - _popupValue = [ - for (final path in draft) List.of(path), - ]; - }); - } - close(); - }, - child: const TText('确定'), - ), - ), - child: StatefulBuilder( - builder: (context, setPopupState) => TTreeSelect( - key: const Key('tree-select-popup'), - options: _options, - value: draft, - multiple: true, - onChanged: (value) { - setPopupState(() => draft = value); - }, - ), - ), + ExampleModule( + title: '组件状态', + children: [ + ExampleItem( + desc: '树形选择器-三列', + methodName: 'TreeSelectThreeColumnsExample', + builder: (_) => const TreeSelectThreeColumnsExample(), ), - ); - }, - ); - } + ], + ), + ], + ); } diff --git a/tdesign-component/example/lib/page/tree_select/tree_select_examples.dart b/tdesign-component/example/lib/page/tree_select/tree_select_examples.dart new file mode 100644 index 000000000..c36f99fee --- /dev/null +++ b/tdesign-component/example/lib/page/tree_select/tree_select_examples.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import '../../annotation/example_code.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `TreeSelectSingleExample()`。 +@ExampleCode(group: 'tree-select') +class TreeSelectSingleExample extends StatefulWidget { + const TreeSelectSingleExample({super.key}); + + @override + State createState() => + _TreeSelectSingleExampleState(); +} + +class _TreeSelectSingleExampleState extends State { + static const _options = [ + TTreeSelectOption( + label: '甘肃省', + value: 'gansu', + children: [TTreeSelectOption(label: '兰州市', value: 'lanzhou')], + ), + TTreeSelectOption( + label: '广东省', + value: 'guangdong', + children: [ + TTreeSelectOption(label: '汕头市', value: 'shantou'), + TTreeSelectOption(label: '汕尾市', value: 'shanwei'), + TTreeSelectOption(label: '韶关市', value: 'shaoguan'), + TTreeSelectOption(label: '深圳市', value: 'shenzhen'), + TTreeSelectOption(label: '阳江市', value: 'yangjiang'), + TTreeSelectOption(label: '云浮市', value: 'yunfu'), + ], + ), + TTreeSelectOption( + label: '贵州省', + value: 'guizhou', + children: [TTreeSelectOption(label: '贵阳市', value: 'guiyang')], + ), + TTreeSelectOption( + label: '海南省', + value: 'hainan', + children: [TTreeSelectOption(label: '海口市', value: 'haikou')], + ), + TTreeSelectOption( + label: '河北省', + value: 'hebei', + children: [TTreeSelectOption(label: '石家庄市', value: 'shijiazhuang')], + ), + TTreeSelectOption( + label: '黑龙江省', + value: 'heilongjiang', + children: [TTreeSelectOption(label: '哈尔滨市', value: 'haerbin')], + ), + ]; + + List> _value = const [ + ['guangdong', 'shanwei'], + ]; + + @override + Widget build(BuildContext context) => TTreeSelect( + key: const ValueKey('tree-select-single'), + options: _options, + value: _value, + onChanged: (value) => setState(() => _value = value), + ); +} + +/// 在已配置 TDesign 主题的应用中使用 `TreeSelectMultipleExample()`。 +@ExampleCode(group: 'tree-select') +class TreeSelectMultipleExample extends StatefulWidget { + const TreeSelectMultipleExample({super.key}); + + @override + State createState() => + _TreeSelectMultipleExampleState(); +} + +class _TreeSelectMultipleExampleState extends State { + static const _options = [ + TTreeSelectOption( + label: '甘肃省', + value: 'gansu', + children: [TTreeSelectOption(label: '兰州市', value: 'lanzhou')], + ), + TTreeSelectOption( + label: '广东省', + value: 'guangdong', + children: [ + TTreeSelectOption(label: '汕头市', value: 'shantou'), + TTreeSelectOption(label: '汕尾市', value: 'shanwei'), + TTreeSelectOption(label: '韶关市', value: 'shaoguan'), + TTreeSelectOption(label: '深圳市', value: 'shenzhen'), + TTreeSelectOption(label: '阳江市', value: 'yangjiang'), + TTreeSelectOption(label: '云浮市', value: 'yunfu'), + ], + ), + TTreeSelectOption( + label: '贵州省', + value: 'guizhou', + children: [TTreeSelectOption(label: '贵阳市', value: 'guiyang')], + ), + TTreeSelectOption( + label: '海南省', + value: 'hainan', + children: [TTreeSelectOption(label: '海口市', value: 'haikou')], + ), + TTreeSelectOption( + label: '河北省', + value: 'hebei', + children: [TTreeSelectOption(label: '石家庄市', value: 'shijiazhuang')], + ), + TTreeSelectOption( + label: '黑龙江省', + value: 'heilongjiang', + children: [TTreeSelectOption(label: '哈尔滨市', value: 'haerbin')], + ), + ]; + + List> _value = const [ + ['guangdong', 'shanwei'], + ]; + + @override + Widget build(BuildContext context) => TTreeSelect( + key: const ValueKey('tree-select-multiple'), + options: _options, + value: _value, + multiple: true, + onChanged: (value) => setState(() => _value = value), + ); +} + +/// 在已配置 TDesign 主题的应用中使用 `TreeSelectThreeColumnsExample()`。 +@ExampleCode(group: 'tree-select') +class TreeSelectThreeColumnsExample extends StatefulWidget { + const TreeSelectThreeColumnsExample({super.key}); + + @override + State createState() => + _TreeSelectThreeColumnsExampleState(); +} + +class _TreeSelectThreeColumnsExampleState + extends State { + static const _options = [ + TTreeSelectOption( + label: '甘肃省', + value: 'gansu', + children: [ + TTreeSelectOption( + label: '兰州市', + value: 'lanzhou', + children: [TTreeSelectOption(label: '城关区', value: 'chengguan')], + ), + ], + ), + TTreeSelectOption( + label: '广东省', + value: 'guangdong', + children: [ + TTreeSelectOption( + label: '汕头市', + value: 'shantou', + children: [TTreeSelectOption(label: '金平区', value: 'jinping')], + ), + TTreeSelectOption( + label: '汕尾市', + value: 'shanwei', + children: [TTreeSelectOption(label: '城区', value: 'chengqu')], + ), + TTreeSelectOption( + label: '韶关市', + value: 'shaoguan', + children: [TTreeSelectOption(label: '浈江区', value: 'zhenjiang')], + ), + TTreeSelectOption( + label: '深圳市', + value: 'shenzhen', + children: [ + TTreeSelectOption(label: '龙华区', value: 'longhua'), + TTreeSelectOption(label: '罗湖区', value: 'luohu'), + TTreeSelectOption(label: '南山区', value: 'nanshan'), + TTreeSelectOption(label: '坪山区', value: 'pingshan'), + TTreeSelectOption(label: '其它区', value: 'other'), + TTreeSelectOption(label: '盐田区', value: 'yantian'), + ], + ), + TTreeSelectOption( + label: '阳江市', + value: 'yangjiang', + children: [TTreeSelectOption(label: '江城区', value: 'jiangcheng')], + ), + TTreeSelectOption( + label: '云浮市', + value: 'yunfu', + children: [TTreeSelectOption(label: '云城区', value: 'yuncheng')], + ), + ], + ), + TTreeSelectOption( + label: '贵州省', + value: 'guizhou', + children: [ + TTreeSelectOption( + label: '贵阳市', + value: 'guiyang', + children: [TTreeSelectOption(label: '南明区', value: 'nanming')], + ), + ], + ), + TTreeSelectOption( + label: '海南省', + value: 'hainan', + children: [ + TTreeSelectOption( + label: '海口市', + value: 'haikou', + children: [TTreeSelectOption(label: '秀英区', value: 'xiuying')], + ), + ], + ), + TTreeSelectOption( + label: '河北省', + value: 'hebei', + children: [ + TTreeSelectOption( + label: '石家庄市', + value: 'shijiazhuang', + children: [TTreeSelectOption(label: '长安区', value: 'changan')], + ), + ], + ), + TTreeSelectOption( + label: '黑龙江省', + value: 'heilongjiang', + children: [ + TTreeSelectOption( + label: '哈尔滨市', + value: 'haerbin', + children: [TTreeSelectOption(label: '道里区', value: 'daoli')], + ), + ], + ), + ]; + + List> _value = const [ + ['guangdong', 'shenzhen', 'nanshan'], + ]; + + @override + Widget build(BuildContext context) => TTreeSelect( + key: const ValueKey('tree-select-three-columns'), + options: _options, + value: _value, + onChanged: (value) => setState(() => _value = value), + ); +} diff --git a/tdesign-component/example/test/fonts/README.md b/tdesign-component/example/test/fonts/README.md index 47c116e4b..6151cbc90 100644 --- a/tdesign-component/example/test/fonts/README.md +++ b/tdesign-component/example/test/fonts/README.md @@ -16,6 +16,12 @@ NoticeBar 新增整页 Golden 的独立字体子集,字符清单见 新增文案的补充 fallback。它排在原字体之后,避免扩充原字体改变 Button、Checkbox、 Upload 等既有 Golden 的字形选择与像素基线。 +`TreeSelectGoldenCJK-Regular.otf` 仅补充 TreeSelect 整页 Golden 新增且既有子集 +未覆盖的字形,字符清单见 `tree_select_demo_glyphs.txt`,不会改变既有组件基线。 +它使用 Noto Sans SC 2.004 `NotoSansSC-Regular.otf`,来源与本文下方记录一致; +子集工具为 fonttools 4.59.1,子集 SHA-256 为 +`1cbb5418c4cd91a103deb2a28f68dacb1e895a6efcb8f2f5e8198d67d38ac7ca`。 + `StepperGoldenCJK-Regular.otf` 仅补充 Stepper 整页 Golden 的“步”字,字符清单见 `stepper_demo_glyphs.txt`,不会改变既有组件的字形选择。子集 SHA-256 为 `6df8e74aa398c55ff2140025fd79c31300653bc02ddf31d35ec94f9c5ae3d0d0`。 @@ -101,6 +107,7 @@ HarfBuzz 11.4.5,子集 SHA-256 为 Textarea、Upload、PullDownRefresh、Toast 或 SwipeCell Demo 页面文案后,更新原字符清单; 更新 Loading、Message、Popover 或 Popup 页面文案时,更新补充字符清单;更新 ActionSheet、Dialog、DropdownMenu 或 NoticeBar 页面文案时,更新 feedback 字符清单。 +更新 TreeSelect 页面文案时,更新 TreeSelect 字符清单。 更新 Stepper 页面文案时,更新 Stepper 字符清单。 更新 Slider 页面文案时,更新 Slider 字符清单。 更新 Picker 页面文案时,更新 Picker 字符清单。 diff --git a/tdesign-component/example/test/fonts/TreeSelectGoldenCJK-Regular.otf b/tdesign-component/example/test/fonts/TreeSelectGoldenCJK-Regular.otf new file mode 100644 index 000000000..2fb0cc2aa Binary files /dev/null and b/tdesign-component/example/test/fonts/TreeSelectGoldenCJK-Regular.otf differ diff --git a/tdesign-component/example/test/fonts/tree_select_demo_glyphs.txt b/tdesign-component/example/test/fonts/tree_select_demo_glyphs.txt new file mode 100644 index 000000000..b2eee83dc --- /dev/null +++ b/tdesign-component/example/test/fonts/tree_select_demo_glyphs.txt @@ -0,0 +1 @@ +TreeSelect 用于多层级数据的逐级选择组件类型基础树形选择器多选状态三列甘肃省广东省贵州省海南省河北省黑龙江省汕头市汕尾市韶关市深圳市阳江市云浮市龙华区罗湖区南山区坪山区其它区盐田区 diff --git a/tdesign-component/example/test/goldens/tree_select_page_dark.png b/tdesign-component/example/test/goldens/tree_select_page_dark.png new file mode 100644 index 000000000..15b900b97 Binary files /dev/null and b/tdesign-component/example/test/goldens/tree_select_page_dark.png differ diff --git a/tdesign-component/example/test/goldens/tree_select_page_light.png b/tdesign-component/example/test/goldens/tree_select_page_light.png new file mode 100644 index 000000000..9b6db90fc Binary files /dev/null and b/tdesign-component/example/test/goldens/tree_select_page_light.png differ diff --git a/tdesign-component/example/test/goldens/tree_select_popup_selected.png b/tdesign-component/example/test/goldens/tree_select_popup_selected.png deleted file mode 100644 index a90e752fa..000000000 Binary files a/tdesign-component/example/test/goldens/tree_select_popup_selected.png and /dev/null differ diff --git a/tdesign-component/example/test/tree_select_demo_golden_test.dart b/tdesign-component/example/test/tree_select_demo_golden_test.dart new file mode 100644 index 000000000..4521e7da2 --- /dev/null +++ b/tdesign-component/example/test/tree_select_demo_golden_test.dart @@ -0,0 +1,4 @@ +import 'demo_page_test_utils.dart'; +import 'tree_select_demo_test_spec.dart'; + +void main() => registerDemoGoldenTests(treeSelectDemoPageTestSpec); diff --git a/tdesign-component/example/test/tree_select_demo_test.dart b/tdesign-component/example/test/tree_select_demo_test.dart new file mode 100644 index 000000000..a01c7a884 --- /dev/null +++ b/tdesign-component/example/test/tree_select_demo_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; +import 'package:tdesign_flutter_example/base/example_base.dart'; +import 'package:tdesign_flutter_example/page/t_tree_select_page.dart'; +import 'package:tdesign_flutter_example/provider/theme_mode_provider.dart'; + +import 'demo_page_test_utils.dart'; +import 'tree_select_demo_test_spec.dart'; + +void main() { + registerDemoStructureTests(treeSelectDemoPageTestSpec); + + testWidgets('TreeSelect Demo follows official scenario order', ( + tester, + ) async { + await pumpFullDemoPage(tester, treeSelectDemoPageTestSpec, ThemeMode.light); + final keys = ['single', 'multiple', 'three-columns']; + final tops = keys + .map( + (id) => tester.getTopLeft(find.byKey(ValueKey('tree-select-$id'))).dy, + ) + .toList(); + expect(tops, orderedEquals([...tops]..sort())); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('single and multiple values use complete controlled paths', ( + tester, + ) async { + await pumpFullDemoPage(tester, treeSelectDemoPageTestSpec, ThemeMode.light); + final single = tester.widget( + find.byKey(const ValueKey('tree-select-single')), + ); + final multiple = tester.widget( + find.byKey(const ValueKey('tree-select-multiple')), + ); + expect(single.multiple, isFalse); + expect(single.value.single, ['guangdong', 'shanwei']); + expect(multiple.multiple, isTrue); + expect(multiple.value.single, ['guangdong', 'shanwei']); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('two-column examples match the design data and stay controlled', ( + tester, + ) async { + await pumpFullDemoPage(tester, treeSelectDemoPageTestSpec, ThemeMode.light); + final tree = find.byKey(const ValueKey('tree-select-single')); + for (final province in const ['甘肃省', '广东省', '贵州省', '海南省', '河北省', '黑龙江省']) { + expect( + find.descendant(of: tree, matching: find.text(province)), + findsOneWidget, + ); + } + for (final city in const ['汕头市', '汕尾市', '韶关市', '深圳市', '阳江市', '云浮市']) { + expect( + find.descendant(of: tree, matching: find.text(city)), + findsOneWidget, + ); + } + + await tester.tap(find.descendant(of: tree, matching: find.text('云浮市'))); + await tester.pump(); + expect(tester.widget(tree).value.single, [ + 'guangdong', + 'yunfu', + ]); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('multiple example toggles complete leaf paths', (tester) async { + await pumpFullDemoPage(tester, treeSelectDemoPageTestSpec, ThemeMode.light); + final tree = find.byKey(const ValueKey('tree-select-multiple')); + await tester.tap(find.descendant(of: tree, matching: find.text('深圳市'))); + await tester.pump(); + expect(tester.widget(tree).value, [ + ['guangdong', 'shanwei'], + ['guangdong', 'shenzhen'], + ]); + await tester.tap(find.descendant(of: tree, matching: find.text('汕尾市'))); + await tester.pump(); + expect(tester.widget(tree).value.single, [ + 'guangdong', + 'shenzhen', + ]); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('three-column example renders the complete depth', ( + tester, + ) async { + await pumpFullDemoPage(tester, treeSelectDemoPageTestSpec, ThemeMode.light); + final tree = find.byKey(const ValueKey('tree-select-three-columns')); + expect(tester.widget(tree).value.single, [ + 'guangdong', + 'shenzhen', + 'nanshan', + ]); + expect( + find.descendant(of: tree, matching: find.text('南山区')), + findsOneWidget, + ); + expect(tester.getSize(tree), const Size(375, 336)); + final horizontalScroll = find.descendant( + of: tree, + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && widget.axisDirection == AxisDirection.right, + ), + ); + expect( + tester.state(horizontalScroll).position.maxScrollExtent, + 0, + ); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('all real code panels show complete runnable examples', ( + tester, + ) async { + tester.view.physicalSize = const Size(375, 1800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final model = ExamplePageModel( + text: 'TreeSelect 树形选择器', + name: 'tree_select', + pageBuilder: (_, __) => const TTreeSelectPage(), + )..showAction = true; + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => ThemeModeProvider(), + child: MaterialApp( + theme: TThemeBuilder.light(TThemeData.defaultData()), + home: ExamplePageInheritedTheme( + model: model, + child: const TTreeSelectPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final single = find.byKey(const ValueKey('tree-select-single')); + await tester.tap(find.descendant(of: single, matching: find.text('云浮市'))); + await tester.pump(); + expect(tester.widget(single).value.single, [ + 'guangdong', + 'yunfu', + ]); + + const names = [ + 'TreeSelectSingleExample', + 'TreeSelectMultipleExample', + 'TreeSelectThreeColumnsExample', + ]; + final expectedSources = []; + for (final name in names) { + final source = await rootBundle.loadString( + 'assets/code/tree-select.$name.txt', + ); + expect(source, contains('class $name extends StatefulWidget')); + expect(source, contains('static const _options')); + expect(source, contains('List> _value')); + expect(source, contains('onChanged: (value) => setState')); + expectedSources.add(source); + } + + await tester.tap(find.byIcon(TIcons.code)); + await tester.pumpAndSettle(); + expect(find.text('code'), findsNWidgets(3)); + for (var i = 0; i < names.length; i++) { + await tester.tap(find.text('code').at(i)); + await tester.pumpAndSettle(); + final content = tester.widget(find.byType(Markdown)).data; + expect(content, contains(expectedSources[i])); + Navigator.of(tester.element(find.byType(Markdown))).pop(); + await tester.pumpAndSettle(); + } + expect(tester.widget(single).value.single, [ + 'guangdong', + 'yunfu', + ]); + expect(tester.takeException(), isNull); + await disposeDemoPage(tester); + }, tags: 'demo'); +} diff --git a/tdesign-component/example/test/tree_select_demo_test_spec.dart b/tdesign-component/example/test/tree_select_demo_test_spec.dart new file mode 100644 index 000000000..007e37ff6 --- /dev/null +++ b/tdesign-component/example/test/tree_select_demo_test_spec.dart @@ -0,0 +1,38 @@ +import 'package:tdesign_flutter_example/page/t_tree_select_page.dart'; + +import 'demo_page_test_utils.dart'; + +const treeSelectDemoPageTestSpec = DemoPageTestSpec( + name: 'tree_select', + title: 'TreeSelect 树形选择器', + page: TTreeSelectPage(), + expectedTexts: [ + '用于多层级数据的逐级选择。', + '01 组件类型', + '基础树形选择器', + '多选树形选择器', + '02 组件状态', + '树形选择器-三列', + '甘肃省', + '广东省', + '贵州省', + '海南省', + '河北省', + '黑龙江省', + '汕头市', + '汕尾市', + '韶关市', + '深圳市', + '阳江市', + '云浮市', + '龙华区', + '罗湖区', + '南山区', + '坪山区', + '其它区', + '盐田区', + ], + useAlignmentCjkFont: true, + supplementalCjkFontFamily: 'TDesign TreeSelect Golden CJK', + supplementalCjkFontPath: 'test/fonts/TreeSelectGoldenCJK-Regular.otf', +); diff --git a/tdesign-component/example/test/tree_select_page_test.dart b/tdesign-component/example/test/tree_select_page_test.dart deleted file mode 100644 index acfb8a27a..000000000 --- a/tdesign-component/example/test/tree_select_page_test.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; -import 'package:tdesign_flutter_example/page/t_tree_select_page.dart'; -import 'package:tdesign_flutter_example/provider/theme_mode_provider.dart'; - -void main() { - setUpAll(() async { - final iconFont = FontLoader('packages/tdesign_flutter_icons/TIcons') - ..addFont(rootBundle.load('packages/tdesign_flutter_icons/fonts/t.ttf')); - final flutterBin = - File(Platform.resolvedExecutable).parent.parent.parent.parent.parent; - final robotoFile = File( - '${flutterBin.path}/cache/artifacts/material_fonts/Roboto-Regular.ttf', - ); - final robotoFont = FontLoader('Roboto') - ..addFont(robotoFile.readAsBytes().then(ByteData.sublistView)); - await Future.wait([iconFont.load(), robotoFont.load()]); - }); - - Widget buildPage() { - return ChangeNotifierProvider( - create: (_) => ThemeModeProvider(), - child: MaterialApp( - theme: TThemeBuilder.light(TThemeData.defaultData()), - home: const TTreeSelectPage(), - ), - ); - } - - void configurePhone(WidgetTester tester) { - tester.view.physicalSize = const Size(375, 812); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - } - - Future openPopupAndSelectCity(WidgetTester tester) async { - await tester.pumpWidget(buildPage()); - await tester.pump(); - await tester.tap(find.widgetWithText(TCell, '选择分类')); - await tester.pumpAndSettle(); - - final popup = find.byKey(const Key('tree-select-popup')); - expect(popup, findsOneWidget); - expect(tester.getSize(popup).height, greaterThanOrEqualTo(336)); - - for (final label in const ['城市', '广东', '深圳']) { - await tester.tap( - find.descendant(of: popup, matching: find.text(label)), - ); - await tester.pump(); - } - return popup; - } - - testWidgets('弹窗跨分支选择后停留在当前页面并在确认后提交', (tester) async { - configurePhone(tester); - - final popup = await openPopupAndSelectCity(tester); - - expect( - find.descendant(of: popup, matching: find.text('深圳')), - findsOneWidget, - ); - expect(find.text('已选择 1 项'), findsOneWidget); - expect(tester.takeException(), isNull); - - await tester.tap(find.text('确定')); - await tester.pumpAndSettle(); - - expect(find.text('已选择 2 项'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('弹窗展示丰富数据且具体禁用项不可选择', (tester) async { - configurePhone(tester); - - await tester.pumpWidget(buildPage()); - await tester.pump(); - await tester.tap(find.widgetWithText(TCell, '选择分类')); - await tester.pumpAndSettle(); - - final popup = find.byKey(const Key('tree-select-popup')); - await tester.tap(find.descendant(of: popup, matching: find.text('城市'))); - await tester.pump(); - await tester.tap(find.descendant(of: popup, matching: find.text('广东'))); - await tester.pump(); - - final disabledLabel = find.descendant( - of: popup, - matching: find.text('珠海(暂不可选)'), - ); - expect(disabledLabel, findsOneWidget); - expect( - find.descendant(of: popup, matching: find.text('东莞')), - findsOneWidget, - ); - final disabledTile = find.ancestor( - of: disabledLabel, - matching: find.byType(Opacity), - ); - expect(tester.widget(disabledTile.first).opacity, 0.4); - - await tester.tap(disabledLabel, warnIfMissed: false); - await tester.pump(); - - expect( - find.descendant( - of: find.ancestor( - of: disabledLabel, - matching: find.byKey(const ValueKey((2, 'zhuhai'))), - ), - matching: find.byIcon(TIcons.check), - ), - findsNothing, - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('弹窗跨分支选中视觉快照', (tester) async { - configurePhone(tester); - - await openPopupAndSelectCity(tester); - - await expectLater( - find.byType(Overlay), - matchesGoldenFile('goldens/tree_select_popup_selected.png'), - ); - }); -} diff --git a/tdesign-component/lib/src/components/tree/t_tree_select.dart b/tdesign-component/lib/src/components/tree/t_tree_select.dart index 7cbbcb24c..b98fc678d 100644 --- a/tdesign-component/lib/src/components/tree/t_tree_select.dart +++ b/tdesign-component/lib/src/components/tree/t_tree_select.dart @@ -9,9 +9,9 @@ import '../text/t_text.dart'; import 't_tree_select_theme_data.dart'; const _kTreeSelectHeight = 336.0; -const _kRootColumnWidth = 106.0; +const _kRootColumnWidth = 103.0; const _kIntermediateColumnWidth = 103.0; -const _kLeafColumnMinWidth = 184.0; +const _kLeafColumnMinWidth = 169.0; const _kItemHeight = 56.0; const _kOutwardCornerRadius = 9.0; @@ -22,7 +22,7 @@ class TTreeSelectOption { /// 展示文案。 required this.label, - /// 业务值。 + /// 业务值;同一层级的选项必须保持唯一。 required this.value, /// 子选项。 @@ -35,7 +35,9 @@ class TTreeSelectOption { /// 展示文案。 final String label; - /// 业务值。 + /// 业务值;同一层级的选项必须保持唯一。 + /// + /// 值可为 null,但同一层级最多只能有一个 null 值。 final Object? value; /// 子选项。 @@ -56,13 +58,16 @@ class TTreeSelect extends StatefulWidget { /// 根选项。 required this.options, - /// 受控选中路径。 + /// 受控选中路径;每一项应为从根到叶子的完整 [TTreeSelectOption.value] 路径。 + /// 单选模式最多传入一条,多选模式可传入多条且不得重复。 required this.value, /// 选中路径变化回调;为 null 时禁用。 this.onChanged, /// 是否允许选择多个叶子节点。 + /// + /// 为 false 时,[value] 最多包含一条路径。 this.multiple = false, }); @@ -70,12 +75,18 @@ class TTreeSelect extends StatefulWidget { final List options; /// 受控选中路径。 + /// + /// 每一项应为从根到叶子的完整 [TTreeSelectOption.value] 路径。 + /// 暂时无法在 [options] 中解析到叶子的路径不会显示选中态。 + /// 组件会回退到首个可用分支。单选模式最多传入一条,多选模式可传入多条且不得重复。 final List> value; /// 选中路径变化回调;为 null 时禁用。 final ValueChanged>>? onChanged; /// 是否允许选择多个叶子节点。 + /// + /// 为 false 时,[value] 最多包含一条路径。 final bool multiple; @override @@ -191,6 +202,7 @@ class _TTreeSelectState extends State { @override Widget build(BuildContext context) { + assert(_debugCheckConfiguration()); final theme = Theme.of(context).extension(); final columns = _visibleColumns(); final panel = LayoutBuilder( @@ -232,6 +244,37 @@ class _TTreeSelectState extends State { ); } + bool _debugCheckConfiguration() { + assert( + widget.multiple || widget.value.length <= 1, + 'TTreeSelect.value must contain at most one path when multiple is false.', + ); + for (var index = 0; index < widget.value.length; index++) { + for (var previous = 0; previous < index; previous++) { + assert( + !listEquals(widget.value[previous], widget.value[index]), + 'TTreeSelect.value must not contain duplicate paths.', + ); + } + } + + void checkUniqueValues(List options) { + final values = []; + for (final option in options) { + assert( + !values.any((value) => value == option.value), + 'TTreeSelectOption.value must be unique among siblings. ' + 'Duplicate value: ${option.value}.', + ); + values.add(option.value); + checkUniqueValues(option.children); + } + } + + checkUniqueValues(widget.options); + return true; + } + List _resolveColumnWidths( int columnCount, double availableWidth, diff --git a/tdesign-component/lib/src/components/tree/t_tree_select_theme_data.dart b/tdesign-component/lib/src/components/tree/t_tree_select_theme_data.dart index 26cb248bb..467a23081 100644 --- a/tdesign-component/lib/src/components/tree/t_tree_select_theme_data.dart +++ b/tdesign-component/lib/src/components/tree/t_tree_select_theme_data.dart @@ -1,7 +1,9 @@ -import 'dart:ui' show lerpDouble; - import 'package:flutter/material.dart'; +const _kDefaultHeight = 336.0; +const _kDefaultRootColumnWidth = 103.0; +const _kDefaultItemHeight = 56.0; + /// TTreeSelect 组件级 ThemeExtension。 class TTreeSelectThemeData extends ThemeExtension { const TTreeSelectThemeData({ @@ -11,7 +13,7 @@ class TTreeSelectThemeData extends ThemeExtension { /// 根列宽度。 this.rootColumnWidth, - /// 子列宽度。 + /// 所有非根列的固定宽度;为 null 时由组件按可用宽度自动布局。 this.columnWidth, /// 单项最小高度。 @@ -45,7 +47,9 @@ class TTreeSelectThemeData extends ThemeExtension { /// 根列宽度。 final double? rootColumnWidth; - /// 子列宽度。 + /// 所有非根列的固定宽度;为 null 时由组件按可用宽度自动布局。 + /// + /// 设置后每个非根列均使用该宽度,面板总宽度超过可用宽度时可横向滚动。 final double? columnWidth; /// 单项最小高度。 @@ -111,24 +115,98 @@ class TTreeSelectThemeData extends ThemeExtension { return this; } return TTreeSelectThemeData( - height: lerpDouble(height, other.height, t), - rootColumnWidth: lerpDouble(rootColumnWidth, other.rootColumnWidth, t), - columnWidth: lerpDouble(columnWidth, other.columnWidth, t), - itemHeight: lerpDouble(itemHeight, other.itemHeight, t), - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t), - rootBackgroundColor: - Color.lerp(rootBackgroundColor, other.rootBackgroundColor, t), - selectedBackgroundColor: Color.lerp( + height: _lerpDoubleWithDefault(height, other.height, t, _kDefaultHeight), + rootColumnWidth: _lerpDoubleWithDefault( + rootColumnWidth, + other.rootColumnWidth, + t, + _kDefaultRootColumnWidth, + ), + columnWidth: _lerpNullable( + columnWidth, + other.columnWidth, + t, + _lerpDouble, + ), + itemHeight: _lerpDoubleWithDefault( + itemHeight, + other.itemHeight, + t, + _kDefaultItemHeight, + ), + backgroundColor: _lerpNullable( + backgroundColor, + other.backgroundColor, + t, + _lerpColor, + ), + rootBackgroundColor: _lerpNullable( + rootBackgroundColor, + other.rootBackgroundColor, + t, + _lerpColor, + ), + selectedBackgroundColor: _lerpNullable( selectedBackgroundColor, other.selectedBackgroundColor, t, + _lerpColor, + ), + textStyle: _lerpNullable(textStyle, other.textStyle, t, _lerpTextStyle), + selectedTextStyle: _lerpNullable( + selectedTextStyle, + other.selectedTextStyle, + t, + _lerpTextStyle, + ), + disabledTextStyle: _lerpNullable( + disabledTextStyle, + other.disabledTextStyle, + t, + _lerpTextStyle, + ), + indicatorColor: _lerpNullable( + indicatorColor, + other.indicatorColor, + t, + _lerpColor, ), - textStyle: TextStyle.lerp(textStyle, other.textStyle, t), - selectedTextStyle: - TextStyle.lerp(selectedTextStyle, other.selectedTextStyle, t), - disabledTextStyle: - TextStyle.lerp(disabledTextStyle, other.disabledTextStyle, t), - indicatorColor: Color.lerp(indicatorColor, other.indicatorColor, t), ); } } + +T? _lerpNullable( + T? begin, + T? end, + double t, + T Function(T begin, T end, double t) lerp, +) { + if (begin == null || end == null) { + return t < 0.5 ? begin : end; + } + return lerp(begin, end, t); +} + +double _lerpDouble(double begin, double end, double t) { + return begin * (1 - t) + end * t; +} + +double? _lerpDoubleWithDefault( + double? begin, + double? end, + double t, + double defaultValue, +) { + if (begin == null && end == null) { + return null; + } + return _lerpDouble(begin ?? defaultValue, end ?? defaultValue, t); +} + +Color _lerpColor(Color begin, Color end, double t) { + return Color.lerp(begin, end, t)!; +} + +TextStyle _lerpTextStyle(TextStyle begin, TextStyle end, double t) { + return TextStyle.lerp(begin, end, t)!; +} diff --git a/tdesign-component/test/components/tree/t_tree_select_test.dart b/tdesign-component/test/components/tree/t_tree_select_test.dart index 85ae74fc8..d86f441df 100644 --- a/tdesign-component/test/components/tree/t_tree_select_test.dart +++ b/tdesign-component/test/components/tree/t_tree_select_test.dart @@ -2,6 +2,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; +@immutable +class _EqualValueWithDifferentHashCode { + const _EqualValueWithDifferentHashCode(this.id, this.hashCode); + + final String id; + + @override + final int hashCode; + + @override + bool operator ==(Object other) { + return other is _EqualValueWithDifferentHashCode && other.id == id; + } +} + void main() { const options = [ TTreeSelectOption( @@ -207,7 +222,7 @@ void main() { expect(find.text('Apple'), findsOneWidget); }); - testWidgets('default visual style matches develop tree select layout', + testWidgets('default visual style matches the TreeSelect design layout', (tester) async { final token = TThemeData.defaultData(); await tester.pumpWidget(wrap(const Align( @@ -229,8 +244,8 @@ void main() { find.byWidgetPredicate( (widget) => widget is Container && - widget.constraints?.minWidth == 106 && - widget.constraints?.maxWidth == 106 && + widget.constraints?.minWidth == 103 && + widget.constraints?.maxWidth == 103 && widget.color == token.bgColorSecondaryContainer, ), findsOneWidget, @@ -239,8 +254,8 @@ void main() { find.byWidgetPredicate( (widget) => widget is Container && - widget.constraints?.minWidth == 269 && - widget.constraints?.maxWidth == 269 && + widget.constraints?.minWidth == 272 && + widget.constraints?.maxWidth == 272 && widget.color == token.bgColorContainer, ), findsOneWidget, @@ -291,15 +306,6 @@ void main() { ))); expect(find.text('Shenzhen'), findsOneWidget); - expect( - find.byWidgetPredicate( - (widget) => - widget is Container && - widget.constraints?.minWidth == 106 && - widget.constraints?.maxWidth == 106, - ), - findsOneWidget, - ); expect( find.byWidgetPredicate( (widget) => @@ -307,14 +313,14 @@ void main() { widget.constraints?.minWidth == 103 && widget.constraints?.maxWidth == 103, ), - findsNWidgets(2), + findsNWidgets(3), ); expect( find.byWidgetPredicate( (widget) => widget is Container && - widget.constraints?.minWidth == 184 && - widget.constraints?.maxWidth == 184, + widget.constraints?.minWidth == 169 && + widget.constraints?.maxWidth == 169, ), findsOneWidget, ); @@ -382,6 +388,115 @@ void main() { expect(changed, isFalse); }); + testWidgets('single mode rejects more than one controlled path in debug', + (tester) async { + await tester.pumpWidget(wrap(const TTreeSelect( + options: options, + value: [ + ['fruit', 'apple'], + ['fruit', 'banana'], + ], + onChanged: _ignore, + ))); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('at most one path')); + }); + + testWidgets('duplicate sibling option values are rejected in debug', + (tester) async { + await tester.pumpWidget(wrap(const TTreeSelect( + options: [ + TTreeSelectOption(label: 'First', value: 'duplicate'), + TTreeSelectOption(label: 'Second', value: 'duplicate'), + ], + value: [], + onChanged: _ignore, + ))); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('unique among siblings')); + }); + + testWidgets('sibling uniqueness uses the same equality as path matching', + (tester) async { + await tester.pumpWidget(wrap(const TTreeSelect( + options: [ + TTreeSelectOption( + label: 'First', + value: _EqualValueWithDifferentHashCode('duplicate', 1), + ), + TTreeSelectOption( + label: 'Second', + value: _EqualValueWithDifferentHashCode('duplicate', 2), + ), + ], + value: [], + onChanged: _ignore, + ))); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('unique among siblings')); + }); + + testWidgets('duplicate null sibling option values are rejected in debug', + (tester) async { + await tester.pumpWidget(wrap(const TTreeSelect( + options: [ + TTreeSelectOption(label: 'First', value: null), + TTreeSelectOption(label: 'Second', value: null), + ], + value: [], + onChanged: _ignore, + ))); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('unique among siblings')); + }); + + testWidgets('duplicate nested sibling values are rejected in debug', + (tester) async { + await tester.pumpWidget(wrap(const TTreeSelect( + options: [ + TTreeSelectOption( + label: 'Root', + value: 'root', + children: [ + TTreeSelectOption(label: 'First', value: 'duplicate'), + TTreeSelectOption(label: 'Second', value: 'duplicate'), + ], + ), + ], + value: [], + onChanged: _ignore, + ))); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('unique among siblings')); + }); + + testWidgets('duplicate controlled paths are rejected in debug', + (tester) async { + await tester.pumpWidget(wrap(const TTreeSelect( + options: options, + value: [ + ['fruit', 'apple'], + ['fruit', 'apple'], + ], + multiple: true, + onChanged: _ignore, + ))); + + final exception = tester.takeException(); + expect(exception, isA()); + expect(exception.toString(), contains('duplicate paths')); + }); + testWidgets('theme controls dimensions, colors, and text styles', (tester) async { const selectedStyle = TextStyle(color: Colors.red); @@ -473,6 +588,17 @@ void main() { expect(base.copyWith(height: 320).height, 320); expect(base.lerp(null, 0.5), same(base)); expect(base.lerp(other, 0.5).height, 350); + + const fallback = TTreeSelectThemeData(); + expect(fallback.lerp(fallback, 0.5).height, isNull); + expect(fallback.lerp(base, 0).height, 336); + expect(fallback.lerp(base, 0.5).height, 318); + expect(fallback.lerp(base, 1).height, 300); + expect(base.lerp(fallback, 0).height, 300); + expect(base.lerp(fallback, 0.5).height, 318); + expect(base.lerp(fallback, 1).height, 336); + expect(fallback.lerp(base, 0.49).backgroundColor, isNull); + expect(fallback.lerp(base, 0.5).backgroundColor, Colors.white); }); } diff --git a/tdesign-component/tool/component_test_manifest.dart b/tdesign-component/tool/component_test_manifest.dart index 03d1b02af..2cd4371d4 100644 --- a/tdesign-component/tool/component_test_manifest.dart +++ b/tdesign-component/tool/component_test_manifest.dart @@ -678,6 +678,19 @@ const componentTestManifests = [ ), ], ), + ComponentTestManifest( + name: 'tree_select', + coverageTargets: ['lib/src/components/tree/'], + componentTests: ['test/components/tree/t_tree_select_test.dart'], + exampleTests: ['test/tree_select_demo_test.dart'], + visualTests: [ + VisualTestManifest( + name: 'TreeSelect Demo', + workingDirectory: 'example', + testFiles: ['test/tree_select_demo_golden_test.dart'], + ), + ], + ), ComponentTestManifest( name: 'upload', coverageTargets: ['lib/src/components/upload/'], diff --git a/tdesign-component/tool/components.json b/tdesign-component/tool/components.json index 74901c285..b65f1f798 100644 --- a/tdesign-component/tool/components.json +++ b/tdesign-component/tool/components.json @@ -443,7 +443,7 @@ "api": { "names": [ "TTreeSelect", - "TSelectOption" + "TTreeSelectOption" ], "getComments": false }