@linhey/rhine-design 0.3.0 → 0.5.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 (89) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +63 -14
  3. package/components/Actions.tsx +2 -0
  4. package/components/AdvancedForms.tsx +184 -0
  5. package/components/AppShell.tsx +71 -0
  6. package/components/Background.tsx +29 -0
  7. package/components/COMMON_COMPONENTS.md +5 -1
  8. package/components/Charts.tsx +94 -0
  9. package/components/Data.tsx +98 -18
  10. package/components/FormWorkflow.tsx +25 -0
  11. package/components/Forms.tsx +14 -8
  12. package/components/MOTION.md +6 -2
  13. package/components/Media.tsx +82 -0
  14. package/components/Navigation.tsx +42 -0
  15. package/components/Overlays.tsx +10 -3
  16. package/components/README.md +20 -5
  17. package/components/SPACING.md +82 -0
  18. package/components/StatusTimeline.tsx +67 -0
  19. package/components/TYPOGRAPHY.md +34 -2
  20. package/components/background.css +43 -0
  21. package/components/charts.css +27 -0
  22. package/components/common.css +17 -8
  23. package/components/data.css +6 -0
  24. package/components/drawer-placement.css +4 -0
  25. package/components/extensions.css +70 -0
  26. package/components/index.ts +18 -2
  27. package/components/media-parity.css +3 -0
  28. package/components/patterns.css +39 -0
  29. package/components/styles.css +15 -8
  30. package/components/theme-arknights.css +306 -0
  31. package/components/theme-neo-brutalism.css +90 -0
  32. package/components/theme.tsx +58 -0
  33. package/components/timeline.css +23 -0
  34. package/components/tokens.css +49 -1
  35. package/docs/ANTD_MUI_AUDIT.md +38 -0
  36. package/docs/APPLICATION_COMPONENTS.md +175 -0
  37. package/docs/DESIGN_SYSTEM.md +12 -2
  38. package/docs/DESIGN_SYSTEM_AUDIT.md +6 -4
  39. package/docs/INTERACTION_PATTERNS.md +129 -0
  40. package/docs/ISSUE_4_VALIDATION.md +21 -0
  41. package/docs/ISSUE_VALIDATION.md +52 -0
  42. package/docs/REFERENCE_RESEARCH.md +38 -0
  43. package/docs/THEME_NEO_BRUTALISM.md +37 -0
  44. package/docs/THEMING.md +145 -0
  45. package/docs/assets/application-shell.png +0 -0
  46. package/docs/assets/issue4-shell.png +0 -0
  47. package/docs/assets/media-viewer-mobile.png +0 -0
  48. package/docs/assets/monitoring-desktop.png +0 -0
  49. package/docs/audits/DATA_ANTD_MUI.md +75 -0
  50. package/docs/audits/FORMS_ANTD_MUI.md +88 -0
  51. package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
  52. package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
  53. package/package.json +5 -1
  54. package/preview/BackgroundGallery.tsx +46 -0
  55. package/preview/CommonGallery.tsx +8 -8
  56. package/preview/ExtensionsGallery.tsx +95 -0
  57. package/preview/GalleryShell.tsx +107 -0
  58. package/preview/LineHeightGallery.tsx +27 -0
  59. package/preview/MonitoringGallery.tsx +27 -0
  60. package/preview/MotionGallery.tsx +6 -2
  61. package/preview/PatternGallery.tsx +52 -0
  62. package/preview/SpacingGallery.tsx +32 -0
  63. package/preview/SubscriptionGallery.tsx +84 -0
  64. package/preview/backgrounds.css +10 -0
  65. package/preview/extensions.css +6 -0
  66. package/preview/line-height.css +2 -0
  67. package/preview/main.tsx +48 -25
  68. package/preview/patterns.css +7 -0
  69. package/preview/preview.css +2 -8
  70. package/preview/shell.css +35 -0
  71. package/preview/spacing.css +2 -0
  72. package/preview/subscriptions.css +27 -0
  73. package/preview/theme-arknights.css +234 -0
  74. package/preview/theme-neo-brutalism.css +11 -0
  75. package/skills/rhine-design/SKILL.md +26 -3
  76. package/skills/rhine-design/references/API_MAP.md +11 -3
  77. package/skills/rhine-design/references/TASK_MAP.md +5 -1
  78. package/skills/rhine-design/references/integration.md +7 -3
  79. package/tests/Background.test.tsx +22 -0
  80. package/tests/DataParity.test.tsx +148 -0
  81. package/tests/Extensions.test.tsx +145 -0
  82. package/tests/FormParity.test.tsx +185 -0
  83. package/tests/GalleryShell.test.tsx +98 -0
  84. package/tests/Monitoring.test.tsx +38 -0
  85. package/tests/MonitoringParity.test.tsx +125 -0
  86. package/tests/Patterns.test.tsx +75 -0
  87. package/tests/ShellMediaParity.test.tsx +180 -0
  88. package/tests/StatusTimeline.test.tsx +15 -0
  89. package/tests/Theme.test.tsx +112 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # 版本记录
2
2
 
3
+ ## 0.5.0(2026-10-24)
4
+
5
+ - 完成 [Ant Design / MUI 逐项对标](docs/ANTD_MUI_AUDIT.md):补齐表单只读/校验/键盘、连续多选、树字符导航、数据表单选/排序清除/自动固定列、分页首末页/边界焦点、受控左右抽屉、媒体方向键/首尾焦点、图表受控逐点查看与格式化、健康历史稳定 ID 与受控详情。RemoteMultiSelect 默认连续选择,`closeOnSelect=true` 可恢复选完关闭;原单选 Combobox 默认不变。无新增运行时依赖。
6
+ - 发布审阅修正:数字未编辑时不重复提交,未指定 precision 时保留输入精度;加载中的媒体预览保持挂载以接收原生加载/失败事件;空图表不显示不可操作提示,时间轴移除不适用的 ARIA 属性;修正橙趣表格密度/表头主题变量、行悬停底色及仅 CSS 主题根的派生变量。
7
+
8
+ - 对应 #1–#6:新增 Background/AppShell、MediaCard/MediaViewer、DateField/DateRangePicker/NumberField/TreeSelect/RemoteMultiSelect,加上 TrendChart/StackedBar,共 11 个公共组件。
9
+ - DataTable 增加受控跨页选择、禁选/混合态、层级展开与详情、密度和固定表头/列;Pagination 增加页大小意图。
10
+ - 新增三主题背景、表单、数据与中性媒体 tokens;提供六组真实 React 展台及[应用组件契约](docs/APPLICATION_COMPONENTS.md)。宿主继续负责请求、时区、导出、下载、虚拟列表和持久化。
11
+ - StatusTimeline 增加开始/结束、原因、计数及统计口径的结构化详情,使用可避让视口的 Popover;新增柱形趋势、缺失断线和堆叠占比,未知值不补零,配套文字替代与空/陈旧/错误状态。
12
+ - 本节 API 自 0.5.0 提供;0.4.0 不含这些能力。建议固定版本并提交 lockfile。
13
+
14
+ - 补齐 Background 六类场景的底色、文字与纹理配置,新增局部 `appearance` 覆盖。`decoration` 默认 `auto`,仅画布使用主题纹理;正文与 AppShell 默认纯色,原有显式 `theme` / `none` 保留。Surface / Fieldset 与主要浮层接入语义底色,遮罩独立配置。新增展台 `#backgrounds`,包含实际弹窗、局部覆盖与纹理切换,仅展示当前主题的场景效果,跟随主题选择器更新。已有正文需要纹理时显式传 `decoration="theme"`。
15
+
16
+ ## 0.4.0
17
+
18
+ - 新增 `@linhey/rhine-design/theme`:`ThemeProvider`、`useTheme`、`themes`、`ThemeName` 与 `ThemeProviderProps`,自动加载三套内置主题,支持受控/非受控切换及并列 Provider;嵌套覆盖暂不支持。切换保留组件、表单与浮层状态,偏好存储由宿主管理。展台复用公共 API;主题 CSS 中的展台布局和 HUD/扫描/开机装饰移至 `preview/theme-*.css`。补齐接入文档及 API/展台回归测试。
19
+
20
+ - 新增「橙趣 · 粗描边」主题 `theme-neo-brutalism.css`:浅灰蓝点阵、米白大圆角、黑色粗描边、无模糊硬阴影、橙色主操作与低饱和类型标签。展台主题切换器支持选择与记忆,`?theme=neo-brutalism#subscriptions` 可直达单页订阅示例;复用 Dialog/Menu/DataTable 展示卡片详情、管理菜单和可更新的演示数量与金额。可单独导入。
21
+
22
+ - 新增明日方舟·战术终端主题 `theme-arknights.css`:暗色舱底、平行四边形按钮、信号黄高亮、坐标网格与警示斜纹,附 HUD 取景框、终端读数与扫描/开机装饰动效;仅覆盖变量与装饰层,不改组件 API 与交互契约,减少动态下装饰动效全部关闭。展台右上角可切换主题并记忆选择。
23
+
24
+ - 将密集任务浮层经验整理为通用规范:自然高度/单层滚动、导航行、渐进展示、视觉与命中区分离,以及选择即保存的适用边界;补充临界溢出和末条详情验收要求。本项仅整理文档,不代表发布新API。
25
+
26
+ - compact健康条将20px视觉高度与44px命中区分开,避免列表重复叠加空白;间距规范补充标题8px、分隔线两侧16px及操作区避让规则。
27
+
28
+ - Dialog的sidePanel支持渲染函数,将传入的唯一关闭按钮与侧栏标题、说明组合成顶部导航;取消额外关闭行,左右标题对齐,展台同步示例。
29
+
30
+ - 健康时间轴使用独立明亮health色;新增compact模式,重复列表隐藏常驻日期/详情,悬停与键盘聚焦时查看,保留44px操作区和aria-valuetext。规范补充“选择即保存”的忙碌/确认/失败契约,默认与紧凑展台同步更新。
31
+
32
+ - 新增 StatusTimeline / TimelineLegend,源码组件增至48个。五类历史证据、44px键盘/指针操作区、时间文字与图例;不计算健康评分,不对未知补绿色,不播放装饰动画。监控场景展台可交互查看。
33
+ - Dialog新增可选sidePanel:左侧主任务、右侧关联预览,最大960px、24px分栏间距,720px及以下上下排列;保留同一个模态焦点范围、关闭与减少动态规则。展台提供左右分栏预览。
34
+
35
+ - SideNav支持可选20px线性图标(Icon新增connection/chart),移除当前项的外链箭头;采用44px最小操作行、14/22文字、4px行间距与3px圆角,选中状态保留苔绿底/左侧细边界和aria-current。导航展台同步示例;已有不带图标用法继续可用。
36
+
37
+ - SelectionCard统一24px内边距、16/24标题、600字重与8px标题/内容间隔;DataTable标题显式16/24。SideNav分组、Breadcrumbs、Progress标签及KeyValue标签明确12/18,侧栏分组标题后8px。既有真实展台同步展示,不再继承正文行高。
38
+
39
+ - 补充文字行高、段落与控件规则及可切换行框辅助线的多行展台。沿用现有字号/行高token,按钮、Tab和单行输入同步消费body行高,默认尺寸不变。
40
+
41
+ - 新增间距规范与`#spacing`真实组合展台:24/8/16卡片配方,默认/加载/错误/长文案;复用Stack/Inline/Grid,公共API和组件数量不变。Surface内边距改为消费既有space-6 token,默认外观不变。
42
+
43
+ - 新增 SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary,main 公共组件增至46个;展台自身复用导航组件。
44
+ - 新增导航与步骤、表单流程、14类场景与交互选型章节,动效实验室增加抽屉与步骤播放/重播,总计12组。
45
+ - 参考 GitHub 高星项目与 skills,沉淀组件选择、错误恢复、监控告警、动效与无障碍契约;不增加运行时依赖。
46
+
47
+ - 在线展台改为侧栏目录与单章节工作区,支持章节深链接、历史导航、当前项高亮、独立滚动与窄屏折叠目录;全局动效偏好跨章节保留,离开的演示卸载。
48
+
3
49
  ## 0.3.0
4
50
 
5
51
  - **破坏性变更:**统一使用莱茵设计的 `rhine-` 命名空间,无旧前缀别名。
package/README.md CHANGED
@@ -16,15 +16,21 @@
16
16
  <a href="CHANGELOG.md">版本记录</a>
17
17
  </p>
18
18
 
19
- Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、40 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
19
+ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、48 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
20
20
 
21
21
  - **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
22
- - **40 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
23
- - **可验证的动效**:10 组播放 / 重播 / 复位演示,以及三角构架启动编排。
22
+ - **48 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
23
+ - **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
24
24
  - **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
25
25
 
26
+ **0.4.0 新增能力:**主题 API、三套内置主题,以及导航、表单流程与健康时间轴组件;在线展台跟随 main,安装前请核对[版本记录](CHANGELOG.md)。新增组件及选型见[交互规范](docs/INTERACTION_PATTERNS.md),参考项目见[GitHub 调研](docs/REFERENCE_RESEARCH.md)。
27
+
26
28
  基于 React 与 Radix UI,采用 [Apache-2.0](LICENSE) 许可证。
27
29
 
30
+ **0.5.0 新增能力**:新增背景与多侧栏、复杂表单、受控数据表、媒体与监控图表,共 59 个组件。API、最小接入与版本边界见[应用组件契约](docs/APPLICATION_COMPONENTS.md)。npm 0.4.0 不含这些能力。
31
+
32
+ 本轮新增 API 与交互已逐项核对 Ant Design / MUI,修复及明确保留的差异见[对标清单](docs/ANTD_MUI_AUDIT.md)。
33
+
28
34
  <a id="quick-start"></a>
29
35
 
30
36
  ## 快速接入
@@ -34,22 +40,26 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
34
40
  在你的 React 项目中安装:
35
41
 
36
42
  ```sh
37
- npm install @linhey/rhine-design@0.3.0
43
+ npm install --save-exact @linhey/rhine-design@0.5.0
38
44
  ```
39
45
 
40
46
  包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
41
47
 
42
48
  ```tsx
43
- import { Button, Stack, Surface, Text } from '@linhey/rhine-design';
49
+ import { Button, Inline, Stack, Surface, Text } from '@linhey/rhine-design';
44
50
 
45
51
  export function Welcome() {
46
52
  return (
47
53
  <div className="rhine-theme">
48
54
  <Surface>
49
55
  <Stack gap={4}>
50
- <Text as="h1" variant="title">开始新的工作</Text>
51
- <Text tone="muted">用统一的组件,构建属于你的工作空间。</Text>
52
- <Button onClick={() => { /* 在这里处理点击 */ }}>继续</Button>
56
+ <Stack gap={2}>
57
+ <Text as="h1" variant="title">开始新的工作</Text>
58
+ <Text tone="muted">用统一的组件,构建属于你的工作空间。</Text>
59
+ </Stack>
60
+ <Inline gap={2}>
61
+ <Button onClick={() => { /* 在这里处理点击 */ }}>继续</Button>
62
+ </Inline>
53
63
  </Stack>
54
64
  </Surface>
55
65
  </div>
@@ -59,6 +69,38 @@ export function Welcome() {
59
69
 
60
70
  入口自动加载样式,用 `.rhine-theme` 包裹页面即可使用默认主题。布局和颜色变量使用 `--rhine-*` 前缀;完整定义见 [tokens.css](components/tokens.css)。
61
71
 
72
+ ### 背景组件(0.5.0)
73
+
74
+ 0.5.0 提供 Background 与 AppShell,npm 0.4.0 不含这两项能力。背景层级、最小接入及兼容范围见[背景与应用外壳契约](docs/APPLICATION_COMPONENTS.md),真实展台见 [场景背景](https://linhay.github.io/rhine-design/#backgrounds) 和 [背景与侧栏](https://linhay.github.io/rhine-design/#app-shell)。
75
+
76
+ ### 一键切换主题(0.4.0)
77
+
78
+ 展台右上角可切换并记忆主题。新增 **橙趣 · 粗描边**:浅灰蓝点阵背景、米白圆角卡片、黑色粗边、向右下偏移的硬阴影与亮橙主按钮。打开 [在线订阅管理示例](https://linhay.github.io/rhine-design/?theme=neo-brutalism#subscriptions),体验卡片详情、管理菜单与演示表单;本地开发访问 `http://127.0.0.1:18309/?theme=neo-brutalism#subscriptions`。
79
+
80
+ 从 0.4.0 接入,入口自动加载内置主题,页面只需包裹一次:
81
+
82
+ ```tsx
83
+ import { Button, Surface } from '@linhey/rhine-design';
84
+ import { ThemeProvider, useTheme } from '@linhey/rhine-design/theme';
85
+
86
+ function Appearance() {
87
+ const { setTheme } = useTheme();
88
+ return <Button onClick={() => setTheme('arknights')}>切换战术主题</Button>;
89
+ }
90
+
91
+ export function App() {
92
+ return <ThemeProvider defaultTheme="neo-brutalism">
93
+ <Surface><Appearance /></Surface>
94
+ </ThemeProvider>;
95
+ }
96
+ ```
97
+
98
+ 受控模式、主题列表、存储职责、浮层继承和迁移方式见 [主题 API 接入](docs/THEMING.md)。第一版支持并列 Provider,不支持嵌套覆盖;偏好由宿主管理,库不读写本地存储。此 API 自 0.4.0 提供。
99
+
100
+ 完整变量、类型标签、交互与减少动态规则见 [橙趣主题规范](docs/THEME_NEO_BRUTALISM.md)。现有 CSS-only 用法仍支持;展台装饰独立放在 `preview/theme-*.css`。
101
+
102
+ 0.5.0 新增六类[场景背景配置](docs/THEMING.md#场景背景050):画布、正文、侧栏、卡片表单、浮层和媒体。正文默认无纹理,支持局部配色与纹理覆盖;本地展台 `http://127.0.0.1:18309/#backgrounds` 可查看当前主题的六类场景,并通过主题选择器切换。
103
+
62
104
  类名、CSS 变量、动画名称与动态设置统一使用 `rhine-` 命名空间。升级到 0.3.0 时请同步迁移主题根、自定义样式与动效属性,具体见 [迁移说明](CHANGELOG.md#030)。
63
105
 
64
106
  ### 兼容范围
@@ -66,7 +108,7 @@ export function Welcome() {
66
108
  - 包导出 TS/TSX、CSS 和 SVG 源码,构建器需支持 CSS 导入与 Vite 的 SVG `?no-inline` 引用;目前未验证其他构建器和 SSR。
67
109
  - 浮层挂载到最近的 `.rhine-theme`。请将主题根放在页面根部,避免对它使用 `transform`、裁剪或 `contain`。
68
110
  - 组件提供呈现与交互,应用负责数据请求、搜索、分页和操作结果。
69
- - 当前提供默认暖白主题;Logo 支持独立的深浅配色,完整组件深色主题尚未提供。
111
+ - 默认使用暖白主题;0.4.0 还提供可选的战术终端与橙趣主题。Logo 的深浅配色独立于界面主题。
70
112
 
71
113
  <a id="components"></a>
72
114
 
@@ -77,12 +119,14 @@ export function Welcome() {
77
119
  | 品牌与文字 | Logo · Text |
78
120
  | 操作 | Button · Icon · IconButton · Link · CopyButton |
79
121
  | 布局 | Surface · Toolbar · Divider · Stack · Inline · Grid |
80
- | 表单与选择 | TextField · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard |
81
- | 导航与展开 | Tabs · SegmentedControl · Disclosure |
82
- | 数据 | Metric · KeyValue · DataTable · Pagination |
83
- | 浮层 | Dialog · ConfirmDialog · Popover · Menu · Tooltip |
122
+ | 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard |
123
+ | 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
124
+ | 数据 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ |
125
+ | 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
84
126
  | 状态与反馈 | Badge · Notice · Toast · Progress · Skeleton · EmptyState |
85
127
 
128
+ ¹ 自 0.4.0 提供。
129
+
86
130
  参数、状态和组合示例见[组件 API](components/README.md)与[常用组件契约](components/COMMON_COMPONENTS.md)。
87
131
 
88
132
  <a id="documentation"></a>
@@ -94,13 +138,18 @@ export function Welcome() {
94
138
  | 主题 | 文档 | 在线预览 |
95
139
  | --- | --- | --- |
96
140
  | 视觉基础与组合原则 | [设计规范](docs/DESIGN_SYSTEM.md) | [视觉基础](https://linhay.github.io/rhine-design/#foundation) |
141
+ | 间距、分组与卡片组合 | [Spacing](components/SPACING.md) | [间距与组合](https://linhay.github.io/rhine-design/#spacing) |
97
142
  | 字体、文字层级与数值 | [Typography](components/TYPOGRAPHY.md) | [字体与文字](https://linhay.github.io/rhine-design/#typography) |
98
143
  | 品牌几何与 Logo | [Logo 契约](components/README.md#品牌标志) | [品牌标志](https://linhay.github.io/rhine-design/#brand) |
99
144
  | 动效、交互与减少动态 | [Motion](components/MOTION.md) | [动效实验室](https://linhay.github.io/rhine-design/#motion) |
100
145
  | 启动动画与就绪衔接 | [Startup](components/STARTUP.md) | [启动动画](https://linhay.github.io/rhine-design/#startup) |
146
+ | 场景与交互选型 | [使用规范](docs/INTERACTION_PATTERNS.md) | [场景指南](https://linhay.github.io/rhine-design/#patterns) |
147
+ | 导航与表单流程 | [组件契约](docs/INTERACTION_PATTERNS.md#新组件契约) | [导航](https://linhay.github.io/rhine-design/#navigation) · [表单](https://linhay.github.io/rhine-design/#form-workflow) |
101
148
  | 系统覆盖与演进方向 | [组件系统对照](docs/DESIGN_SYSTEM_AUDIT.md) | — |
102
149
 
103
- 展台包含组件状态、键盘操作、长文本、加载与错误示例。动效可播放、重播、复位和慢放,并支持减少动态模式。在线版本跟随 `main` 更新;如需本地运行:
150
+ 展台采用左侧分组目录、右侧单章节工作区;目录与内容独立滚动,当前章节高亮,原有锚点可直接分享并支持浏览器前进/后退。窄屏使用可展开目录,选择章节后自动收起。动效偏好跨章节保留。
151
+
152
+ 展台包含组件状态、键盘操作、长文本、加载与错误示例。动效可播放、重播、复位和慢放,并支持减少动态模式。离开章节会卸载其演示,返回后重新开始。在线版本跟随 `main` 更新;如需本地运行:
104
153
 
105
154
  ```sh
106
155
  git clone https://github.com/linhay/rhine-design.git
@@ -13,6 +13,8 @@ const paths = {
13
13
  external: 'M11 3h6v6M17 3 9 11M7 3H3v14h14v-4',
14
14
  check: 'm4 10 4 4 8-8',
15
15
  more: 'M4 10h.1M10 10h.1M16 10h.1',
16
+ connection: 'M7 2v4M13 2v4M5 6h10v3a5 5 0 0 1-10 0V6M10 14v4',
17
+ chart: 'M3 3v14h14M7 13V9M11 13V5M15 13V7',
16
18
  } as const;
17
19
  export type IconName = keyof typeof paths;
18
20
  export function Icon({ name, size = 20 }: { name: IconName; size?: 16 | 20 }) {
@@ -0,0 +1,184 @@
1
+ import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
2
+ import { Button, TextField, type Choice } from './components';
3
+ import { Combobox } from './Forms';
4
+
5
+ export interface DateFieldProps {
6
+ label: string; value: string; onValueChange: (value: string) => void;
7
+ min?: string; max?: string; disabled?: boolean; error?: string;
8
+ readOnly?: boolean; required?: boolean; id?: string; name?: string; hint?: string;
9
+ onValidationChange?: (error: string | null) => void;
10
+ }
11
+ function dateError(value: string, min?: string, max?: string): string | undefined {
12
+ if (min && max && min > max) return '最早日期不能晚于最晚日期。';
13
+ if (!value) return;
14
+ const parsed = new Date(`${value}T00:00:00Z`);
15
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) return '请输入有效日期。';
16
+ if (min && value < min) return `日期不能早于 ${min}。`;
17
+ if (max && value > max) return `日期不能晚于 ${max}。`;
18
+ }
19
+ function useDateValidation(error: string | undefined, onValidationChange?: (error: string | null) => void) {
20
+ const callback = useRef(onValidationChange);
21
+ useEffect(() => { callback.current = onValidationChange; }, [onValidationChange]);
22
+ useEffect(() => { callback.current?.(error || null); }, [error]);
23
+ }
24
+ /** Date-only ISO strings: conversion to timestamps and time zones belongs to the host. */
25
+ export function DateField({ onValueChange, onValidationChange, error, ...props }: DateFieldProps) {
26
+ const invalid = error || dateError(props.value, props.min, props.max);
27
+ useDateValidation(invalid, onValidationChange);
28
+ return <TextField {...props} error={invalid} type="date" onChange={event => { if (!props.disabled && !props.readOnly) onValueChange(event.target.value); }} />;
29
+ }
30
+ export interface DateRange { start: string; end: string }
31
+ export interface DateRangePickerProps {
32
+ label: string; value: DateRange; onValueChange: (value: DateRange) => void;
33
+ min?: string; max?: string; disabled?: boolean; error?: string;
34
+ presets?: { label: string; value: DateRange }[];
35
+ readOnly?: boolean; required?: boolean; clearable?: boolean; hint?: string;
36
+ startLabel?: string; endLabel?: string; startName?: string; endName?: string;
37
+ onValidationChange?: (error: string | null) => void;
38
+ }
39
+ export function DateRangePicker({ label, value, onValueChange, min, max, disabled, error, presets = [], readOnly = false, required, clearable = true, hint, startLabel = '开始日期', endLabel = '结束日期', startName, endName, onValidationChange }: DateRangePickerProps) {
40
+ const id = useId();
41
+ const rangeError = (range: DateRange) => dateError(range.start, min, max) || dateError(range.end, min, max) || (range.start && range.end && range.start > range.end ? '结束日期不能早于开始日期。' : undefined);
42
+ const invalid = error || rangeError(value);
43
+ useDateValidation(invalid, onValidationChange);
44
+ return <fieldset className="rhine-date-range" disabled={disabled} aria-describedby={[hint && `${id}-hint`, invalid && id].filter(Boolean).join(' ') || undefined}>
45
+ <legend>{label}</legend><div className="rhine-date-range__fields">
46
+ <DateField label={startLabel} name={startName} value={value.start} min={min} max={max} readOnly={readOnly} required={required} disabled={disabled} onValueChange={start => onValueChange({ ...value, start })} error={invalid ? '请检查日期范围' : undefined} />
47
+ <DateField label={endLabel} name={endName} value={value.end} min={value.start && (!min || value.start > min) ? value.start : min} max={max} readOnly={readOnly} required={required} disabled={disabled} onValueChange={end => onValueChange({ ...value, end })} />
48
+ </div><div className="rhine-inline">
49
+ {presets.map(preset => <Button key={preset.label} variant="secondary" disabled={disabled || readOnly || !!rangeError(preset.value) || !!(required && (!preset.value.start || !preset.value.end))} onClick={() => onValueChange(preset.value)}>{preset.label}</Button>)}
50
+ {clearable && <Button variant="quiet" disabled={disabled || readOnly || (!value.start && !value.end)} onClick={() => onValueChange({ start: '', end: '' })}>清空日期</Button>}
51
+ </div>{hint && <p id={`${id}-hint`} className="rhine-field__hint">{hint}</p>}{invalid && <p id={id} role="alert" className="rhine-field__error">{invalid}</p>}
52
+ </fieldset>;
53
+ }
54
+
55
+ export interface NumberFieldProps {
56
+ label: string; value: number | null; onValueChange: (value: number | null) => void;
57
+ min?: number; max?: number; step?: number; precision?: number; disabled?: boolean; error?: string;
58
+ readOnly?: boolean; required?: boolean; name?: string; id?: string; hint?: string;
59
+ controls?: boolean;
60
+ }
61
+ export function NumberField({ label, value, onValueChange, min, max, step = 1, precision, disabled, error, readOnly = false, required, name, id, hint, controls = true }: NumberFieldProps) {
62
+ const [draft, setDraft] = useState(value === null ? '' : String(value));
63
+ const [localError, setLocalError] = useState('');
64
+ const composing = useRef(false);
65
+ useEffect(() => { setDraft(value === null ? '' : String(value)); setLocalError(''); }, [value]);
66
+ const numericDraft = draft.trim() ? Number(draft) : null;
67
+ const boundsError = (min !== undefined && max !== undefined && min > max) ? '最小值不能大于最大值。' : numericDraft !== null && Number.isFinite(numericDraft) && ((min !== undefined && numericDraft < min) || (max !== undefined && numericDraft > max)) ? `请输入 ${min ?? '−∞'} 至 ${max ?? '+∞'} 之间的数字。` : undefined;
68
+ const blocked = disabled || readOnly || (min !== undefined && max !== undefined && min > max);
69
+ const normalize = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, Number(n.toFixed(Math.max(0, Math.min(15, precision ?? 12))))));
70
+ const commit = () => {
71
+ if (composing.current || blocked) return;
72
+ if (!draft.trim()) { setLocalError(''); if (value !== null) onValueChange(null); return; }
73
+ const n = Number(draft);
74
+ if (!Number.isFinite(n)) { setLocalError('请输入有效数字。'); return; }
75
+ if ((min !== undefined && n < min) || (max !== undefined && n > max)) { setLocalError(`请输入 ${min ?? '−∞'} 至 ${max ?? '+∞'} 之间的数字。`); return; }
76
+ const next = precision === undefined ? n : normalize(n); setDraft(String(next)); setLocalError(''); if (next !== value) onValueChange(next);
77
+ };
78
+ const move = (direction: number) => {
79
+ if (blocked || composing.current) return;
80
+ const parsed = draft.trim() ? Number(draft) : value ?? 0;
81
+ const next = normalize((Number.isFinite(parsed) ? parsed : value ?? 0) + direction * (Number.isFinite(step) && step ? Math.abs(step) : 1));
82
+ setDraft(String(next)); setLocalError(''); onValueChange(next);
83
+ };
84
+ return <div className="rhine-number-field"><TextField label={label} id={id} name={name} hint={hint} required={required} readOnly={readOnly} inputMode="decimal" role="spinbutton" aria-valuenow={numericDraft !== null && Number.isFinite(numericDraft) ? numericDraft : undefined} aria-valuemin={min} aria-valuemax={max}
85
+ value={draft} disabled={disabled} error={error || localError || boundsError} onChange={event => { if (!blocked) { setDraft(event.target.value); setLocalError(''); } }} onBlur={commit}
86
+ onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
87
+ onKeyDown={event => { if (event.nativeEvent.isComposing || composing.current || blocked) return; if (event.key === 'Escape' && (draft !== (value === null ? '' : String(value)) || localError)) { event.preventDefault(); event.stopPropagation(); setDraft(value === null ? '' : String(value)); setLocalError(''); } if (event.key === 'Enter') { event.preventDefault(); commit(); } if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); move(event.key === 'ArrowUp' ? 1 : -1); } }} />
88
+ {controls && <div className="rhine-inline"><Button variant="secondary" disabled={blocked || (numericDraft !== null && min !== undefined && numericDraft <= min)} onMouseDown={event => event.preventDefault()} onClick={() => move(-1)} aria-label={`减少${label}`}>−</Button><Button variant="secondary" disabled={blocked || (numericDraft !== null && max !== undefined && numericDraft >= max)} onMouseDown={event => event.preventDefault()} onClick={() => move(1)} aria-label={`增加${label}`}>+</Button></div>}
89
+ </div>;
90
+ }
91
+
92
+ export interface RemoteMultiSelectProps {
93
+ label: string; query: string; onQueryChange: (query: string) => void;
94
+ value: Choice[]; onValueChange: (value: Choice[]) => void; options: Choice[];
95
+ loading?: boolean; error?: string; disabled?: boolean;
96
+ readOnly?: boolean; clearable?: boolean; closeOnSelect?: boolean; clearQueryOnSelect?: boolean;
97
+ placeholder?: string; hint?: string; name?: string;
98
+ }
99
+ export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, ...props }: RemoteMultiSelectProps) {
100
+ const inputId = useId();
101
+ const focusInput = () => document.getElementById(inputId)?.focus();
102
+ const remove = (key: string) => {
103
+ if (props.disabled || readOnly || value.find(item => item.value === key)?.disabled) return;
104
+ onValueChange(value.filter(item => item.value !== key)); focusInput();
105
+ };
106
+ return <div className="rhine-multi-select">
107
+ <Combobox {...props} id={inputId} readOnly={readOnly} selectedValues={value.map(item => item.value)} closeOnSelect={closeOnSelect} onEmptyBackspace={() => { const last = [...value].reverse().find(item => !item.disabled); if (last) remove(last.value); }} options={options.map(option => ({ ...option, disabled: option.disabled || value.some(item => item.value === option.value) }))} value={null} onValueChange={option => { const original = options.find(item => item.value === option.value); if (original && !value.some(item => item.value === original.value)) { onValueChange([...value, original]); if (clearQueryOnSelect) props.onQueryChange(''); } }} />
108
+ {name && value.map(item => <input key={item.value} type="hidden" name={name} value={item.value} disabled={props.disabled} />)}
109
+ <ul className="rhine-multi-select__tags" aria-label={`${props.label}已选项`}>{value.map(item => <li key={item.value}><span>{item.label}</span><Button variant="quiet" disabled={props.disabled || readOnly || item.disabled} aria-label={`移除${item.label}`} onClick={() => remove(item.value)}>×</Button></li>)}</ul>
110
+ {clearable && <Button variant="quiet" disabled={props.disabled || readOnly || !value.some(item => !item.disabled)} onClick={() => { onValueChange(value.filter(item => item.disabled)); focusInput(); }}>清空选择</Button>}
111
+ </div>;
112
+ }
113
+
114
+ export interface TreeNode { id: string; label: string; disabled?: boolean; selectable?: boolean; children?: TreeNode[] }
115
+ export interface TreeSelectProps {
116
+ label: string; nodes: TreeNode[]; value: string[]; onValueChange: (value: string[]) => void;
117
+ expandedKeys: string[]; onExpandedChange: (keys: string[]) => void; multiple?: boolean;
118
+ disabled?: boolean; loading?: boolean; error?: string;
119
+ readOnly?: boolean; name?: string;
120
+ }
121
+ export function TreeSelect({ label, nodes, value, onValueChange, expandedKeys, onExpandedChange, multiple = false, disabled = false, loading, error, readOnly = false, name }: TreeSelectProps) {
122
+ const root = useRef<HTMLDivElement>(null), id = useId();
123
+ const [active, setActive] = useState<string>();
124
+ const hadFocus = useRef(false), typeahead = useRef({ text: '', time: 0 });
125
+ const visible: { node: TreeNode; parent?: string }[] = [];
126
+ const collect = (items: TreeNode[], parent?: string) => items.forEach(node => { visible.push({ node, parent }); if (expandedKeys.includes(node.id)) collect(node.children ?? [], node.id); });
127
+ collect(nodes);
128
+ const parents = new Map<string, string | undefined>();
129
+ const collectParents = (items: TreeNode[], parent?: string) => items.forEach(node => { parents.set(node.id, parent); collectParents(node.children ?? [], node.id); });
130
+ collectParents(nodes);
131
+ let ancestor = active;
132
+ while (ancestor && !visible.some(item => item.node.id === ancestor)) ancestor = parents.get(ancestor);
133
+ const current = ancestor || visible.find(item => value.includes(item.node.id))?.node.id || visible[0]?.node.id;
134
+ const itemId = (key: string) => `${id}-item-${encodeURIComponent(key)}`;
135
+ const focus = (key?: string) => { if (!key) return; setActive(key); document.getElementById(itemId(key))?.focus(); };
136
+ useLayoutEffect(() => {
137
+ if (hadFocus.current && active !== current && current) document.getElementById(itemId(current))?.focus();
138
+ }, [active, current, id]);
139
+ const expand = (node: TreeNode, next: boolean) => { if (!disabled) onExpandedChange(next ? [...new Set([...expandedKeys, node.id])] : expandedKeys.filter(key => key !== node.id)); };
140
+ const canChoose = (node: TreeNode) => !disabled && !readOnly && !loading && !error && !node.disabled && node.selectable !== false;
141
+ const choose = (node: TreeNode) => { if (!canChoose(node)) return; onValueChange(multiple ? value.includes(node.id) ? value.filter(key => key !== node.id) : [...value, node.id] : [node.id]); };
142
+ const keyDown = (event: KeyboardEvent, node: TreeNode) => {
143
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
144
+ const index = visible.findIndex(item => item.node.id === node.id), expanded = expandedKeys.includes(node.id);
145
+ if (['ArrowDown','ArrowUp','ArrowLeft','ArrowRight','Home','End',' ','Enter'].includes(event.key)) { event.preventDefault(); event.stopPropagation(); }
146
+ if (event.key === 'ArrowDown') focus(visible[Math.min(visible.length - 1, index + 1)]?.node.id);
147
+ if (event.key === 'ArrowUp') focus(visible[Math.max(0, index - 1)]?.node.id);
148
+ if (event.key === 'Home') focus(visible[0]?.node.id);
149
+ if (event.key === 'End') focus(visible.at(-1)?.node.id);
150
+ if (event.key === 'ArrowRight' && node.children?.length) { if (!expanded) expand(node, true); else focus(node.children[0].id); }
151
+ if (event.key === 'ArrowLeft') { if (expanded && node.children?.length) expand(node, false); else focus(visible[index]?.parent); }
152
+ if (event.key === ' ' || event.key === 'Enter') choose(node);
153
+ if (event.key === '*' && !disabled) { event.preventDefault(); event.stopPropagation(); onExpandedChange([...new Set([...expandedKeys, ...visible.filter(item => item.parent === visible[index]?.parent && item.node.children?.length).map(item => item.node.id)])]); }
154
+ if (multiple && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
155
+ event.preventDefault(); event.stopPropagation();
156
+ if (disabled || readOnly || loading || error) return;
157
+ const eligible = visible.filter(item => canChoose(item.node)).map(item => item.node.id);
158
+ onValueChange(eligible.every(key => value.includes(key)) ? value.filter(key => !eligible.includes(key)) : [...new Set([...value, ...eligible])]);
159
+ } else if (event.key.length === 1 && event.key !== ' ' && event.key !== '*' && !event.ctrlKey && !event.metaKey && !event.altKey) {
160
+ event.stopPropagation();
161
+ const now = Date.now();
162
+ const previous = now - typeahead.current.time < 500 ? typeahead.current.text : '';
163
+ const typed = previous + event.key.toLocaleLowerCase();
164
+ typeahead.current = { text: typed, time: now };
165
+ const search = [...typed].every(character => character === typed[0]) ? typed[0] : typed;
166
+ const ordered = [...visible.slice(index + 1), ...visible.slice(0, index + 1)];
167
+ const match = ordered.find(item => item.node.label.toLocaleLowerCase().startsWith(search));
168
+ if (match) { event.preventDefault(); event.stopPropagation(); focus(match.node.id); }
169
+ }
170
+ };
171
+ const renderNodes = (items: TreeNode[]) => items.map(node => {
172
+ const expanded = expandedKeys.includes(node.id);
173
+ return <div role="treeitem" key={node.id} id={itemId(node.id)} tabIndex={current === node.id ? 0 : -1} aria-label={node.label} aria-expanded={node.children?.length ? expanded : undefined} aria-selected={!multiple && node.selectable !== false ? value.includes(node.id) : undefined} aria-checked={multiple && node.selectable !== false ? value.includes(node.id) : undefined} aria-disabled={disabled || loading || !!error || node.disabled || undefined}
174
+ onFocus={event => { if (event.target === event.currentTarget) setActive(node.id); }} onKeyDown={event => keyDown(event, node)}>
175
+ <div className="rhine-tree__row" data-selected={value.includes(node.id)} onClick={() => { focus(node.id); choose(node); }}>
176
+ {node.children?.length ? <button type="button" tabIndex={-1} disabled={disabled} aria-label={`${expanded ? '收起' : '展开'}${node.label}`} onClick={event => { event.stopPropagation(); focus(node.id); expand(node, !expanded); }}>{expanded ? '−' : '+'}</button> : <span className="rhine-tree__spacer" />}
177
+ {node.selectable !== false && <span aria-hidden="true">{value.includes(node.id) ? '✓' : '○'}</span>}<span>{node.label}</span>{node.disabled && <small>不可选</small>}
178
+ </div>{expanded && !!node.children?.length && <div role="group">{renderNodes(node.children)}</div>}
179
+ </div>;
180
+ });
181
+ return <div className="rhine-tree" ref={root} onFocusCapture={() => { hadFocus.current = true; }} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) hadFocus.current = false; }}>
182
+ {name && value.map(key => <input key={key} type="hidden" name={name} value={key} disabled={disabled} />)}
183
+ <div role="tree" aria-label={label} aria-multiselectable={multiple || undefined} aria-readonly={readOnly || undefined} aria-busy={loading} aria-disabled={disabled || undefined}>{renderNodes(nodes)}</div>{(loading || error || !nodes.length) && <p role={error ? 'alert' : 'status'}>{loading ? '正在读取…' : error || '暂无节点'}</p>}</div>;
184
+ }
@@ -0,0 +1,71 @@
1
+ import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
+ import { Button } from './components';
3
+ import { Drawer } from './Overlays';
4
+ import { Background, type BackgroundDecoration } from './Background';
5
+
6
+ export interface ShellPanel {
7
+ label: string; content: ReactNode; open: boolean; onOpenChange: (open: boolean) => void;
8
+ drawerOpen?: boolean; defaultDrawerOpen?: boolean; onDrawerOpenChange?: (open: boolean) => void;
9
+ width?: number; breakpoint?: number;
10
+ }
11
+ export interface AppShellProps {
12
+ layout: 'single' | 'double-left' | 'left-right';
13
+ navigation: ShellPanel; secondary?: ShellPanel;
14
+ header?: ReactNode; children: ReactNode; label?: string; decoration?: BackgroundDecoration;
15
+ className?: string; style?: CSSProperties;
16
+ }
17
+
18
+ function Panel({ panel, overlay, side, id, drawerOpen, setDrawerOpen }: { panel: ShellPanel; overlay: boolean; side: 'left' | 'right'; id: string; drawerOpen: boolean; setDrawerOpen: (open: boolean) => void }) {
19
+ const floating = overlay || drawerOpen;
20
+ return <div className="rhine-shell__panel-control">
21
+ <Drawer title={panel.label} description="关闭后返回当前任务。" side={side} open={drawerOpen} onOpenChange={setDrawerOpen}
22
+ trigger={<Button id={`${id}-toggle`} variant="secondary" aria-controls={floating ? undefined : id} aria-expanded={floating ? drawerOpen : panel.open}
23
+ onClick={event => { if (!floating) { event.preventDefault(); panel.onOpenChange(!panel.open); } }}>{floating ? '' : panel.open ? '收起' : '展开'}{panel.label}</Button>}>{panel.content}</Drawer>
24
+ </div>;
25
+ }
26
+
27
+ export function AppShell({ layout, navigation, secondary, header, children, label = '应用工作区', decoration = 'auto', className = '', style }: AppShellProps) {
28
+ const ref = useRef<HTMLDivElement>(null);
29
+ const focusedPanel = useRef<'nav' | 'secondary' | null>(null);
30
+ const [width, setWidth] = useState(0);
31
+ const [localNavigationDrawer, setLocalNavigationDrawer] = useState(navigation.defaultDrawerOpen ?? false), [localSecondaryDrawer, setLocalSecondaryDrawer] = useState(secondary?.defaultDrawerOpen ?? false);
32
+ const navigationDrawer = navigation.drawerOpen ?? localNavigationDrawer, secondaryDrawer = secondary?.drawerOpen ?? localSecondaryDrawer;
33
+ const setNavigationDrawer = (next: boolean) => { if (navigation.drawerOpen === undefined) setLocalNavigationDrawer(next); navigation.onDrawerOpenChange?.(next); };
34
+ const setSecondaryDrawer = (next: boolean) => { if (secondary?.drawerOpen === undefined) setLocalSecondaryDrawer(next); secondary?.onDrawerOpenChange?.(next); };
35
+ const id = useId();
36
+ useLayoutEffect(() => {
37
+ if (!ref.current) return;
38
+ // CSS layout pixels stay stable under zoom and transforms; visual bounds do not.
39
+ const measure = () => setWidth(ref.current?.clientWidth ?? 0);
40
+ measure();
41
+ const observer = new ResizeObserver(measure);
42
+ observer.observe(ref.current);
43
+ return () => observer.disconnect();
44
+ }, []);
45
+ const navOverlay = width < (navigation.breakpoint ?? 760), secondaryOverlay = width < (secondary?.breakpoint ?? 1120);
46
+ const hasSecondary = layout !== 'single' && !!secondary;
47
+ useLayoutEffect(() => {
48
+ const suffix = focusedPanel.current;
49
+ if (suffix) {
50
+ const pane = document.getElementById(`${id}-${suffix}`);
51
+ if (pane?.hidden && (document.activeElement === document.body || pane.contains(document.activeElement))) {
52
+ (document.getElementById(`${id}-${suffix}-toggle`) ?? document.getElementById(`${id}-nav-toggle`))?.focus();
53
+ focusedPanel.current = null;
54
+ }
55
+ }
56
+ }, [id, navOverlay, secondaryOverlay, hasSecondary, navigation.open, secondary?.open]);
57
+ return <div className={`rhine-shell ${className}`} style={style} ref={ref} role="region" aria-label={label} data-layout={layout}
58
+ onFocusCapture={event => { focusedPanel.current = document.getElementById(`${id}-nav`)?.contains(event.target) ? 'nav' : document.getElementById(`${id}-secondary`)?.contains(event.target) ? 'secondary' : null; }}
59
+ onBlurCapture={event => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) focusedPanel.current = null; }}>
60
+ <header className="rhine-shell__header">
61
+ <Panel panel={navigation} overlay={navOverlay} side="left" id={`${id}-nav`} drawerOpen={navigationDrawer} setDrawerOpen={setNavigationDrawer} />
62
+ {hasSecondary && <Panel panel={secondary} overlay={secondaryOverlay} side={layout === 'left-right' ? 'right' : 'left'} id={`${id}-secondary`} drawerOpen={secondaryDrawer} setDrawerOpen={setSecondaryDrawer} />}
63
+ {header}
64
+ </header>
65
+ <div className="rhine-shell__body">
66
+ <aside id={`${id}-nav`} className="rhine-shell__nav" style={{ flexBasis: navigation.width }} aria-label={navigation.label} hidden={navOverlay || navigationDrawer || !navigation.open}>{!navigationDrawer && navigation.content}</aside>
67
+ <aside id={`${id}-secondary`} className="rhine-shell__secondary" style={{ flexBasis: secondary?.width }} aria-label={secondary?.label} hidden={!hasSecondary || secondaryOverlay || secondaryDrawer || !secondary?.open}>{hasSecondary && !secondaryDrawer && secondary?.content}</aside>
68
+ <Background className="rhine-shell__content" decoration={decoration} tabIndex={0} role="region" aria-label="正文">{children}</Background>
69
+ </div>
70
+ </div>;
71
+ }
@@ -0,0 +1,29 @@
1
+ import type { ComponentPropsWithRef, CSSProperties } from 'react';
2
+
3
+ export type BackgroundRole = 'canvas' | 'content' | 'sidebar' | 'surface' | 'overlay' | 'media';
4
+ export type BackgroundDecoration = 'auto' | 'theme' | 'none';
5
+
6
+ export interface BackgroundAppearance {
7
+ color?: CSSProperties['backgroundColor'];
8
+ textColor?: CSSProperties['color'];
9
+ texture?: CSSProperties['backgroundImage'];
10
+ textureSize?: CSSProperties['backgroundSize'];
11
+ }
12
+
13
+ export interface BackgroundProps extends ComponentPropsWithRef<'div'> {
14
+ roleTone?: BackgroundRole;
15
+ decoration?: BackgroundDecoration;
16
+ appearance?: BackgroundAppearance;
17
+ }
18
+
19
+ /** Paints on this element, without a positioning context or an intercepting overlay. */
20
+ export function Background({ roleTone = 'content', decoration = 'auto', appearance, style, className = '', ...props }: BackgroundProps) {
21
+ const paint = {
22
+ '--rhine-background-color': appearance?.color,
23
+ '--rhine-background-foreground': appearance?.textColor,
24
+ '--rhine-background-image': appearance?.texture,
25
+ '--rhine-background-size': appearance?.textureSize,
26
+ ...style,
27
+ } as CSSProperties;
28
+ return <div {...props} style={paint} className={`rhine-background ${className}`} data-tone={roleTone} data-decoration={decoration} />;
29
+ }
@@ -2,6 +2,10 @@
2
2
 
3
3
  本文件定义[莱茵设计](../README.md)的常用组件契约。先固定契约和组件展台,再验证;默认优先复用本目录,页面先改设计稿,再按授权接入App。具体业务由宿主定义,不进入通用契约。
4
4
 
5
+ ## 导航与流程能力(0.4.0)
6
+
7
+ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimeline、TimelineLegend 的 API、键盘与场景契约见[交互选型](../docs/INTERACTION_PATTERNS.md)。0.4.0 共48个导出组件;下表保留原24个扩展组件的契约。0.5.0 新增11个组件及DataTable/Pagination扩展,见[应用组件契约](../docs/APPLICATION_COMPONENTS.md)。
8
+
5
9
  ## 范围与契约
6
10
 
7
11
  | 组件 | 最小职责与边界 |
@@ -40,7 +44,7 @@
40
44
 
41
45
  ## 接入契约
42
46
 
43
- 统一从 套件的`components/index.ts` 导入,并在页面根部使用 `.rhine-theme`。主题根不要放在带transform、overflow裁剪或contain的局部面板;浮层挂到最近主题根,保持token和减少动态偏好。Dialog、Popover、Menu的trigger与Tooltip的children必须是单个可聚焦、可接收ref和事件的元素;使用Button/IconButton即可。Dialog受控时必须同时传open与onOpenChange。
47
+ 统一从 套件的`components/index.ts` 导入,并在页面根部使用 `.rhine-theme`。主题根不要放在带transform、overflow裁剪或contain的局部面板;浮层挂到最近主题根,保持token和减少动态偏好。Dialog、Popover、Menu的trigger与Tooltip的children必须是单个可聚焦、可接收ref和事件的元素;使用Button/IconButton即可。Dialog受控时必须同时传open与onOpenChange。分栏的sidePanel可传ReactNode,或传`closeButton => ReactNode`将关闭按钮放进侧栏导航;函数模式必须展示传入按钮,不能复制第二个关闭入口。
44
48
 
45
49
  ```tsx
46
50
  <Stack gap={4}>