@linhey/rhine-design 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +3 -1
  3. package/components/AdvancedForms.tsx +24 -10
  4. package/components/COMMON_COMPONENTS.md +1 -0
  5. package/components/Forms.tsx +14 -1
  6. package/components/ListFilterBar.tsx +5 -3
  7. package/components/Overlays.tsx +3 -2
  8. package/components/README.md +3 -3
  9. package/components/SPACING.md +6 -0
  10. package/components/TYPOGRAPHY.md +2 -0
  11. package/components/checkbox-row.css +12 -0
  12. package/components/common.css +1 -1
  13. package/components/compact-forms.css +20 -0
  14. package/components/components.tsx +7 -4
  15. package/components/extensions.css +10 -0
  16. package/components/index.ts +2 -1
  17. package/components/media-parity.css +4 -4
  18. package/components/patterns.css +3 -3
  19. package/components/styles.css +6 -2
  20. package/components/theme-picker.css +2 -2
  21. package/components/timeline.css +2 -1
  22. package/docs/APPLICATION_COMPONENTS.md +6 -2
  23. package/docs/CHECKBOX_ROWS.md +36 -0
  24. package/docs/COMPACT_ACCOUNT_PATTERNS.md +3 -1
  25. package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
  26. package/docs/COMPACT_LAYOUT_VERIFICATION.md +53 -0
  27. package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
  28. package/docs/DESIGN_SYSTEM.md +2 -0
  29. package/docs/INTERACTION_PATTERNS.md +6 -0
  30. package/docs/LIST_FILTER_BAR.md +8 -1
  31. package/docs/assets/compact-layouts/account.png +0 -0
  32. package/docs/assets/compact-layouts/date-arknights.png +0 -0
  33. package/docs/assets/compact-layouts/date-neo-brutalism.png +0 -0
  34. package/docs/assets/compact-layouts/date-rhine.png +0 -0
  35. package/docs/assets/compact-layouts/table.png +0 -0
  36. package/package.json +1 -1
  37. package/preview/CheckboxRowGallery.tsx +26 -0
  38. package/preview/CompactAccountGallery.tsx +18 -18
  39. package/preview/CompactFilterBarGallery.tsx +19 -0
  40. package/preview/ComponentTypographyGallery.tsx +23 -0
  41. package/preview/DateRangeFilterGallery.tsx +18 -1
  42. package/preview/DenseFilterControlsGallery.tsx +21 -0
  43. package/preview/GalleryShell.tsx +1 -1
  44. package/preview/SpacingGallery.tsx +15 -1
  45. package/preview/main.tsx +7 -0
  46. package/preview/preview.css +9 -2
  47. package/scripts/check-checkbox-rows.js +65 -0
  48. package/scripts/check-compact-layouts.js +136 -0
  49. package/scripts/check-component-typography.js +76 -0
  50. package/scripts/check-dense-filters.js +60 -0
  51. package/scripts/check-filter-native-date.js +1 -1
  52. package/skills/rhine-design/SKILL.md +3 -3
  53. package/skills/rhine-design/references/API_MAP.md +3 -1
  54. package/skills/rhine-design/references/integration.md +1 -1
  55. package/tests/CheckboxRow.test.tsx +53 -0
  56. package/tests/CompactAccountGallery.test.tsx +2 -1
  57. package/tests/CompactFilterBar.test.tsx +50 -0
  58. package/tests/DenseFilterControls.test.tsx +79 -0
  59. package/tests/DesignSystem.test.tsx +12 -1
@@ -14,7 +14,7 @@
14
14
 
15
15
  外层 `Surface` 已定义对象边界时,内部额度摘要不再包细线圆角框。进度、剩余额度、重置时间与预测状态通过 `Progress`、`Text`、`Inline` 和 `Stack` 组合,必要分区使用 `Divider`;不能移除未知或预测失败的文字。
16
16
 
17
- 事实按共享维度配对:优先级与密钥数量一行;账号到期与订阅到期分别呈现;今日与本周期读数配对。复用 `KeyValue`、`Inline`、`Stack`,不把每个字段装成胶囊或小卡片。压缩来自移除装饰、重复标签和无意义空白,不减小业务字号、不裁切数字、不固定高度。保留双成本的顺序和含义、未知 `—` 与真实 `0`,不相加或相互替换。长内容和 200% 缩放允许自然增高。
17
+ 事实按共享维度配对:基础信息把优先级、密钥数量与账号到期排列在同一行,窄卡片中到期独占一行;订阅到期独立呈现。今日和本周期占两个稳定等宽列,消费与生图按行对齐。复用 `Grid`、`Inline`、`Stack` 和 `Text`,不把每个字段装成胶囊或小卡片,也不混用不同的标签列宽。压缩来自移除装饰、重复标签和无意义空白,不减小业务字号、不裁切数字、不固定高度。保留双成本的顺序和含义、未知 `—` 与真实 `0`,不相加或相互替换。长内容和 200% 缩放允许自然增高。
18
18
 
19
19
  ## ButtonLink:具有按钮外观的原生链接
20
20
 
@@ -51,6 +51,8 @@
51
51
 
52
52
  `StatusTimelineList` 负责稳定行身份与详情互斥,公共契约见[应用组件](APPLICATION_COMPONENTS.md)。时间窗口、统计范围、unknown/partial/error 与实际业务判定仍由宿主保留;短标签不改变详情证据。
53
53
 
54
+ 列表末项不叠加行间距:最后一项底部为零,列表仅保留 8px 操作余量,容纳 28px 时间条外延的 44px 命中区。与 `Stack gap={1}` 的图例组合时,时间条到图例容器的间距为 12px,不能靠宿主负 margin 或缩小命中区压缩。多条列表中间仍保留分隔留白。此修正及事实列对齐由 [issue #23](https://github.com/linhay/rhine-design/issues/23) 跟踪,自 0.9.1 提供;使用前核对实际安装版本。
55
+
54
56
  时间轴与图例的浮层内部归一化定位包装层的祖先 CSS zoom,正文保留等效缩放与视口内滚动。此修复仅覆盖这两类详情,不宣称其他浮层已支持主题根 CSS zoom;浏览器原生网页缩放与 CSS zoom 是不同的证据。跨卡鼠标移走后延迟关闭,并允许移入详情继续阅读;键盘焦点和触屏操作不被鼠标离开逻辑打断。
55
57
 
56
58
  ## 验收边界
@@ -0,0 +1,38 @@
1
+ # 紧凑筛选控件
2
+
3
+ 本节 API 自 0.10.0 提供,0.9.1 不包含这些选项。真实组件组合为 `preview/CompactFilterBarGallery.tsx`。
4
+
5
+ 统一展台入口 `#compact-filters`,同时运行 `preview/DenseFilterControlsGallery.tsx`:100 个候选、90 个已选项及一体日期区间。`RemoteMultiSelect maxVisibleTags={2}` 默认只显示两个标签与总数,展开后使用有界滚动,不将大量已选项常驻到筛选抽屉。`DateRangePicker compact` 将两端原生日期放在同一边框内,保留无障碍标签、原生日期键盘与错误关联;极窄容器允许内部换行,不能裁切日期。
6
+
7
+ - `SegmentedControl fullWidth` 占满可用行宽,选项等宽;长标签在所属选项中换行。选中背景和可见文本区域共同撑满 label,文字水平/垂直居中,不把扩大点击区域误认为扩大选中背景。
8
+ - 默认 `fullWidth=false` 保持既有按内容布局。原生 radio 的 checked、禁用、Tab 和方向键行为不变;独立实例继续使用独立 name。
9
+ - 有限标签在极窄容器内允许换行,保留完整的移除按钮;展开只影响呈现,不裁减表单值。清空或宿主将选择数降至限制以内后,再次批量选择恢复折叠。
10
+ - `ListFilterBar drawerDescription={null} drawerResult={null}` 删除可省略说明与重复统计。没有内容时不留下空段落/反馈容器;关闭后仍显示栏内完整摘要。
11
+ - 正在更新和错误不是冗余说明,抽屉打开时继续提供反馈。减少文字不能掩盖请求失败或把旧结果解释为当前条件成功。
12
+ - `Dialog` / `Drawer` 的 description 接收 ReactNode,`null` 不渲染描述,同时移除 aria-describedby;标题和关闭入口始终保留。
13
+
14
+ 验收三主题、400/520/880px、200% 内容缩放及长标签;检查每个选项的几何宽度和文字中心、整个选中底色的宽度、键盘原生单选、抽屉关闭归焦、无描述时的可访问名称。JSDOM 验证语义与回调,真实浏览器验证几何。
15
+
16
+ ## 回归记录(2026-10-25)
17
+
18
+ Node 24:254 项测试、类型检查和生产构建通过。生产预览下三主题 × 400/520/880px × 100%/200% CSS zoom 的 18 组检查通过,覆盖有限标签与完整移除入口、有界展开、清空后重新折叠、原生日期最后一段编辑、等宽单选键盘操作、可省略摘要及保留加载/错误、关闭归焦。400px / 200% 的三主题日期截图人工核对完整年月日与日历入口;不外推为原生菜单缩放或所有浏览器验收。
19
+
20
+ ```sh
21
+ npm run check
22
+ ```
23
+
24
+ 在一个终端启动预览,并在浏览器检查期间保持运行:
25
+
26
+ ```sh
27
+ npx vite preview --host 127.0.0.1 --port 18527 --strictPort
28
+ ```
29
+
30
+ 预览就绪后,在另一个终端执行:
31
+
32
+ ```sh
33
+ playwright-cli -s=dense-filters open 'http://127.0.0.1:18527/#compact-filters'
34
+ mkdir -p output/playwright/issue-27
35
+ playwright-cli -s=dense-filters run-code --filename=scripts/check-dense-filters.js
36
+ ```
37
+
38
+ 每个矩阵场景使用独立导航参数重建示例状态,避免前次未关闭浮层导致下轮读到隐藏的底层内容。全程只使用手写合成数据。
@@ -0,0 +1,53 @@
1
+ # 紧凑布局回归:#23 / #24 / #25
2
+
3
+ 2026-10-25,在 `npm run check` 生成的固定生产预览中验证。内容均为合成数据,这是 PR #26 的修复验收记录,修复归入 0.9.1;发布状态以 registry 与 GitHub Release 为准。本次没有重跑 Gateway 消费端或生产链路。
4
+
5
+ ## 检查范围
6
+
7
+ | 项目 | 范围与交互 |
8
+ | --- | --- |
9
+ | 自动检查 | 31 个测试文件、246 项测试;TypeScript;Vite 构建及 CSS/SVG 产物检查 |
10
+ | 筛选 Drawer | 三主题 × 400/520/880px × 100%/200% CSS zoom,共 18 组;原生日期末段增减、清空、非法片段、整体重置、Escape 归焦 |
11
+ | 精确日期容器 | 三主题 × 实际 150/200/280 CSS px,共 9 组;200% CSS zoom;范围与 Inline 内独立 DateField 分别编辑、清空、重置;断言实际容器宽度 |
12
+ | 无外围内距表格 | 三主题 × 三视口 × 两种 CSS zoom,共 18 组;外围 padding 为 0、单元格内距保留、鼠标勾选和 Space 取消、局部滚动至末列 |
13
+ | 紧凑账号 | 三主题 × 三视口 × 两种内容 CSS zoom × 正常/零值/未知/长值,共 72 组;事实列对齐、局部溢出、12px 尾距、44px 命中区,以及键盘/鼠标详情和图例 |
14
+ | 原生日期既有契约 | 8 项真实键盘回归:未完成片段不覆盖已接受范围、修复年份、空值片段、清空、同值预设及三个筛选示例的整体重置 |
15
+ | 最终重置焦点复核 | 三主题 × 三种实际容器宽度,共 9 项;清除独立日期后输入非法片段,重置恢复日期、移除错误并保留按钮焦点 |
16
+
17
+ `DateField` 的宿主拒绝合法提议、禁用/只读与外部值同步继续由 `tests/DateNativeInput.test.tsx` 覆盖。本次尺寸修复不改变日期字符串、时区或查询归属。
18
+
19
+ 精确宽度示例使用 1280px 外层视口容纳展台侧栏;400/520/880px 窄屏另由真实 Drawer 矩阵覆盖。在 880px 外层、200% CSS zoom 下,标称 200/280px 的示例都会被 `max-width:100%` 夹到 192px,因此不能把选项值当成实际宽度证据。
20
+
21
+ ## 截图
22
+
23
+ 以下为 150 CSS px 容器内独立日期的三主题截图。完整年月日、日历入口与正常字号共同核对;实际末段编辑由浏览器脚本验证,截图或 `scrollWidth` 单独均不足以验收。
24
+
25
+ ![莱茵日期](assets/compact-layouts/date-rhine.png)
26
+ ![战术日期](assets/compact-layouts/date-arknights.png)
27
+ ![橙趣日期](assets/compact-layouts/date-neo-brutalism.png)
28
+
29
+ 表格及长值账号的真实组件展台:
30
+
31
+ ![纯表格](assets/compact-layouts/table.png)
32
+ ![紧凑账号](assets/compact-layouts/account.png)
33
+
34
+ ## 重跑
35
+
36
+ ```sh
37
+ npm run check
38
+ npx vite preview --host 127.0.0.1 --port 18525 --strictPort
39
+ ```
40
+
41
+ 另一终端使用已安装的 `playwright-cli`,打开同一预览后执行:
42
+
43
+ ```sh
44
+ mkdir -p output/playwright/issues-23-25
45
+ playwright-cli -s=rhine-layout open 'http://127.0.0.1:18525/#list-filter-bar' --headed
46
+ playwright-cli -s=rhine-layout run-code --filename scripts/check-compact-layouts.js
47
+ playwright-cli -s=rhine-layout run-code --filename scripts/check-filter-native-date.js
48
+ playwright-cli -s=rhine-layout close
49
+ ```
50
+
51
+ 确认返回结果分别为 `pass: true` 和 `complete: true`,再逐一检查日期截图。脚本以当前页 origin 为预览地址,不依赖固定端口。
52
+
53
+ 证据限于本次 Chromium 与 CSS zoom;不等同于浏览器菜单缩放、其他浏览器或任意更小容器验收。构建通过,仍有现有的超过 500kB chunk 提示。
@@ -0,0 +1,44 @@
1
+ # 公共组件文字配对复核(#31)
2
+
3
+ 2026-10-26。本轮修复自 npm 0.10.0 提供,0.9.1 不包含。范围为 `components/*.css` 中的字号声明及对应真实组件,重点检查组件从正文切换到 caption/heading 后是否仍依赖父级行高或浏览器默认字重;不全局重写 strong、p 或所有字体。
4
+
5
+ | 组件位置 | 本轮处理 |
6
+ | --- | --- |
7
+ | MediaViewer header 说明、操作标签 | caption 12/18/400,长说明自然换行 |
8
+ | EmptyState 标题 | heading 16/24,显式块级行框与自然换行 |
9
+ | MediaViewer header 标题 | section 18/26,显式主题标题字重 |
10
+ | MediaCard 加载/空/错误文案 | body 14/22/400,符号标记继续独立 |
11
+ | CopyButton 反馈、Steps 说明、Fieldset 提示 | caption 12/18/400 |
12
+ | ErrorSummary 标题 | heading 16/24 与主题标题字重 |
13
+ | ThemePicker 状态、ThemePreview 加载/失败文案 | caption 12/18/400,保留最小区域高度 |
14
+
15
+ 标题字重使用 `--rhine-weight-heading`:莱茵与战术 600,橙趣主题已有覆盖为 750。这里保留主题语义,不强制全部主题变成 600。辅助说明使用 `--rhine-weight-regular`,避免宿主强调字重泄入。
16
+
17
+ ## 保留与排除
18
+
19
+ - Text、Checkbox row、表格 caption、媒体卡片 caption、导航、筛选反馈等已有字号/行高配对,保持不变。
20
+ - Badge、Notice 说明、图表辅助说明的既有 12/20、表格普通文本的既有密度,以及主题表格/表单标签覆盖不机械统一。正文同层的分段控件与 Notice 标题没有切换到 caption/heading,不在本次跨层级修复中重设字重。
21
+ - Metric 单位属于指标与辅助字号的行内混排;整行由指标控制,不把整体压成 caption 高度。状态符号、编号、Logo 等图形/品牌表达不当作正文。
22
+ - 消费应用自己的标题与辅助文字组合不在设计库修复范围;本轮组件修复不迁移业务页面;npm 发布单独执行版本流程。
23
+
24
+ ## 复现与回归
25
+
26
+ 真实展台 `#component-typography` 使用纯合成内容,覆盖上述规则。复制动作使用异步空实现,不访问剪贴板;主题预览使用故意损坏的内嵌图片验证失败文案,不请求外部资源。
27
+
28
+ 先运行 `npm run check`,然后在一个终端保持预览运行:
29
+
30
+ ```sh
31
+ npx vite preview --host 127.0.0.1 --port 18531 --strictPort
32
+ ```
33
+
34
+ 另一终端执行:
35
+
36
+ ```sh
37
+ playwright-cli -s=component-type open 'http://127.0.0.1:18531/#component-typography'
38
+ mkdir -p output/playwright/issue-31
39
+ playwright-cli -s=component-type run-code --filename=scripts/check-component-typography.js
40
+ ```
41
+
42
+ 回归直接断言计算字号、行高与字重,并改变宿主行高/字重确认局部配对;检查三主题、400/520/880px、100%/200% CSS zoom、长文、44px 操作区域、关闭与焦点归还。400px、200% CSS zoom 下额外测量 EmptyState 标题与 MediaViewer 说明的文字 Range,确认实际多行且文字边界无水平或垂直裁切,不仅检查外围容器。不将 CSS zoom 外推为原生菜单缩放或所有浏览器覆盖。
43
+
44
+ 本轮结果:259 项组件测试、类型检查与生产构建通过;上述 18 组 Chromium 场景通过,无页面异常。另人工复核三主题在 400px、200% CSS zoom 下的空状态与媒体头部截图,长文和操作仍完整可读。
@@ -82,6 +82,8 @@
82
82
  - 400/520/880px作为展台验收宽度;消费项目按其最小容器尺寸追加验证。
83
83
  - 指标与表格按容器重排;数字和说明可换行,必要时表格局部滚动,不以超小字号塞入全部列。
84
84
  - 100% 与 200% 字体缩放下允许纵向增长、重排;不固定卡片高度、不裁掉报错和主要操作。
85
+ - Surface 只承载表格时使用 `padding="none"` 取消外围内距;表头、单元格与分页仍保留自身阅读间距,不连带压缩所有卡片。该 prop 自 0.9.1 提供,见 [#24](https://github.com/linhay/rhine-design/issues/24),默认行为不变,示例见 `#spacing` 与[间距规范](../components/SPACING.md#只承载表格的-surface)。
86
+ - 窄屏按真实局部容器验收,不能只检查 document 无横向溢出。抽屉、分栏、卡片中的输入应在 200% 内容缩放后仍看清并可编辑完整值;原生 date 的日期片段和日历图标、搜索文字与清空按钮均须有实际可用宽度。先移除重复分组内距或改为单列,不缩小字体、隐藏关键图标或用 overflow 裁掉内容来通过检查。
85
87
 
86
88
 
87
89
  ## 4. 组件与内容契约
@@ -31,6 +31,12 @@
31
31
  | 某时段的时间与状态 | 悬停、键盘聚焦时显示 | 同时提供可访问名称;触屏也能聚焦/操作 |
32
32
  | 失败、未知、数据不完整、关键陈旧状态 | 相关区域常显 | 不用减少文字来掩盖数据质量问题 |
33
33
 
34
+ ## 局部宽度与输入可用性
35
+
36
+ 400px 视口并不代表控件有400px可用:需扣除宿主侧栏、卡片、Drawer 与字段分组的逐层内距。在 200% CSS zoom 下分别测控件边框、实际文本编辑区与邻接按钮,检查完整值、placeholder、标签、错误和焦点;原生日期必须实际聚焦并编辑最后一段,核对年月日与日历入口均未被挤掉。
37
+
38
+ 整页 `scrollWidth <= clientWidth` 只是外层检查,不能证明内部没有裁切。验收同时保留局部截图与关键输入交互;控件的 scrollWidth 也不能直接反映浏览器原生 date 的内部编辑区。优先容器查询与单列重排、去掉重复内距,保留正常字号及操作目标,不通过全局 overflow:hidden、字体缩小或隐藏日历入口掩盖问题。
39
+
34
40
  ## 历史健康记录
35
41
 
36
42
  `StatusTimeline({label,segments,startLabel,endLabel})` 接收按时间排序的 `{id,label,status}`;status仅限 healthy/recovered/degraded/failed/unknown。宿主负责证据分类、时区、缺失时段补unknown和陈旧说明;组件不计算评分、不查询后端。`TimelineLegend()`提供五种状态的文字和色块,恢复态另加纹理。两者从公共barrel导入并使用同一主题。
@@ -7,10 +7,11 @@
7
7
  ## 先决定常显内容
8
8
 
9
9
  - 搜索在用户确实需要查找记录时提供;统计范围等场景无需填充一个无用搜索框。
10
+ - 搜索 label 持续可见,不以 placeholder 代替。#22 的 `f0f8495` 已移除早期候选隐藏搜索 label 的 CSS;消费旧安装副本时须核对,不能以源码文档描述代替实际安装行为。
10
11
  - `primary` 只常显前两项,超出的项目自动进入“更多筛选”。选择高频且互相独立的条件,例如状态和排序;不要为了展开所有控件增加第二个筛选栏。
11
12
  - 其他条件进入同一个 Drawer。字段自己的 label 持续可见,组件不重复加可见标题,也不提供任意 `children` 让页面插回一排控件。
12
13
  - 非默认条件用短摘要说明当前限制;单项清除与整体重置都由宿主定义。默认排序、默认统计日期等是否需要摘要由宿主的 `activeLabel` 决定,不把所有非空值都视为筛选。
13
- - 结果数量、更新中与错误反馈在栏内呈现;Drawer 打开时同样显示反馈。失败不能藏在遮罩后的页面上,也不能把旧数量当作新条件已经成功。
14
+ - 结果数量、更新中与错误反馈在栏内呈现;Drawer 默认重复结果摘要,信息较多时用 `drawerResult={null}` 移除重复统计。更新中与错误仍必须在 Drawer 内反馈,失败不能藏在遮罩后的页面上,也不能把旧数量当作新条件已经成功。
14
15
  - 刷新之外最多一个业务操作。多记录的选择、批量操作属于结果区,录制启停等对象设置也不塞进筛选栏。
15
16
  - 平级工作区已有侧栏身份时不重复大标题。组件的 `label` 提供可访问分组名称,宿主仍负责页面标题、主内容名称及路由焦点。
16
17
 
@@ -28,6 +29,8 @@
28
29
  | `refresh` | `{onClick,busy?,disabled?}`;原生共享 Button,不自动执行请求 |
29
30
  | `action` | 可选单个 ReactElement,代表一个业务操作;消费方不能用 Fragment 或自建按钮组绕过数量限制 |
30
31
  | `result` | 宿主提供的结果摘要;数量未知时明确显示未知,不用已加载条数冒充总数 |
32
+ | `drawerDescription` | 可选 ReactNode;省略时保留默认即时更新说明,传 `null` 不渲染说明,也不留下空段落或失效的 aria-describedby |
33
+ | `drawerResult` | 可选 ReactNode;省略时沿用 `result`,传 `null` 仅移除抽屉重复摘要;pending/error 始终保留 |
31
34
  | `pending` / `error` | 正在更新 / 失败说明;在栏与已展开 Drawer 中保持一致 |
32
35
  | `className` | 只用于外层组合布局,不覆盖组件内部视觉或状态 |
33
36
 
@@ -58,6 +61,8 @@
58
61
 
59
62
  示例中的状态、函数与额外字段由宿主提供。使用受控 `search.value` 才能准确决定搜索是否需要重置;组件不解析其他控件的值。
60
63
 
64
+ `drawerDescription` / `drawerResult` 自 0.10.0 提供,0.9.1 不支持。紧凑示例见 `preview/CompactFilterBarGallery.tsx`。无必要解释时,标题后直接展示筛选字段,不以另一段帮助文字替代被移除的重复说明。
65
+
61
66
  ## 状态与查询边界
62
67
 
63
68
  更多筛选是同一组状态的展开视图,不建立另一套草稿/应用流程。普通选择即时回传宿主;关闭、点击外部与 Escape 只收起抽屉,不撤销已经接受的条件。业务本来要求完整范围显式应用的日期控件可以保留其局部草稿,但应在 Drawer 内内联展示,不再嵌套日期 Drawer。
@@ -71,3 +76,5 @@ Drawer 复用已有模态、Escape 与焦点归还;移除当前聚焦摘要时
71
76
  `preview/ListFilterBarGallery.tsx` 提供独立会话列表、420px 嵌入列表、无搜索统计范围三种组合。独立例子刻意传入第三项 primary,以验证运行时移入 Drawer;全局状态选择使用真实控件演示正常、加载、无结果、错误与禁用。记录与操作均为合成示例,无业务请求或文件下载。
72
77
 
73
78
  验收至少覆盖:三主题与真实窄容器;400/520/880px、200% CSS zoom;主条件上限;输入法、键盘和焦点;多个活跃条件的完整摘要、单项/全量重置;Drawer 内加载与失败;筛选后总数、分页与迟到响应。图库证明共享组件组合,产品的服务端权限、导出和查询契约仍分别验收。
79
+
80
+ Drawer 中的原生日期还须检查实际文本编辑区:完整值、最后日期片段与日历图标均可见并可操作;input/document 的 scrollWidth 不溢出不代表原生编辑区没有裁字。重复分组内距的共享修复由 [#25](https://github.com/linhay/rhine-design/issues/25) 跟踪;不在业务页复制日期内部 CSS。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linhey/rhine-design",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,26 @@
1
+ import { useState } from 'react';
2
+ import { Checkbox, Stack, Text } from '../components';
3
+
4
+ const choices = [
5
+ { id: 'a', label: '示例项目 A', hint: '内存卡 · 14 项' },
6
+ { id: 'b', label: '跨团队拍摄与素材整理的较长示例项目名称', hint: '移动设备 · 128 项,包含待整理素材' },
7
+ { id: 'c', label: 'Archive_With_A_Very_Long_Unbroken_Synthetic_Project_Name' },
8
+ { id: 'd', label: '其他来源' },
9
+ ];
10
+ export function CheckboxRowGallery() {
11
+ const [selected, setSelected] = useState(['a']);
12
+ const [width, setWidth] = useState('240');
13
+ return <Stack gap={4}>
14
+ <Text as="h2" variant="heading">侧栏双行多选</Text>
15
+ <Text tone="muted">纯合成项目。整行、说明和留白均可选择;长名称自然换行。</Text>
16
+ <label>侧栏宽度 <select value={width} onChange={event => setWidth(event.target.value)}>{['160', '240', '320'].map(value => <option key={value} value={value}>{value}px</option>)}</select></label>
17
+ <div data-checkbox-rows style={{ width: Number(width), maxWidth: '100%' }}><Stack gap={2}>
18
+ <Checkbox layout="row" label="全部项目" hint="选择四个示例项目" checked={selected.length === choices.length} indeterminate={selected.length > 0 && selected.length < choices.length} onChange={event => setSelected(event.target.checked ? choices.map(item => item.id) : [])} />
19
+ {choices.map(item => <Checkbox key={item.id} layout="row" label={item.label} hint={item.hint} checked={selected.includes(item.id)} onChange={event => setSelected(current => event.target.checked ? [...current, item.id] : current.filter(id => id !== item.id))} />)}
20
+ <Checkbox layout="row" label="不可用项目" hint="当前无法读取" disabled />
21
+ <Checkbox layout="row" label="已选但不可用" hint="等待重新连接" disabled checked />
22
+ </Stack></div>
23
+ <Text variant="caption">已选 {selected.length} 项</Text>
24
+ <Checkbox label="默认表单布局" hint="保留旧表单说明的位置和命中范围。" />
25
+ </Stack>;
26
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import {
3
- Badge, Button, ButtonLink, Checkbox, Disclosure, Divider, Inline, KeyValue,
3
+ Badge, Button, ButtonLink, Checkbox, Disclosure, Divider, Grid, Inline,
4
4
  Notice, Progress, Select, Skeleton, Stack, StatusTimelineList, Surface, Text,
5
5
  TextField, TimelineLegend, Tooltip, type TimelineSegment, type TimelineStatus,
6
6
  } from '../components';
@@ -45,29 +45,29 @@ export function CompactAccountExample({ state = 'normal' }: { state?: AccountSta
45
45
  </Stack>
46
46
  <Stack gap={1}>
47
47
  <StatusTimelineList label="资源用量与异常记录" items={[{
48
- id: 'resource-a', title: '用量与异常记录',
49
- note: unknown ? '历史记录不完整,仅展示已确认的时段。' : undefined,
48
+ id: 'resource-a', title: '用量与异常记录', summary: '24小时窗口 · 15分钟',
49
+ note: `10/23 00:00 – 10/24 00:00 · 上海${unknown ? ' · 历史记录不完整,仅展示已确认的时段。' : ''}`,
50
50
  timeline: { startLabel: '10/23 00:00 · 上海', endLabel: '10/24 00:00 · 上海', segments: unknown ? segments.slice(0, 48) : segments },
51
51
  }]} />
52
52
  <TimelineLegend compact label="历史状态图例" items={compactAccountLegend} />
53
53
  </Stack>
54
- <Stack gap={2} data-testid="compact-account-facts">
55
- <Inline gap={4}>
54
+ <Stack gap={3} className="compact-gallery-facts" data-testid="compact-account-facts">
55
+ <Grid gap={2} className="compact-gallery-facts-meta">
56
56
  <Tooltip content="数值越小,调度优先级越高;这不是健康评分。"><Text as="span" variant="caption" tabIndex={0}>优先级 <strong>0</strong></Text></Tooltip>
57
57
  <Text as="span" variant="caption">API-Key <strong>{unknown ? '—' : zero ? '0' : '3'}</strong></Text>
58
- </Inline>
59
- <KeyValue items={[
60
- { key: 'expires', label: '到期', value: long ? '2027-10-24 23:59 · 上海(运营设置的账号期限)' : '未设置' },
61
- ...(long ? [{ key: 'subscription-expires', label: '订阅到期', value: '2026-11-24 08:30 · 上海(当前服务套餐期限)' }] : []),
62
- ]} />
63
- <Inline gap={4} className="compact-gallery-fact-pair">
64
- <Text as="span" variant="caption">今日消费 <strong>{unknown ? '—' : zero ? '$0.00' : long ? '$12,345.67 / $24,691.34' : '$2.40'}</strong></Text>
65
- <Text as="span" variant="caption">本周期 <strong>{unknown ? '—' : zero ? '$0.00' : long ? '$123,456.78 / $246,913.56' : '$18.50'}</strong></Text>
66
- </Inline>
67
- <Inline gap={4} className="compact-gallery-fact-pair">
68
- <Text as="span" variant="caption">今日生图 <strong>{unknown ? '—' : zero ? '0' : '8'}</strong></Text>
69
- <Text as="span" variant="caption">本周期生图 <strong>{unknown ? '—' : zero ? '0' : '24'}</strong></Text>
70
- </Inline>
58
+ <Text as="span" variant="caption" className="compact-gallery-facts-expiry">到期 <strong>{long ? '2027-10-24 23:59 · 上海(运营设置的账号期限)' : '未设置'}</strong></Text>
59
+ {long && <Text as="span" variant="caption" className="compact-gallery-facts-subscription">订阅到期 <strong>2026-11-24 08:30 · 上海(当前服务套餐期限)</strong></Text>}
60
+ </Grid>
61
+ <Stack gap={2}>
62
+ <Grid gap={2} className="compact-gallery-fact-pair">
63
+ <Text as="span" variant="caption">今日消费 <strong>{unknown ? '—' : zero ? '$0.00' : long ? '$12,345.67 / $24,691.34' : '$2.40'}</strong></Text>
64
+ <Text as="span" variant="caption">本周期 <strong>{unknown ? '—' : zero ? '$0.00' : long ? '$123,456.78 / $246,913.56' : '$18.50'}</strong></Text>
65
+ </Grid>
66
+ <Grid gap={2} className="compact-gallery-fact-pair">
67
+ <Text as="span" variant="caption">今日生图 <strong>{unknown ? '—' : zero ? '0' : '8'}</strong></Text>
68
+ <Text as="span" variant="caption">本周期生图 <strong>{unknown ? '—' : zero ? '0' : '24'}</strong></Text>
69
+ </Grid>
70
+ </Stack>
71
71
  </Stack>
72
72
  </Stack></Surface>;
73
73
  }
@@ -0,0 +1,19 @@
1
+ import { useState } from 'react';
2
+ import { Button, Inline, ListFilterBar, SegmentedControl, Select, Stack, Text } from '../components';
3
+
4
+ export function CompactFilterBarGallery() {
5
+ const [range, setRange] = useState('today');
6
+ const [feedback, setFeedback] = useState('normal');
7
+ return <Stack gap={4}>
8
+ <Text>筛选抽屉仅保留必要字段;进行中与失败仍然可见。</Text>
9
+ <Inline gap={2}>
10
+ {['normal', 'pending', 'error'].map(state => <Button key={state} variant="secondary" aria-pressed={feedback === state} onClick={() => setFeedback(state)}>{({ normal: '正常', pending: '加载', error: '失败' })[state]}</Button>)}
11
+ </Inline>
12
+ <ListFilterBar label="紧凑筛选示例" result="120 条合成记录" drawerDescription={null} drawerResult={null}
13
+ pending={feedback === 'pending'} error={feedback === 'error' ? '暂时无法更新合成结果,请重试。' : undefined}
14
+ primary={[{ id: 'platform', label: '平台', control: <Select label="平台" defaultValue="all"><option value="all">全部平台</option></Select> }]}
15
+ filters={[{ id: 'range', label: '时间快捷', control: <SegmentedControl fullWidth label="时间快捷" value={range} onValueChange={setRange} items={[{ value: 'today', label: '今天' }, { value: 'yesterday', label: '昨天' }, { value: 'week', label: '本周' }]} /> }]}
16
+ />
17
+ <SegmentedControl fullWidth label="等宽长文本与禁用示例" value="first" onValueChange={() => {}} items={[{ value: 'first', label: '较长的第一个范围' }, { value: 'second', label: '第二范围' }, { value: 'third', label: '不可选择', disabled: true }]} />
18
+ </Stack>;
19
+ }
@@ -0,0 +1,23 @@
1
+ import { useState } from 'react';
2
+ import { Button, CopyButton, EmptyState, ErrorSummary, Fieldset, MediaCard, MediaViewer, Stack, Steps, Text, TextField, ThemePicker } from '../components';
3
+
4
+ const description = '示例辅助说明:这是一段用于检查长文本自然换行的合成内容,来源、时间与读取状态应保持完整可读。';
5
+ export function ComponentTypographyGallery() {
6
+ const [open, setOpen] = useState(false);
7
+ const [next, setNext] = useState(false);
8
+ return <Stack gap={4}>
9
+ <Text as="h2" variant="heading">公共组件文字配对</Text>
10
+ <EmptyState title="没有匹配结果:请检查名称、日期与当前范围" description={description} action={<Button onClick={() => setOpen(true)}>打开文字检查</Button>} />
11
+ <MediaViewer open={open} onOpenChange={setOpen} trigger={<Button>打开媒体示例</Button>} title={next ? '下一项合成媒体' : '示例媒体与较长标题:检查字号、行高和字重'} description={description}
12
+ onNext={() => setNext(true)} onPrevious={() => setNext(false)}
13
+ actions={<label>显示方式 <select aria-label="显示方式"><option>适应窗口</option><option>原始尺寸</option></select></label>}>
14
+ <Text>合成画布:不加载真实媒体。</Text>
15
+ </MediaViewer>
16
+ <MediaCard title="读取状态" kind="photo" state="loading" onOpen={() => setOpen(true)} />
17
+ <CopyButton value="synthetic-example" label="模拟复制" copy={async () => {}} />
18
+ <Steps current={0} items={[{ id:'one',label:'检查说明',description }]} />
19
+ <Fieldset legend="分组提示" hint={description}><TextField id="typography-example" label="示例字段" /></Fieldset>
20
+ <ErrorSummary title="请检查以下字段的完整内容" errors={[{fieldId:'typography-example',message:'定位示例字段'}]} />
21
+ <ThemePicker value="rhine" onValueChange={() => {}} options={[{value:'rhine',label:'仅展示文字状态',previewSrc:'data:image/png;base64,invalid'}]} />
22
+ </Stack>;
23
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { Button, DateRangePicker, Divider, Icon, Inline, Popover, SegmentedControl, Stack, Surface, Text, type DateRange } from '../components';
2
+ import { Button, DateField, DateRangePicker, Divider, Icon, Inline, Popover, SegmentedControl, Select, Stack, Surface, Text, type DateRange } from '../components';
3
3
 
4
4
  const quickRanges = [
5
5
  { value: 'today', label: '今天', range: { start: '2026-10-24', end: '2026-10-24' } },
@@ -78,6 +78,10 @@ export function DateRangeFilterExample({ label = '时间范围', initialValue =
78
78
  }
79
79
 
80
80
  export function DateRangeFilterGallery() {
81
+ const [narrowWidth, setNarrowWidth] = useState('150');
82
+ const [narrowRange, setNarrowRange] = useState<DateRange>({ start: '2026-10-24', end: '2026-10-25' });
83
+ const [singleDate, setSingleDate] = useState('2026-10-24');
84
+ const [resetRevision, setResetRevision] = useState(0);
81
85
  return <section id="date-range-filter">
82
86
  <div className="ds-section-title"><span>RANGE</span><h2>日期筛选 · 快捷选择与自定义</h2></div>
83
87
  <p className="ds-caption">常用日期是一组互斥选项;具体日期持续可见。更多预设即选即用,自定义日期在确认后生效。</p>
@@ -103,6 +107,19 @@ export function DateRangeFilterGallery() {
103
107
  <Text variant="caption" tone="muted">归档结果的统计范围固定,保持日期可读。</Text>
104
108
  </Stack>
105
109
  </Surface>
110
+ <Stack gap={3}>
111
+ <Text as="h3" variant="heading">窄容器中的完整日期</Text>
112
+ <Text variant="caption" tone="muted">只改变实际容器宽度,不缩小字号。请聚焦日期并编辑最后一段,确认完整年月日与日历入口仍可见;尺寸适配自 0.9.1 提供。</Text>
113
+ <Select label="日期容器宽度" value={narrowWidth} onChange={event => setNarrowWidth(event.target.value)}>
114
+ <option value="150">150 CSS px</option><option value="200">200 CSS px</option><option value="280">280 CSS px</option>
115
+ </Select>
116
+ <Stack gap={3} style={{ width: Number(narrowWidth), maxWidth: '100%' }}>
117
+ <DateRangePicker key={resetRevision} label="局部日期" value={narrowRange} onValueChange={setNarrowRange} />
118
+ <Inline><DateField key={resetRevision} label="独立日期" value={singleDate} onValueChange={setSingleDate} /></Inline>
119
+ <Button variant="secondary" onClick={() => { setNarrowRange({ start: '2026-10-24', end: '2026-10-25' }); setSingleDate('2026-10-24'); setResetRevision(value => value + 1); }}>重置示例日期</Button>
120
+ </Stack>
121
+ <Text variant="caption" role="status">已接受日期:{narrowRange.start || '不限开始'} — {narrowRange.end || '不限结束'}</Text>
122
+ </Stack>
106
123
  <Text variant="caption" tone="muted">方向键切换快捷项;Escape 或取消退出草稿并归还焦点,点击外部控件按原生焦点行为继续。所有主题使用同一组公开组件。</Text>
107
124
  </Stack>
108
125
  </section>;
@@ -0,0 +1,21 @@
1
+ import { useState } from 'react';
2
+ import { Button, DateRangePicker, Inline, RemoteMultiSelect, Stack, Surface, Text, type Choice, type DateRange } from '../components';
3
+
4
+ const departments: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: `synthetic-${i}`, label: `合成部门 ${i + 1}${i % 8 === 0 ? ' · 跨团队协作与长期计划' : ''}` }));
5
+
6
+ export function DenseFilterControlsGallery() {
7
+ const [selected, setSelected] = useState(departments.slice(0, 90));
8
+ const [query, setQuery] = useState('');
9
+ const [date, setDate] = useState<DateRange>({ start: '2026-10-01', end: '2026-10-24' });
10
+ const [error, setError] = useState<string | null>(null);
11
+ // Fixture filtering is local; production hosts keep server-side query ownership.
12
+ const options = departments.filter(item => item.label.includes(query));
13
+ return <Surface><Stack gap={4}>
14
+ <Text as="h2" variant="heading">密集筛选控件</Text>
15
+ <Text tone="muted">100 个合成部门、90 个已选项;可搜索、展开移除或清空。日期保留原生键盘和日历。</Text>
16
+ <Inline gap={2}><Button variant="secondary" onClick={() => setSelected(departments)}>全选部门</Button><Button variant="quiet" onClick={() => setSelected([])}>清空部门</Button></Inline>
17
+ <RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} value={selected} onValueChange={setSelected} options={options} maxVisibleTags={2} placeholder="搜索部门" />
18
+ <DateRangePicker compact label="日期区间" value={date} onValueChange={setDate} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={setError} />
19
+ <Text variant="caption" tone="muted">{error || `${date.start} 至 ${date.end}`}</Text>
20
+ </Stack></Surface>;
21
+ }
@@ -6,7 +6,7 @@ import './theme-arknights.css';
6
6
  import './theme-neo-brutalism.css';
7
7
 
8
8
  export const galleryGroups = [
9
- { label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例']] },
9
+ { label: '开始', items: [['top', '设计总览'], ['subscriptions', '订阅管理示例'], ['compact-filters', '紧凑与大量筛选'], ['checkbox-rows', '侧栏双行多选'], ['component-typography', '组件文字配对']] },
10
10
  { label: '应用能力', items: [['app-shell', '背景与多侧栏'], ['advanced-forms', '复杂表单'], ['advanced-data', '受控数据表'], ['media', '媒体卡片与查看器'], ['monitoring', '健康详情与图表']] },
11
11
  { label: '设计语言', items: [['foundation', '视觉基础'], ['backgrounds', '场景背景'], ['spacing', '间距与组合'], ['typography', '字体与文字'], ['brand', '品牌标志']] },
12
12
  { label: '通用组件', items: [['actions', '按钮与输入'], ['selection', '选择与切换'], ['feedback', '状态与加载'], ['disclosure', '展开与组合'], ['data', '数据与分页'], ['forms', '表单与选择'], ['utilities', '操作与布局'], ['overlays', '浮层与反馈'], ['overlay-bounds', '浮层缩放与边界'], ['settings-dialog', '设置导航弹窗'], ['theme-picker', '主题预览与选择'], ['navigation', '导航与步骤'], ['form-workflow', '表单流程']] },
@@ -1,10 +1,11 @@
1
1
  import { useState } from 'react';
2
- import { Button, Grid, Inline, Notice, Select, Stack, Surface, Text, type Spacing } from '../components';
2
+ import { Button, DataTable, Grid, Inline, Notice, Select, Stack, Surface, Text, type Spacing } from '../components';
3
3
  import './spacing.css';
4
4
 
5
5
  const scale: Spacing[] = [1, 2, 3, 4, 6, 8, 12];
6
6
  export function SpacingGallery() {
7
7
  const [state, setState] = useState('normal');
8
+ const [selected, setSelected] = useState<string[]>(['research']);
8
9
  return <section id="spacing">
9
10
  <div className="ds-section-title"><span>SPACE</span><h2>间距与组合 · 让关系可见</h2></div>
10
11
  <Stack gap={6}>
@@ -27,6 +28,19 @@ export function SpacingGallery() {
27
28
  <Text variant="caption" tone="muted">Surface只负责边界与内边距。避免父gap与子margin重复累加;不固定卡高,不为空白增加动效。</Text>
28
29
  </Stack></Surface>
29
30
  </Grid>
31
+ <Stack gap={3}>
32
+ <Text as="h3" variant="heading">纯表格 · 取消容器外围内距</Text>
33
+ <Text variant="caption" tone="muted">Surface padding="none" 自 0.9.1 提供;表头和单元格保留原有间距。切换主题并勾选行,检查边界和文字。</Text>
34
+ <Surface padding="none">
35
+ <DataTable caption="示例部门用量" rows={[
36
+ { id: 'research', name: '研发协作组', requests: 128 },
37
+ { id: 'accessibility', name: '跨部门体验与可访问性协作小组', requests: 42 },
38
+ ]} columns={[
39
+ { key: 'name', header: '部门', render: row => row.name, rowHeader: true },
40
+ { key: 'requests', header: '请求', render: row => row.requests, align: 'right' },
41
+ ]} rowKey={row => row.id} rowLabel={row => row.name} selection={{ keys: selected, onChange: setSelected }} />
42
+ </Surface>
43
+ </Stack>
30
44
  </Stack>
31
45
  </section>;
32
46
  }
package/preview/main.tsx CHANGED
@@ -22,6 +22,10 @@ import { MonitoringGallery } from './MonitoringGallery';
22
22
  import { DateRangeFilterGallery } from './DateRangeFilterGallery';
23
23
  import { ListFilterBarGallery } from './ListFilterBarGallery';
24
24
  import { CompactAccountGallery } from './CompactAccountGallery';
25
+ import { CompactFilterBarGallery } from './CompactFilterBarGallery';
26
+ import { DenseFilterControlsGallery } from './DenseFilterControlsGallery';
27
+ import { CheckboxRowGallery } from './CheckboxRowGallery';
28
+ import { ComponentTypographyGallery } from './ComponentTypographyGallery';
25
29
 
26
30
  function Gallery() {
27
31
  const [motion, setMotion] = useState('system');
@@ -48,6 +52,9 @@ function Gallery() {
48
52
  {section === 'date-range-filter' && <DateRangeFilterGallery />}
49
53
  {section === 'list-filter-bar' && <ListFilterBarGallery />}
50
54
  {section === 'compact-account' && <CompactAccountGallery />}
55
+ {section === 'compact-filters' && <><CompactFilterBarGallery /><DenseFilterControlsGallery /></>}
56
+ {section === 'checkbox-rows' && <CheckboxRowGallery />}
57
+ {section === 'component-typography' && <ComponentTypographyGallery />}
51
58
  {section === 'backgrounds' && <BackgroundGallery />}
52
59
  {section === 'app-shell' && <ShellGallery />}
53
60
  {section === 'orbit' && <OrbitGallery />}
@@ -3,8 +3,15 @@ body { margin:0; }
3
3
  .compact-gallery-stage { max-width:100%; min-width:0; }
4
4
  .compact-gallery-examples { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); gap:var(--rhine-space-6); align-items:start; }
5
5
  .compact-gallery-examples>* { min-width:0; }
6
- .compact-gallery-fact-pair { align-items:baseline; }
7
- .compact-gallery-fact-pair>* { min-width:0; overflow-wrap:anywhere; }
6
+ .compact-gallery-facts { container-type:inline-size; }
7
+ .compact-gallery-facts-meta { grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,2fr); align-items:baseline; }
8
+ .compact-gallery-facts-subscription { grid-column:1 / -1; }
9
+ .compact-gallery-fact-pair { grid-template-columns:repeat(2,minmax(0,1fr)); align-items:baseline; }
10
+ .compact-gallery-facts-meta>*,.compact-gallery-fact-pair>* { min-width:0; overflow-wrap:anywhere; }
11
+ @container (max-width:360px) {
12
+ .compact-gallery-facts-meta { grid-template-columns:repeat(2,minmax(0,1fr)); }
13
+ .compact-gallery-facts-expiry { grid-column:1 / -1; }
14
+ }
8
15
  .ds-type-row { display:grid; grid-template-columns:180px minmax(0,1fr); align-items:baseline; gap:12px 24px; padding:16px 0; border-bottom:1px solid var(--rhine-line); }
9
16
  .ds-type-row:last-child { border:0; }
10
17
  .ds-type-copy { display:grid; gap:12px; margin-top:16px; max-width:60ch; }
@@ -0,0 +1,65 @@
1
+ // Use a production preview and run with playwright-cli run-code --filename.
2
+ async page => {
3
+ page.setDefaultTimeout(10000);
4
+ const origin = new URL(page.url()).origin;
5
+ const failures = [], errors = [];
6
+ let scenarios = 0;
7
+ page.on('pageerror', error => errors.push(String(error)));
8
+ const check = (pass, context) => { if (!pass) failures.push(context); };
9
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
10
+ for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
11
+ await page.setViewportSize({ width, height: 1000 });
12
+ await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#checkbox-rows`);
13
+ await page.getByRole('heading', { name: '侧栏双行多选', exact: true }).waitFor();
14
+ await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
15
+ const container = page.locator('[data-checkbox-rows]');
16
+ for (const requestedWidth of [160, 240, 320]) {
17
+ const context = { theme, width, zoom, requestedWidth };
18
+ await page.getByRole('combobox', { name: '侧栏宽度', exact: true }).selectOption(String(requestedWidth));
19
+ const geometry = await container.evaluate((root, zoom) => {
20
+ const rows = [...root.querySelectorAll('.rhine-check--row > label')];
21
+ return rows.map(row => {
22
+ const name = row.querySelector('.rhine-check__name'), hint = row.querySelector('.rhine-check__hint');
23
+ const text = row.querySelector('.rhine-check__text');
24
+ const r = row.getBoundingClientRect(), n = name.getBoundingClientRect(), h = hint?.getBoundingClientRect(), t = text.getBoundingClientRect();
25
+ const style = getComputedStyle(row), ns = getComputedStyle(name), hs = hint && getComputedStyle(hint);
26
+ const border = parseFloat(style.borderTopWidth);
27
+ return { height:r.height / zoom, fits:row.scrollWidth <= row.clientWidth + 1, nameSize:ns.fontSize, nameLine:ns.lineHeight, nameWeight:ns.fontWeight, hintSize:hs?.fontSize, hintLine:hs?.lineHeight,
28
+ aligned:!h || Math.abs(n.left-h.left)<1, gap:h ? (h.top-n.bottom)/zoom : null, top:(t.top-r.top)/zoom-border, bottom:(r.bottom-t.bottom)/zoom-border };
29
+ });
30
+ }, zoom);
31
+ check(geometry.every(row => row.fits && row.height >= 44 && row.nameSize === '14px' && row.nameLine === '22px' && row.nameWeight === (theme === 'neo-brutalism' ? '600' : '500') && row.aligned && (row.gap === null || Math.abs(row.gap-4)<.1) && (!row.hintSize || row.hintSize === '12px' && row.hintLine === '18px') && Math.abs(row.top-row.bottom)<.1), { ...context, kind:'geometry', geometry });
32
+ check(await container.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind:'container-fit' });
33
+ scenarios++;
34
+ }
35
+ const all = container.getByRole('checkbox', { name:'全部项目', exact:true });
36
+ const input = container.getByRole('checkbox', { name:'示例项目 A', exact:true });
37
+ check(await all.evaluate(e => e.indeterminate), { theme,width,zoom,kind:'mixed' });
38
+ const row = input.locator('..');
39
+ const selectedStyle = await row.evaluate(e => ({ background:getComputedStyle(e).backgroundColor,border:getComputedStyle(e).borderColor }));
40
+ await row.getByText('内存卡 · 14 项', { exact:true }).click();
41
+ check(!await input.isChecked(), { theme,width,zoom,kind:'hint-click' });
42
+ await page.mouse.move(0, 0);
43
+ const defaultStyle = await row.evaluate(e => ({ background:getComputedStyle(e).backgroundColor,border:getComputedStyle(e).borderColor }));
44
+ check(defaultStyle.background !== selectedStyle.background && defaultStyle.border !== selectedStyle.border, { theme,width,zoom,kind:'selection-style' });
45
+ const rowBox = await row.boundingBox();
46
+ await row.click({ position:{x:rowBox.width / 2,y:4} });
47
+ check(await input.isChecked(), { theme,width,zoom,kind:'padding-click' });
48
+ await all.focus(); await page.keyboard.press('Tab');
49
+ check(await input.evaluate(e => e === document.activeElement) && await row.evaluate(e => getComputedStyle(e).boxShadow.includes('inset')), { theme,width,zoom,kind:'keyboard-focus' });
50
+ await page.keyboard.press('Space');
51
+ check(!await input.isChecked(), { theme,width,zoom,kind:'space-toggle' });
52
+ const disabled = container.getByRole('checkbox', { name:'不可用项目',exact:true });
53
+ await disabled.locator('..').scrollIntoViewIfNeeded();
54
+ const disabledBox = await disabled.locator('..').boundingBox();
55
+ await page.mouse.click(disabledBox.x + disabledBox.width / 2, disabledBox.y + disabledBox.height / 2);
56
+ check(!await disabled.isChecked(), { theme,width,zoom,kind:'disabled' });
57
+ if (width === 400 && zoom === 2) {
58
+ await page.getByRole('combobox', { name:'侧栏宽度',exact:true }).selectOption('160');
59
+ await all.focus(); await page.keyboard.press('Tab');
60
+ await container.screenshot({ path:`output/playwright/issue-29/${theme}.png` });
61
+ }
62
+ }
63
+ }
64
+ return { pass:!failures.length && !errors.length, scenarios, failures, errors, scope:'Chromium, three themes, 400/520/880px, 160/240/320px sidebar capped by available space, CSS zoom 100/200%; native browser menu zoom not covered.' };
65
+ }