@linhey/rhine-design 0.4.0 → 0.6.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 (69) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +24 -7
  3. package/components/AdvancedForms.tsx +184 -0
  4. package/components/AppShell.tsx +71 -0
  5. package/components/Background.tsx +29 -0
  6. package/components/COMMON_COMPONENTS.md +2 -2
  7. package/components/Charts.tsx +94 -0
  8. package/components/Data.tsx +98 -18
  9. package/components/Forms.tsx +14 -8
  10. package/components/MOTION.md +9 -1
  11. package/components/Media.tsx +82 -0
  12. package/components/OrbitBackdrop.tsx +33 -0
  13. package/components/Overlays.tsx +4 -3
  14. package/components/README.md +12 -1
  15. package/components/StatusTimeline.tsx +50 -12
  16. package/components/background.css +43 -0
  17. package/components/charts.css +27 -0
  18. package/components/common.css +5 -5
  19. package/components/data.css +6 -0
  20. package/components/drawer-placement.css +4 -0
  21. package/components/extensions.css +70 -0
  22. package/components/index.ts +14 -2
  23. package/components/media-parity.css +3 -0
  24. package/components/orbit.css +42 -0
  25. package/components/orbitRenderer.ts +230 -0
  26. package/components/patterns.css +1 -1
  27. package/components/styles.css +5 -1
  28. package/components/theme-arknights.css +10 -5
  29. package/components/theme-neo-brutalism.css +10 -4
  30. package/components/timeline.css +2 -2
  31. package/components/tokens.css +43 -0
  32. package/docs/ANTD_MUI_AUDIT.md +38 -0
  33. package/docs/APPLICATION_COMPONENTS.md +175 -0
  34. package/docs/DESIGN_SYSTEM_AUDIT.md +2 -0
  35. package/docs/ISSUE_4_VALIDATION.md +21 -0
  36. package/docs/ISSUE_VALIDATION.md +52 -0
  37. package/docs/ORBIT_BACKDROP.md +81 -0
  38. package/docs/REFERENCE_RESEARCH.md +2 -0
  39. package/docs/THEMING.md +41 -0
  40. package/docs/assets/application-shell.png +0 -0
  41. package/docs/assets/issue4-shell.png +0 -0
  42. package/docs/assets/media-viewer-mobile.png +0 -0
  43. package/docs/assets/monitoring-desktop.png +0 -0
  44. package/docs/assets/orbit-backdrop.png +0 -0
  45. package/docs/audits/DATA_ANTD_MUI.md +75 -0
  46. package/docs/audits/FORMS_ANTD_MUI.md +88 -0
  47. package/docs/audits/MONITORING_ANTD_MUI.md +44 -0
  48. package/docs/audits/SHELL_MEDIA_ANTD_MUI.md +91 -0
  49. package/package.json +1 -1
  50. package/preview/BackgroundGallery.tsx +46 -0
  51. package/preview/ExtensionsGallery.tsx +95 -0
  52. package/preview/GalleryShell.tsx +4 -3
  53. package/preview/MonitoringGallery.tsx +27 -0
  54. package/preview/OrbitGallery.tsx +47 -0
  55. package/preview/backgrounds.css +10 -0
  56. package/preview/extensions.css +6 -0
  57. package/preview/main.tsx +12 -1
  58. package/skills/rhine-design/SKILL.md +24 -3
  59. package/skills/rhine-design/references/API_MAP.md +6 -0
  60. package/skills/rhine-design/references/TASK_MAP.md +3 -1
  61. package/skills/rhine-design/references/integration.md +4 -2
  62. package/tests/Background.test.tsx +22 -0
  63. package/tests/DataParity.test.tsx +148 -0
  64. package/tests/Extensions.test.tsx +145 -0
  65. package/tests/FormParity.test.tsx +185 -0
  66. package/tests/Monitoring.test.tsx +38 -0
  67. package/tests/MonitoringParity.test.tsx +125 -0
  68. package/tests/OrbitBackdrop.test.tsx +183 -0
  69. package/tests/ShellMediaParity.test.tsx +180 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # 版本记录
2
2
 
3
+ ## 0.6.0(2026-10-24)
4
+
5
+ - 对应 [#8](https://github.com/linhay/rhine-design/issues/8):新增 `OrbitBackdrop`,将授权移交的轨道艺术维护为公共组件,保留 420 个环节点、185 条空间曲线、箭头、光晕与透视;公共组件增至 60 个,无新增运行时依赖。
6
+ - 内置暂停 / 继续,遵循系统、显式及祖先减少动态偏好;窄窗、页面隐藏、离屏与零尺寸停帧,恢复沿冻结时间继续,卸载清理资源;Canvas 不可用时保留静态艺术与内容。逻辑尺寸使用 client size,保持 CSS 缩放后的描线和静态重绘一致。
7
+ - 固定深色艺术仅调整继承文字和 Logo,不覆写宿主控件的主题 tokens;表单、按钮及 Surface 保持各主题前景与背景成对。新增 `#orbit` 真实展台、[轨道背景契约](docs/ORBIT_BACKDROP.md)、生命周期测试与三主题组合示例。
8
+ - 保留 0.5.0 的全部应用组件、受控状态与 Ant Design / MUI 契约改进。`OrbitBackdrop` 自 0.6.0 提供,0.5.0 及以前不包含此组件;建议固定精确版本并提交 lockfile。
9
+
10
+ ## 0.5.0(2026-10-24)
11
+
12
+ - 完成 [Ant Design / MUI 逐项对标](docs/ANTD_MUI_AUDIT.md):补齐表单只读/校验/键盘、连续多选、树字符导航、数据表单选/排序清除/自动固定列、分页首末页/边界焦点、受控左右抽屉、媒体方向键/首尾焦点、图表受控逐点查看与格式化、健康历史稳定 ID 与受控详情。RemoteMultiSelect 默认连续选择,`closeOnSelect=true` 可恢复选完关闭;原单选 Combobox 默认不变。无新增运行时依赖。
13
+ - 发布审阅修正:数字未编辑时不重复提交,未指定 precision 时保留输入精度;加载中的媒体预览保持挂载以接收原生加载/失败事件;空图表不显示不可操作提示,时间轴移除不适用的 ARIA 属性;修正橙趣表格密度/表头主题变量、行悬停底色及仅 CSS 主题根的派生变量。
14
+
15
+ - 对应 #1–#6:新增 Background/AppShell、MediaCard/MediaViewer、DateField/DateRangePicker/NumberField/TreeSelect/RemoteMultiSelect,加上 TrendChart/StackedBar,共 11 个公共组件。
16
+ - DataTable 增加受控跨页选择、禁选/混合态、层级展开与详情、密度和固定表头/列;Pagination 增加页大小意图。
17
+ - 新增三主题背景、表单、数据与中性媒体 tokens;提供六组真实 React 展台及[应用组件契约](docs/APPLICATION_COMPONENTS.md)。宿主继续负责请求、时区、导出、下载、虚拟列表和持久化。
18
+ - StatusTimeline 增加开始/结束、原因、计数及统计口径的结构化详情,使用可避让视口的 Popover;新增柱形趋势、缺失断线和堆叠占比,未知值不补零,配套文字替代与空/陈旧/错误状态。
19
+ - 本节 API 自 0.5.0 提供;0.4.0 不含这些能力。建议固定版本并提交 lockfile。
20
+
21
+ - 补齐 Background 六类场景的底色、文字与纹理配置,新增局部 `appearance` 覆盖。`decoration` 默认 `auto`,仅画布使用主题纹理;正文与 AppShell 默认纯色,原有显式 `theme` / `none` 保留。Surface / Fieldset 与主要浮层接入语义底色,遮罩独立配置。新增展台 `#backgrounds`,包含实际弹窗、局部覆盖与纹理切换,仅展示当前主题的场景效果,跟随主题选择器更新。已有正文需要纹理时显式传 `decoration="theme"`。
22
+
3
23
  ## 0.4.0
4
24
 
5
25
  - 新增 `@linhey/rhine-design/theme`:`ThemeProvider`、`useTheme`、`themes`、`ThemeName` 与 `ThemeProviderProps`,自动加载三套内置主题,支持受控/非受控切换及并列 Provider;嵌套覆盖暂不支持。切换保留组件、表单与浮层状态,偏好存储由宿主管理。展台复用公共 API;主题 CSS 中的展台布局和 HUD/扫描/开机装饰移至 `preview/theme-*.css`。补齐接入文档及 API/展台回归测试。
package/README.md CHANGED
@@ -16,10 +16,10 @@
16
16
  <a href="CHANGELOG.md">版本记录</a>
17
17
  </p>
18
18
 
19
- Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、48 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
19
+ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、60 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
20
20
 
21
21
  - **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
22
- - **48 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
22
+ - **60 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
23
23
  - **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
24
24
  - **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
25
25
 
@@ -27,6 +27,12 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
27
27
 
28
28
  基于 React 与 Radix UI,采用 [Apache-2.0](LICENSE) 许可证。
29
29
 
30
+ **0.5.0 新增能力**:新增背景与多侧栏、复杂表单、受控数据表、媒体与监控图表,共 59 个组件。API、最小接入与版本边界见[应用组件契约](docs/APPLICATION_COMPONENTS.md)。npm 0.4.0 不含这些能力。
31
+
32
+ **0.6.0 新增能力**:新增 `OrbitBackdrop` 轨道艺术组件,提供暂停、减少动态与静态降级,总数增至 60。安装前核对[版本记录](CHANGELOG.md)与 registry;在线展台跟随 main。
33
+
34
+ 本轮新增 API 与交互已逐项核对 Ant Design / MUI,修复及明确保留的差异见[对标清单](docs/ANTD_MUI_AUDIT.md)。
35
+
30
36
  <a id="quick-start"></a>
31
37
 
32
38
  ## 快速接入
@@ -36,7 +42,7 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
36
42
  在你的 React 项目中安装:
37
43
 
38
44
  ```sh
39
- npm install @linhey/rhine-design@0.4.0
45
+ npm install --save-exact @linhey/rhine-design@0.6.0
40
46
  ```
41
47
 
42
48
  包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
@@ -65,6 +71,14 @@ export function Welcome() {
65
71
 
66
72
  入口自动加载样式,用 `.rhine-theme` 包裹页面即可使用默认主题。布局和颜色变量使用 `--rhine-*` 前缀;完整定义见 [tokens.css](components/tokens.css)。
67
73
 
74
+ ### 背景组件(0.5.0)
75
+
76
+ 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)。
77
+
78
+ ### 轨道背景(0.6.0)
79
+
80
+ OrbitBackdrop 承接授权移交的轨道动画,保留环节点、曲线、箭头、光晕与透视,并提供暂停、减少动态和离屏停帧。它是纯装饰,不表示加载或业务进度。契约与接入见[轨道背景](docs/ORBIT_BACKDROP.md),真实展台见[轨道星空](https://linhay.github.io/rhine-design/#orbit)。
81
+
68
82
  ### 一键切换主题(0.4.0)
69
83
 
70
84
  展台右上角可切换并记忆主题。新增 **橙趣 · 粗描边**:浅灰蓝点阵背景、米白圆角卡片、黑色粗边、向右下偏移的硬阴影与亮橙主按钮。打开 [在线订阅管理示例](https://linhay.github.io/rhine-design/?theme=neo-brutalism#subscriptions),体验卡片详情、管理菜单与演示表单;本地开发访问 `http://127.0.0.1:18309/?theme=neo-brutalism#subscriptions`。
@@ -91,6 +105,8 @@ export function App() {
91
105
 
92
106
  完整变量、类型标签、交互与减少动态规则见 [橙趣主题规范](docs/THEME_NEO_BRUTALISM.md)。现有 CSS-only 用法仍支持;展台装饰独立放在 `preview/theme-*.css`。
93
107
 
108
+ 0.5.0 新增六类[场景背景配置](docs/THEMING.md#场景背景050):画布、正文、侧栏、卡片表单、浮层和媒体。正文默认无纹理,支持局部配色与纹理覆盖;本地展台 `http://127.0.0.1:18309/#backgrounds` 可查看当前主题的六类场景,并通过主题选择器切换。
109
+
94
110
  类名、CSS 变量、动画名称与动态设置统一使用 `rhine-` 命名空间。升级到 0.3.0 时请同步迁移主题根、自定义样式与动效属性,具体见 [迁移说明](CHANGELOG.md#030)。
95
111
 
96
112
  ### 兼容范围
@@ -108,14 +124,14 @@ export function App() {
108
124
  | --- | --- |
109
125
  | 品牌与文字 | Logo · Text |
110
126
  | 操作 | Button · Icon · IconButton · Link · CopyButton |
111
- | 布局 | Surface · Toolbar · Divider · Stack · Inline · Grid |
112
- | 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard |
127
+ | 布局与背景 | Surface · Toolbar · Divider · Stack · Inline · Grid · Background² · AppShell² · OrbitBackdrop³ |
128
+ | 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard · DateField² · DateRangePicker² · NumberField² · TreeSelect² · RemoteMultiSelect² |
113
129
  | 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
114
- | 数据 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ |
130
+ | 数据与媒体 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ · TrendChart² · StackedBar² · MediaCard² · MediaViewer² |
115
131
  | 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
116
132
  | 状态与反馈 | Badge · Notice · Toast · Progress · Skeleton · EmptyState |
117
133
 
118
- ¹ 自 0.4.0 提供。
134
+ ¹ 自 0.4.0 提供。² 自 0.5.0 提供。³ 自 0.6.0 提供。
119
135
 
120
136
  参数、状态和组合示例见[组件 API](components/README.md)与[常用组件契约](components/COMMON_COMPONENTS.md)。
121
137
 
@@ -133,6 +149,7 @@ export function App() {
133
149
  | 品牌几何与 Logo | [Logo 契约](components/README.md#品牌标志) | [品牌标志](https://linhay.github.io/rhine-design/#brand) |
134
150
  | 动效、交互与减少动态 | [Motion](components/MOTION.md) | [动效实验室](https://linhay.github.io/rhine-design/#motion) |
135
151
  | 启动动画与就绪衔接 | [Startup](components/STARTUP.md) | [启动动画](https://linhay.github.io/rhine-design/#startup) |
152
+ | 轨道装饰、暂停与静态降级 | [OrbitBackdrop](docs/ORBIT_BACKDROP.md) | [轨道星空](https://linhay.github.io/rhine-design/#orbit) |
136
153
  | 场景与交互选型 | [使用规范](docs/INTERACTION_PATTERNS.md) | [场景指南](https://linhay.github.io/rhine-design/#patterns) |
137
154
  | 导航与表单流程 | [组件契约](docs/INTERACTION_PATTERNS.md#新组件契约) | [导航](https://linhay.github.io/rhine-design/#navigation) · [表单](https://linhay.github.io/rhine-design/#form-workflow) |
138
155
  | 系统覆盖与演进方向 | [组件系统对照](docs/DESIGN_SYSTEM_AUDIT.md) | — |
@@ -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,9 +2,9 @@
2
2
 
3
3
  本文件定义[莱茵设计](../README.md)的常用组件契约。先固定契约和组件展台,再验证;默认优先复用本目录,页面先改设计稿,再按授权接入App。具体业务由宿主定义,不进入通用契约。
4
4
 
5
- ## 新增导航与流程能力(main,未发布)
5
+ ## 导航与流程能力(0.4.0)
6
6
 
7
- SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimeline、TimelineLegend 的 API、键盘与场景契约见[交互选型](../docs/INTERACTION_PATTERNS.md)。main 共48个导出组件;下表保留原24个扩展组件的契约。
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
8
 
9
9
  ## 范围与契约
10
10
 
@@ -0,0 +1,94 @@
1
+ import { useId, useState, type CSSProperties, type ReactNode } from 'react';
2
+ import { DataTable } from './Data';
3
+ import { EmptyState, Notice } from './components';
4
+
5
+ export interface ChartPoint { id: string; label: string; value: number | null }
6
+ interface ChartContext { label: string; unit: string; rangeLabel: string; description: string; stale?: string; error?: string; loading?: boolean; valueFormatter?: (value: number | null) => string }
7
+ export interface TrendChartProps extends ChartContext {
8
+ points: ChartPoint[]; kind?: 'bar' | 'line';
9
+ activePointId?: string | null; defaultActivePointId?: string; onActivePointChange?: (id: string | null) => void;
10
+ }
11
+ const known = (value: number | null): value is number => value !== null && Number.isFinite(value);
12
+
13
+ function ChartFrame({ label, unit, rangeLabel, description, stale, error, loading, children }: ChartContext & { children: ReactNode }) {
14
+ return <figure className="rhine-chart" aria-label={label} aria-busy={loading}>
15
+ <figcaption><strong>{label}</strong><span>{rangeLabel} · 单位:{unit}</span><p>{description}</p></figcaption>
16
+ {error && <Notice title="图表读取失败" tone="error">{error} · 已有数据仅供参考。</Notice>}
17
+ {stale && <Notice title="数据已陈旧" tone="warning">{stale}</Notice>}
18
+ {loading && <p role="status">正在读取,保留已有数据。</p>}{children}
19
+ </figure>;
20
+ }
21
+
22
+ /** The host provides bucket order, timestamps, units, and aggregation semantics. */
23
+ export function TrendChart({ points, kind = 'bar', activePointId, defaultActivePointId, onActivePointChange, ...context }: TrendChartProps) {
24
+ const id = useId(), finite = points.map(point => point.value).filter(known);
25
+ const [localActive, setLocalActive] = useState<string | null>(defaultActivePointId ?? null);
26
+ const [focused, setFocused] = useState(false);
27
+ const active = activePointId === undefined ? localActive : activePointId;
28
+ const activeIndex = points.findIndex(point => point.id === active), activePoint = points[activeIndex];
29
+ const select = (next: string | null) => { if (activePointId === undefined) setLocalActive(next); if (next !== active) onActivePointChange?.(next); };
30
+ const format = (value: number | null) => context.valueFormatter ? context.valueFormatter(known(value) ? value : null) : known(value) ? String(value) : '未知';
31
+ const minimum = finite.reduce((min, value) => Math.min(min, value), 0), maximum = finite.reduce((max, value) => Math.max(max, value), 0);
32
+ // Normalize first so opposite finite extremes cannot overflow the coordinate span.
33
+ const magnitude = Math.max(Math.abs(minimum), Math.abs(maximum), 1), span = (maximum / magnitude - minimum / magnitude) || 1;
34
+ const x = (index: number) => (index + 0.5) * 600 / Math.max(1, points.length);
35
+ const y = (value: number) => 170 - (value / magnitude - minimum / magnitude) / span * 160;
36
+ let connected = false;
37
+ const path = points.map((point, index) => {
38
+ if (!known(point.value)) { connected = false; return ''; }
39
+ const command = connected ? 'L' : 'M'; connected = true;
40
+ return `${command}${x(index)},${y(point.value)}`;
41
+ }).join(' ');
42
+ return <ChartFrame {...context}>
43
+ {!finite.length ? <EmptyState title={points.length ? '所有采样值均未知' : '暂无采样'} description="缺失值不会补零。" /> : <>
44
+ <div className="rhine-chart__scale"><span>{format(maximum)} {context.unit}</span><span>{format(minimum)} {context.unit}</span></div>
45
+ <svg className="rhine-chart__plot" viewBox="0 0 600 180" preserveAspectRatio="none" role="img" aria-labelledby={`${id}-title ${id}-description`} aria-describedby={`${id}-inspection`} tabIndex={0}
46
+ onFocus={() => { setFocused(true); if (!activePoint) select(points[0]?.id ?? null); }} onBlur={() => setFocused(false)}
47
+ onKeyDown={event => {
48
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
49
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : event.key === 'ArrowLeft' ? Math.max(0, activeIndex - 1) : event.key === 'ArrowRight' ? Math.min(points.length - 1, activeIndex + 1) : undefined;
50
+ if (next !== undefined) { event.preventDefault(); select(points[next]?.id ?? null); }
51
+ }}
52
+ onPointerMove={event => {
53
+ if (event.pointerType !== 'mouse' && !event.buttons) return;
54
+ const rect = event.currentTarget.getBoundingClientRect();
55
+ if (rect.width) select(points[Math.max(0, Math.min(points.length - 1, Math.floor((event.clientX - rect.left) / rect.width * points.length)))]?.id ?? null);
56
+ }}
57
+ onPointerLeave={() => { if (!focused) select(null); }}
58
+ onClick={event => {
59
+ const rect = event.currentTarget.getBoundingClientRect();
60
+ event.currentTarget.focus();
61
+ if (rect.width) select(points[Math.max(0, Math.min(points.length - 1, Math.floor((event.clientX - rect.left) / rect.width * points.length)))]?.id ?? null);
62
+ }}>
63
+ <title id={`${id}-title`}>{context.label} · {kind === 'bar' ? '柱形趋势' : '历史折线'}</title>
64
+ <desc id={`${id}-description`}>{context.description};{points.length} 个时段,{points.length - finite.length} 个未知值。完整标签与数值见下方数据表。</desc>
65
+ <path className="rhine-chart__grid" d={`M0,${y(0)}H600 M0,10H600 M0,170H600`} />
66
+ {activePoint && <rect className="rhine-chart__highlight" x={activeIndex * 600 / points.length} y={0} width={600 / points.length} height={180} />}
67
+ {kind === 'line' && <path className="rhine-chart__line" d={path} vectorEffect="non-scaling-stroke" />}
68
+ {points.map((point, index) => !known(point.value) ? <path key={point.id} className="rhine-chart__missing" d={`M${x(index) - 3},174l6,6m0,-6l-6,6`} vectorEffect="non-scaling-stroke" /> : kind === 'bar' ?
69
+ <rect key={point.id} className="rhine-chart__bar" x={x(index) - 210 / points.length} y={Math.min(y(0), y(point.value))} width={420 / points.length} height={Math.abs(y(point.value) - y(0))}><title>{point.label}:{point.value} {context.unit}</title></rect> :
70
+ <circle key={point.id} className="rhine-chart__point" cx={x(index)} cy={y(point.value)} r={3}><title>{point.label}:{point.value} {context.unit}</title></circle>)}
71
+ </svg>
72
+ <div className="rhine-chart__range"><span>{points[0]?.label}</span><span>{points.at(-1)?.label}</span></div>
73
+ <p className="rhine-chart__legend"><span aria-hidden="true" />{context.label} · × 表示缺失;折线在缺失处断开。</p>
74
+ <p id={`${id}-inspection`} className="rhine-chart__inspection" aria-live="polite" aria-atomic="true">{activePoint ? `${activePoint.label}:${format(activePoint.value)}${known(activePoint.value) ? ` ${context.unit}` : ''}` : '聚焦图形后用左右键、Home / End 查看采样;也可悬停或轻触。'}</p>
75
+ </>}
76
+ <details className="rhine-chart__data"><summary>查看{context.label}数据表</summary><DataTable caption={`${context.label}原始采样`} rows={points} rowKey={point => point.id} columns={[
77
+ { key: 'label', header: '时段', render: point => point.label }, { key: 'value', header: `值(${context.unit})`, render: point => known(point.value) ? point.value : '未知' },
78
+ ]} /></details>
79
+ </ChartFrame>;
80
+ }
81
+
82
+ export interface ChartPart extends ChartPoint { tone?: 1 | 2 | 3 | 4 }
83
+ export interface StackedBarProps extends ChartContext { parts: ChartPart[] }
84
+ export function StackedBar({ parts, ...context }: StackedBarProps) {
85
+ const incomplete = parts.some(part => !known(part.value)), invalid = parts.some(part => known(part.value) && part.value < 0);
86
+ const total = parts.reduce((sum, part) => sum + (known(part.value) ? part.value : 0), 0);
87
+ const complete = parts.length > 0 && !incomplete && !invalid && Number.isFinite(total) && total > 0;
88
+ return <ChartFrame {...context}>
89
+ {complete ? <div className="rhine-chart__stack" role="img" aria-label={`${context.label}:${parts.map(part => `${part.label} ${part.value} ${context.unit}`).join(';')}。完整比例见图例。`}>
90
+ {parts.map((part, index) => part.value! > 0 && <span key={part.id} data-tone={part.tone ?? index % 4 + 1} style={{ flexGrow: part.value! / total } as CSSProperties} />)}
91
+ </div> : <p role="status">{invalid ? '包含负数,无法绘制占比。' : incomplete ? '总量未知,不计算占比。' : total === 0 && parts.length ? '已知总量为 0,无占比。' : '暂无可绘制的占比。'}</p>}
92
+ <ul className="rhine-chart__parts" aria-label={`${context.label}图例`}>{parts.map((part, index) => <li key={part.id}><span aria-hidden="true" data-tone={part.tone ?? index % 4 + 1} />{part.label}:{context.valueFormatter ? context.valueFormatter(known(part.value) ? part.value : null) : known(part.value) ? part.value : '未知'}{known(part.value) ? ` ${context.unit}` : ''}{complete ? `(${(part.value! / total * 100).toFixed(1)}%)` : ''}</li>)}</ul>
93
+ </ChartFrame>;
94
+ }