@linhey/rhine-design 0.6.0 → 0.8.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.
- package/CHANGELOG.md +32 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AppShell.tsx +46 -17
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +4 -2
- package/components/Overlays.tsx +37 -8
- package/components/README.md +27 -3
- package/components/StatusTimeline.tsx +72 -6
- package/components/StatusTimelineList.tsx +38 -0
- package/components/ThemePicker.tsx +49 -0
- package/components/assets/orbit-bird.svg +578 -0
- package/components/assets/orbit-crystal.svg +11 -0
- package/components/assets/orbit-donut.svg +16 -0
- package/components/assets/theme-previews/arknights.png +0 -0
- package/components/assets/theme-previews/manifest.json +24 -0
- package/components/assets/theme-previews/neo-brutalism.png +0 -0
- package/components/assets/theme-previews/rhine.png +0 -0
- package/components/common.css +3 -3
- package/components/components.tsx +3 -3
- package/components/dialog-settings.css +9 -0
- package/components/extensions.css +8 -32
- package/components/index.ts +11 -5
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +79 -1
- package/components/orbitBirdFlight.ts +34 -0
- package/components/orbitBirdGeometry.ts +158 -0
- package/components/orbitDonut.ts +134 -0
- package/components/orbitDonutThree.ts +208 -0
- package/components/orbitObjectMaterials.ts +76 -0
- package/components/orbitObjects.ts +71 -0
- package/components/orbitObjectsThree.ts +215 -0
- package/components/orbitProjection.ts +21 -0
- package/components/orbitRenderer.ts +163 -46
- package/components/styles.css +3 -1
- package/components/theme-neo-brutalism.css +3 -2
- package/components/theme-picker.css +17 -0
- package/components/theme.tsx +2 -8
- package/components/themeMetadata.ts +7 -0
- package/components/timeline.css +17 -6
- package/components/useFloatingScale.ts +96 -0
- package/docs/APPLICATION_COMPONENTS.md +52 -3
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +7 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +38 -46
- package/docs/SETTINGS_DIALOG.md +21 -0
- package/docs/THEME_PICKER.md +26 -0
- package/docs/THEMING.md +2 -0
- package/docs/assets/compact-account-neo.png +0 -0
- package/docs/assets/neutron-star-original.png +0 -0
- package/docs/assets/neutron-star-theme.png +0 -0
- package/docs/assets/orbit-bird.png +0 -0
- package/docs/assets/orbit-crystal.png +0 -0
- package/docs/assets/orbit-strawberry.png +0 -0
- package/package.json +8 -3
- package/preview/CompactAccountGallery.tsx +178 -0
- package/preview/DateRangeFilterGallery.tsx +109 -0
- package/preview/ExtensionsGallery.tsx +27 -18
- package/preview/GalleryShell.tsx +3 -3
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +35 -32
- package/preview/OverlayBoundsGallery.tsx +49 -0
- package/preview/SettingsDialogGallery.tsx +33 -0
- package/preview/ThemePickerGallery.tsx +24 -0
- package/preview/ThemeSample.tsx +14 -0
- package/preview/TimelineListGallery.tsx +36 -0
- package/preview/extensions.css +9 -0
- package/preview/main.tsx +16 -3
- package/preview/preview.css +8 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-overlay-bounds.js +76 -0
- package/scripts/check-settings-theme.js +88 -0
- package/skills/rhine-design/SKILL.md +3 -2
- package/skills/rhine-design/references/API_MAP.md +13 -2
- package/skills/rhine-design/references/TASK_MAP.md +8 -2
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/AppShellControls.test.tsx +82 -0
- package/tests/ButtonLink.test.tsx +94 -0
- package/tests/CompactAccountGallery.test.tsx +116 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/NeutronStarBackdrop.test.tsx +288 -0
- package/tests/NeutronStarGallery.test.tsx +62 -0
- package/tests/NeutronStarImport.test.tsx +15 -0
- package/tests/OrbitBackdrop.test.tsx +246 -9
- package/tests/OrbitDonutThree.test.tsx +91 -0
- package/tests/OrbitObjectsThree.test.tsx +164 -0
- package/tests/SettingsDialog.test.tsx +66 -0
- package/tests/StatusTimelineList.test.tsx +63 -0
- package/tests/ThemePicker.test.tsx +30 -0
- package/tests/TimelineLegend.test.tsx +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.8.0(2026-10-24)
|
|
4
|
+
|
|
5
|
+
- 审查修复:保留 Tooltip 与嵌套 Dialog 的 Escape 层级;主题元数据独立于可选 CSS;设置示例保存中重开时同步已保存值并保留后续编辑。
|
|
6
|
+
- 对应 #18:Dialog 新增 `navigation` 插槽,提供按实际容器宽度重排的设置导航布局,保留嵌套焦点、草稿与宿主状态,并修复快速打开内层后 Escape 误关外层。
|
|
7
|
+
- 对应 #20:新增受控原生单选 `ThemePicker` 和静态 `ThemePreview`;三套官方组件缩略图随包提供,覆盖加载/失败/禁用,主题切换不重挂载业务子树。
|
|
8
|
+
|
|
9
|
+
- 修复 #16:Popover、Menu、Tooltip 和时间轴共用浮层 CSS zoom 归一化;保留文字与点击目标放大,并在边缘限制实际浮层边界。新增文档/主题根/正文缩放的独立文档展台。
|
|
10
|
+
|
|
11
|
+
### 承接未发布的 0.7.0 候选
|
|
12
|
+
|
|
13
|
+
0.7.0 未成功发布至 npm;下列已接受功能随 0.8.0 一并交付,历史候选制品保持不变。
|
|
14
|
+
|
|
15
|
+
- 橙趣草莓甜甜圈增强为按需加载的 Three.js 艺术:立体糖霜、程序烘焙纹理、280 根糖针、110 颗糖珠、100 根环绕糖粒与 460 颗糖尘;共用原版倾角、62%/57%中心与缩放,校准主体尺寸,保留环面旋转、共享暂停与减少动态时钟,并提供 Canvas2D 降级和 GPU 清理。
|
|
16
|
+
- OrbitBackdrop 新增 `appearance="theme" | "original"`:莱茵为折纸鸟(578 个三角折面、扇形褶皱与叠层双翼、暖白纸纹、弧线飞行和随行纸屑),战术为源石晶簇(倒角晶面、矿物纹理、琥珀裂纹和漂浮碎片),橙趣为草莓甜甜圈。三套物品均按需加载 Three.js;窄屏也可播放,共用暂停、减少动态和可见性时钟,切换主题复用已创建的绘制器。WebGL 失败回退 Canvas2D,Canvas 不可用时展示本地 SVG。`original` 保留初版完整深色轨道。
|
|
17
|
+
- 莱茵纸鸟在同一组折面上连续变形成四瓣纸蝴蝶:单程 14 秒,依次收翼、松折、展平停顿、重新折叠,翼根至翼尖错开动作;全周期 42 秒。变形中暂缓扑翼,暂停或减少动态冻结当前形态,恢复后接续;WebGL 与 Canvas2D 共用几何、变形和飞行姿态,静态 SVG 保留初始纸鸟。
|
|
18
|
+
- 新增 `StatusTimelineList` 多条健康历史组合;与 `AppShellToggle`、`NeutronStarBackdrop`、`ButtonLink` 一起将公共组件增至 64。
|
|
19
|
+
- AppShell 新增可选 `contentHeader` 导航插槽,位于正文滚动区外;展台按应用栏模式组合当前页、侧栏入口与上下文操作,窄窗使用菜单入口,保留草稿和焦点。三主题边界同步;默认 header 模式保持兼容。
|
|
20
|
+
- 重做媒体卡片与查看器:卡片边框、类型标签、选中态及独立操作区跟随三套主题;查看器仅画布保持中性深色,工具栏和信息栏使用宿主主题。展台改为自适应资源网格并单列异常状态,修复 CSS zoom 下查看器高度重复放大。
|
|
21
|
+
- 补齐 Neo Brutalism 的 Badge 墨色描边与字重,中性标签使用主题文字色。
|
|
22
|
+
|
|
23
|
+
- 对应 [#14](https://github.com/linhay/rhine-design/issues/14):新增原生 anchor 语义的 `ButtonLink`,复用 Button 视觉及 busy/disabled;移交 `StatusTimelineList` 稳定行/互斥详情组合,并扩展 `TimelineLegend compact/items` 为一行短标签与悬停、键盘、触屏规则详情。无参图例保持兼容。
|
|
24
|
+
- 时间轴与图例详情在祖先 CSS zoom 下归一化定位包装层,保留正文放大与视口内滚动;鼠标离开跨卡详情延迟关闭,移入浮层可继续阅读,不更改键盘或触屏入口。
|
|
25
|
+
- 新增[紧凑摘要与凭证操作](docs/COMPACT_ACCOUNT_PATTERNS.md)及 `#compact-account` 真实样机:无框额度摘要、配对事实、短必要提示、按需历史;消融不减小业务字体、不隐藏未知/失败或混淆不同成本与到期。以上能力随 0.8.0 提供。
|
|
26
|
+
|
|
27
|
+
- AppShell 新增可选 inline 控制模式与 AppShellToggle:侧栏品牌区收起、正文导航区展开/打开抽屉,不再强制独立顶部横栏;默认 header 模式保持兼容。补充焦点迁移、断点和 Escape 回归及真实展台。
|
|
28
|
+
- 对应 [#12](https://github.com/linhay/rhine-design/issues/12):新增 `NeutronStarBackdrop`,承接完整中子星艺术:28 条磁场线、560 个流动粒子、700 颗星尘、极向喷流与星核;星系以 28 秒有效播放时间完成一轮深度公转,镜头固定。默认跟随现有主题 tokens,`appearance="original"` 保留原始深空;主题切换不重置时间,正文与实体控件保持清晰。
|
|
29
|
+
- Three.js 绘制按需加载,内置暂停 / 继续,系统、显式、祖先减少动态及隐藏 / 离屏 / 零尺寸停帧,恢复不追赶。SSR 安全结构、异步卸载保护、GPU 资源清理与静态降级;无 WebGL、导入失败和 context lost 不影响内容,context restored 按当前偏好恢复。
|
|
30
|
+
- 外层流粒与对应磁场线对齐,避免使用生成参数的错误边界裁剪粒子;暂停控件的焦点框绘制于按钮内部,保持原始深空下的键盘焦点可见。
|
|
31
|
+
- 修复长页窄屏中星核随公转移出画幅:艺术层独立限高并渐隐,窄画幅由库内相机取景适配;保留原始几何与时间公式,正文和控件布局不受艺术高度限制。
|
|
32
|
+
- 新增艺术优先的 `#neutron-star` 真实展台,演示控制与内容组合默认折叠;重播不丢表单草稿。[组件契约](docs/NEUTRON_STAR_BACKDROP.md)、API / 任务索引及回归测试同步。此 API 自 0.8.0 提供。
|
|
33
|
+
- 对应 [#10](https://github.com/linhay/rhine-design/issues/10):新增[日期筛选组合规范](docs/DATE_RANGE_FILTER.md)与 `#date-range-filter` 真实展台;复用 0.6.0 已发布的 SegmentedControl、Button、Popover、DateRangePicker 和布局组件。快捷项共用分段边界,日期持续可见,预设即时应用、自定义确认后应用,取消和关闭不提交。包含跨年、长标签、禁用、键盘和三主题验证;仅组合已有 API,不新增独立日期筛选组件。
|
|
34
|
+
|
|
3
35
|
## 0.6.0(2026-10-24)
|
|
4
36
|
|
|
5
37
|
- 对应 [#8](https://github.com/linhay/rhine-design/issues/8):新增 `OrbitBackdrop`,将授权移交的轨道艺术维护为公共组件,保留 420 个环节点、185 条空间曲线、箭头、光晕与透视;公共组件增至 60 个,无新增运行时依赖。
|
package/README.md
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
<a href="CHANGELOG.md">版本记录</a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
-
Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、
|
|
19
|
+
Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、66 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
|
|
20
20
|
|
|
21
21
|
- **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
|
|
22
|
-
- **
|
|
22
|
+
- **66 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
|
|
23
23
|
- **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
|
|
24
24
|
- **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
|
|
25
25
|
|
|
@@ -29,12 +29,16 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
29
29
|
|
|
30
30
|
**0.5.0 新增能力**:新增背景与多侧栏、复杂表单、受控数据表、媒体与监控图表,共 59 个组件。API、最小接入与版本边界见[应用组件契约](docs/APPLICATION_COMPONENTS.md)。npm 0.4.0 不含这些能力。
|
|
31
31
|
|
|
32
|
-
**0.
|
|
32
|
+
**0.8.0 新增能力**:三主题 Three.js 艺术分别为折纸鸟、源石晶簇与草莓甜甜圈,保留原始轨道切换;新增 `NeutronStarBackdrop`、`StatusTimelineList` 、`AppShellToggle` 与 `ButtonLink`,公共组件总数为 66。包含暂停、减少动态、静态降级与资源清理。安装前核对[版本记录](CHANGELOG.md)与 registry;在线展台跟随 main。
|
|
33
|
+
|
|
34
|
+
0.8.0 同时提供 `ButtonLink` 与可传规则的一行 `TimelineLegend`;[紧凑摘要与凭证操作规范](docs/COMPACT_ACCOUNT_PATTERNS.md)说明无框摘要、历史详情与必要操作的组合。真实展台为 `/#compact-account`。
|
|
33
35
|
|
|
34
36
|
本轮新增 API 与交互已逐项核对 Ant Design / MUI,修复及明确保留的差异见[对标清单](docs/ANTD_MUI_AUDIT.md)。
|
|
35
37
|
|
|
36
38
|
<a id="quick-start"></a>
|
|
37
39
|
|
|
40
|
+
**0.8.0 新增**:新增 [主题预览与选择](docs/THEME_PICKER.md)、[设置弹窗导航](docs/SETTINGS_DIALOG.md)及[浮层缩放修复](docs/FLOATING_OVERLAYS.md)。0.7.0 候选未发布;本版承接其全部已接受功能。
|
|
41
|
+
|
|
38
42
|
## 快速接入
|
|
39
43
|
|
|
40
44
|
需要 **React 19**。当前已验证的构建环境为 **Vite**,开发建议使用 **Node 24**。
|
|
@@ -42,7 +46,7 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
42
46
|
在你的 React 项目中安装:
|
|
43
47
|
|
|
44
48
|
```sh
|
|
45
|
-
npm install --save-exact @linhey/rhine-design@0.
|
|
49
|
+
npm install --save-exact @linhey/rhine-design@0.8.0
|
|
46
50
|
```
|
|
47
51
|
|
|
48
52
|
包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
|
|
@@ -79,6 +83,10 @@ export function Welcome() {
|
|
|
79
83
|
|
|
80
84
|
OrbitBackdrop 承接授权移交的轨道动画,保留环节点、曲线、箭头、光晕与透视,并提供暂停、减少动态和离屏停帧。它是纯装饰,不表示加载或业务进度。契约与接入见[轨道背景](docs/ORBIT_BACKDROP.md),真实展台见[轨道星空](https://linhay.github.io/rhine-design/#orbit)。
|
|
81
85
|
|
|
86
|
+
### 中子星背景(0.8.0)
|
|
87
|
+
|
|
88
|
+
NeutronStarBackdrop 承接另一套独立的中子星艺术,使用按需加载的 Three.js 绘制;内置暂停、减少动态、离屏停帧与静态降级。主题切换不重播,正文与控件不模糊。最小接入、生命周期及版本边界见[中子星契约](docs/NEUTRON_STAR_BACKDROP.md),源码展台入口为 `/#neutron-star`。不要用轨道组件替换中子星的原视觉配方。
|
|
89
|
+
|
|
82
90
|
### 一键切换主题(0.4.0)
|
|
83
91
|
|
|
84
92
|
展台右上角可切换并记忆主题。新增 **橙趣 · 粗描边**:浅灰蓝点阵背景、米白圆角卡片、黑色粗边、向右下偏移的硬阴影与亮橙主按钮。打开 [在线订阅管理示例](https://linhay.github.io/rhine-design/?theme=neo-brutalism#subscriptions),体验卡片详情、管理菜单与演示表单;本地开发访问 `http://127.0.0.1:18309/?theme=neo-brutalism#subscriptions`。
|
|
@@ -123,15 +131,15 @@ export function App() {
|
|
|
123
131
|
| 类别 | 组件 |
|
|
124
132
|
| --- | --- |
|
|
125
133
|
| 品牌与文字 | Logo · Text |
|
|
126
|
-
| 操作 | Button · Icon · IconButton · Link · CopyButton |
|
|
127
|
-
| 布局与背景 | Surface · Toolbar · Divider · Stack · Inline · Grid · Background² · AppShell² · OrbitBackdrop³ |
|
|
134
|
+
| 操作 | Button · ButtonLink⁴ · Icon · IconButton · Link · CopyButton |
|
|
135
|
+
| 布局与背景 | Surface · Toolbar · Divider · Stack · Inline · Grid · Background² · AppShell² · AppShellToggle⁴ · OrbitBackdrop³ · NeutronStarBackdrop⁴ |
|
|
128
136
|
| 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard · DateField² · DateRangePicker² · NumberField² · TreeSelect² · RemoteMultiSelect² |
|
|
129
137
|
| 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
|
|
130
|
-
| 数据与媒体 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · TimelineLegend¹ · TrendChart² · StackedBar² · MediaCard² · MediaViewer² |
|
|
138
|
+
| 数据与媒体 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · StatusTimelineList⁴ · TimelineLegend¹ · TrendChart² · StackedBar² · MediaCard² · MediaViewer² |
|
|
131
139
|
| 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
|
|
132
140
|
| 状态与反馈 | Badge · Notice · Toast · Progress · Skeleton · EmptyState |
|
|
133
141
|
|
|
134
|
-
¹ 自 0.4.0 提供。² 自 0.5.0 提供。³ 自 0.6.0 提供。
|
|
142
|
+
¹ 自 0.4.0 提供。² 自 0.5.0 提供。³ 自 0.6.0 提供。⁴ 自 0.8.0 提供。
|
|
135
143
|
|
|
136
144
|
参数、状态和组合示例见[组件 API](components/README.md)与[常用组件契约](components/COMMON_COMPONENTS.md)。
|
|
137
145
|
|
|
@@ -150,7 +158,10 @@ export function App() {
|
|
|
150
158
|
| 动效、交互与减少动态 | [Motion](components/MOTION.md) | [动效实验室](https://linhay.github.io/rhine-design/#motion) |
|
|
151
159
|
| 启动动画与就绪衔接 | [Startup](components/STARTUP.md) | [启动动画](https://linhay.github.io/rhine-design/#startup) |
|
|
152
160
|
| 轨道装饰、暂停与静态降级 | [OrbitBackdrop](docs/ORBIT_BACKDROP.md) | [轨道星空](https://linhay.github.io/rhine-design/#orbit) |
|
|
161
|
+
| 中子星艺术、主题与 WebGL 生命周期 | [NeutronStarBackdrop(0.8.0)](docs/NEUTRON_STAR_BACKDROP.md) | [中子星展台](https://linhay.github.io/rhine-design/#neutron-star) |
|
|
153
162
|
| 场景与交互选型 | [使用规范](docs/INTERACTION_PATTERNS.md) | [场景指南](https://linhay.github.io/rhine-design/#patterns) |
|
|
163
|
+
| 日期快捷项、范围草稿与确认 | [日期筛选组合](docs/DATE_RANGE_FILTER.md) | [日期筛选](https://linhay.github.io/rhine-design/#date-range-filter) |
|
|
164
|
+
| 无框摘要、一行图例与凭证操作消融 | [紧凑组合](docs/COMPACT_ACCOUNT_PATTERNS.md) | [紧凑账号](https://linhay.github.io/rhine-design/#compact-account) |
|
|
154
165
|
| 导航与表单流程 | [组件契约](docs/INTERACTION_PATTERNS.md#新组件契约) | [导航](https://linhay.github.io/rhine-design/#navigation) · [表单](https://linhay.github.io/rhine-design/#form-workflow) |
|
|
155
166
|
| 系统覆盖与演进方向 | [组件系统对照](docs/DESIGN_SYSTEM_AUDIT.md) | — |
|
|
156
167
|
|
package/components/Actions.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { forwardRef, useEffect, useRef, useState, type AnchorHTMLAttributes, type ButtonHTMLAttributes } from 'react';
|
|
2
|
-
import { Button } from './components';
|
|
2
|
+
import { Button, type ButtonProps } from './components';
|
|
3
3
|
|
|
4
4
|
const paths = {
|
|
5
|
+
menu: 'M3 5h14M3 10h14M3 15h14',
|
|
5
6
|
close: 'm5 5 10 10M15 5 5 15',
|
|
6
7
|
chevronDown: 'm5 8 5 5 5-5',
|
|
7
8
|
chevronLeft: 'm12 5-5 5 5 5',
|
|
@@ -26,6 +27,22 @@ export const IconButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTM
|
|
|
26
27
|
export function Link({ children, className = '', target, rel, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) {
|
|
27
28
|
return <a {...props} target={target} rel={target === '_blank' ? `${rel || ''} noopener noreferrer`.trim() : rel} className={`rhine-link ${className}`}>{children}{target === '_blank' && <><Icon name="external" size={16} /><span className="rhine-sr-only">(新窗口打开)</span></>}</a>;
|
|
28
29
|
}
|
|
30
|
+
export interface ButtonLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement>, Pick<ButtonProps, 'variant' | 'busy' | 'busyLabel'> {
|
|
31
|
+
href: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(function ButtonLink({ href, variant = 'primary', busy = false, busyLabel = '处理中', disabled = false, className = '', children, tabIndex, onClick, onAuxClick, ...props }, ref) {
|
|
35
|
+
const unavailable = disabled || busy;
|
|
36
|
+
// Removing href also prevents context-menu and modified-click navigation while unavailable.
|
|
37
|
+
return <a {...props} ref={ref} href={unavailable ? undefined : href} role="link"
|
|
38
|
+
tabIndex={disabled ? -1 : tabIndex ?? (busy ? 0 : undefined)} aria-disabled={unavailable || undefined}
|
|
39
|
+
aria-busy={busy || undefined} data-disabled={disabled || undefined} className={`rhine-button rhine-button--${variant} rhine-button-link ${className}`}
|
|
40
|
+
onClick={event => { if (unavailable) { event.preventDefault(); event.stopPropagation(); return; } onClick?.(event); }}
|
|
41
|
+
onAuxClick={event => { if (unavailable) { event.preventDefault(); event.stopPropagation(); return; } onAuxClick?.(event); }}>
|
|
42
|
+
<span className={busy ? 'rhine-button__ghost' : undefined} aria-hidden={busy || undefined}>{children}</span>
|
|
43
|
+
{busy && <span className="rhine-button__busy"><span className="rhine-spinner" aria-hidden="true" /><span className="rhine-sr-only">{busyLabel}</span></span>}
|
|
44
|
+
</a>;
|
|
45
|
+
});
|
|
29
46
|
export function CopyButton({ value, label = '复制', copy = text => navigator.clipboard.writeText(text) }: { value: string; label?: string; copy?: (value: string) => Promise<void> }) {
|
|
30
47
|
const [status, setStatus] = useState<'idle' | 'busy' | 'success' | 'error'>('idle');
|
|
31
48
|
const pending = useRef(false);
|
package/components/AppShell.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
1
|
+
import { createContext, useContext, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
2
2
|
import { Button } from './components';
|
|
3
|
+
import { IconButton } from './Actions';
|
|
3
4
|
import { Drawer } from './Overlays';
|
|
4
5
|
import { Background, type BackgroundDecoration } from './Background';
|
|
5
6
|
|
|
@@ -11,22 +12,43 @@ export interface ShellPanel {
|
|
|
11
12
|
export interface AppShellProps {
|
|
12
13
|
layout: 'single' | 'double-left' | 'left-right';
|
|
13
14
|
navigation: ShellPanel; secondary?: ShellPanel;
|
|
14
|
-
header?: ReactNode; children: ReactNode; label?: string; decoration?: BackgroundDecoration;
|
|
15
|
+
header?: ReactNode; contentHeader?: ReactNode; children: ReactNode; label?: string; decoration?: BackgroundDecoration;
|
|
15
16
|
className?: string; style?: CSSProperties;
|
|
17
|
+
controls?: 'header' | 'inline';
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
interface PanelControl { panel: ShellPanel; overlay: boolean; side: 'left' | 'right'; id: string; drawerOpen: boolean; setDrawerOpen: (open: boolean) => void }
|
|
21
|
+
const ShellControls = createContext<{ navigation: PanelControl; secondary?: PanelControl } | null>(null);
|
|
22
|
+
export interface AppShellToggleProps { panel?: 'navigation' | 'secondary'; placement: 'panel' | 'content'; iconOnly?: boolean }
|
|
23
|
+
|
|
24
|
+
export function AppShellToggle({ panel = 'navigation', placement, iconOnly = false }: AppShellToggleProps) {
|
|
25
|
+
const controls = useContext(ShellControls);
|
|
26
|
+
const control = controls?.[panel];
|
|
27
|
+
if (!control) return null;
|
|
28
|
+
const expanded = !control.overlay && !control.drawerOpen && control.panel.open;
|
|
29
|
+
if (placement === 'content') return <Panel {...control} hidden={expanded} iconOnly={iconOnly} />;
|
|
30
|
+
if (!expanded) return null;
|
|
31
|
+
const label = `收起${control.panel.label}`;
|
|
32
|
+
const props = { id: `${control.id}-collapse`, 'aria-controls': control.id, 'aria-expanded': true, onClick: () => control.panel.onOpenChange(false) };
|
|
33
|
+
return iconOnly ? <IconButton {...props} icon="chevronLeft" label={label} title={label} /> : <Button {...props} variant="secondary">{label}</Button>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Panel({ panel, overlay, side, id, drawerOpen, setDrawerOpen, hidden = false, iconOnly = false }: PanelControl & { hidden?: boolean; iconOnly?: boolean }) {
|
|
19
37
|
const floating = overlay || drawerOpen;
|
|
20
|
-
|
|
38
|
+
const label = `${floating ? '' : panel.open ? '收起' : '展开'}${panel.label}`;
|
|
39
|
+
const props = { id: `${id}-toggle`, hidden, 'aria-controls': floating ? undefined : id, 'aria-expanded': floating ? drawerOpen : panel.open,
|
|
40
|
+
onClick: (event: React.MouseEvent<HTMLButtonElement>) => { if (!floating) { event.preventDefault(); panel.onOpenChange(!panel.open); } } };
|
|
41
|
+
return <div className="rhine-shell__panel-control" hidden={hidden}>
|
|
21
42
|
<Drawer title={panel.label} description="关闭后返回当前任务。" side={side} open={drawerOpen} onOpenChange={setDrawerOpen}
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
onCloseAutoFocus={event => { if (document.getElementById(`${id}-toggle`)?.hidden) { event.preventDefault(); document.getElementById(`${id}-collapse`)?.focus(); } }}
|
|
44
|
+
trigger={iconOnly ? <IconButton {...props} icon={floating && side === 'left' ? 'menu' : 'chevronRight'} label={label} title={label} /> : <Button {...props} variant="secondary">{label}</Button>}>{panel.content}</Drawer>
|
|
24
45
|
</div>;
|
|
25
46
|
}
|
|
26
47
|
|
|
27
|
-
export function AppShell({ layout, navigation, secondary, header, children, label = '应用工作区', decoration = 'auto', className = '', style }: AppShellProps) {
|
|
48
|
+
export function AppShell({ layout, navigation, secondary, header, contentHeader, children, label = '应用工作区', decoration = 'auto', className = '', style, controls = 'header' }: AppShellProps) {
|
|
28
49
|
const ref = useRef<HTMLDivElement>(null);
|
|
29
50
|
const focusedPanel = useRef<'nav' | 'secondary' | null>(null);
|
|
51
|
+
const focusedToggle = useRef<string | null>(null);
|
|
30
52
|
const [width, setWidth] = useState(0);
|
|
31
53
|
const [localNavigationDrawer, setLocalNavigationDrawer] = useState(navigation.defaultDrawerOpen ?? false), [localSecondaryDrawer, setLocalSecondaryDrawer] = useState(secondary?.defaultDrawerOpen ?? false);
|
|
32
54
|
const navigationDrawer = navigation.drawerOpen ?? localNavigationDrawer, secondaryDrawer = secondary?.drawerOpen ?? localSecondaryDrawer;
|
|
@@ -45,6 +67,11 @@ export function AppShell({ layout, navigation, secondary, header, children, labe
|
|
|
45
67
|
const navOverlay = width < (navigation.breakpoint ?? 760), secondaryOverlay = width < (secondary?.breakpoint ?? 1120);
|
|
46
68
|
const hasSecondary = layout !== 'single' && !!secondary;
|
|
47
69
|
useLayoutEffect(() => {
|
|
70
|
+
// Inline restore controls disappear after expansion; keep keyboard focus reachable.
|
|
71
|
+
if (controls === 'inline') for (const suffix of ['nav', 'secondary']) {
|
|
72
|
+
const toggle = document.getElementById(`${id}-${suffix}-toggle`);
|
|
73
|
+
if (toggle?.hidden && focusedToggle.current === toggle.id && (document.activeElement === toggle || document.activeElement === document.body)) document.getElementById(`${id}-${suffix}-collapse`)?.focus();
|
|
74
|
+
}
|
|
48
75
|
const suffix = focusedPanel.current;
|
|
49
76
|
if (suffix) {
|
|
50
77
|
const pane = document.getElementById(`${id}-${suffix}`);
|
|
@@ -53,19 +80,21 @@ export function AppShell({ layout, navigation, secondary, header, children, labe
|
|
|
53
80
|
focusedPanel.current = null;
|
|
54
81
|
}
|
|
55
82
|
}
|
|
56
|
-
}, [id, navOverlay, secondaryOverlay, hasSecondary, navigation.open, secondary?.open]);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
}, [id, controls, navOverlay, secondaryOverlay, hasSecondary, navigation.open, secondary?.open, navigationDrawer, secondaryDrawer]);
|
|
84
|
+
const navigationControl: PanelControl = { panel: navigation, overlay: navOverlay, side: 'left', id: `${id}-nav`, drawerOpen: navigationDrawer, setDrawerOpen: setNavigationDrawer };
|
|
85
|
+
const secondaryControl: PanelControl | undefined = hasSecondary ? { panel: secondary, overlay: secondaryOverlay, side: layout === 'left-right' ? 'right' : 'left', id: `${id}-secondary`, drawerOpen: secondaryDrawer, setDrawerOpen: setSecondaryDrawer } : undefined;
|
|
86
|
+
return <ShellControls.Provider value={{ navigation: navigationControl, secondary: secondaryControl }}><div className={`rhine-shell ${className}`} style={style} ref={ref} role="region" aria-label={label} data-layout={layout}
|
|
87
|
+
onFocusCapture={event => { focusedToggle.current = event.target.id.endsWith('-toggle') ? event.target.id : null; focusedPanel.current = document.getElementById(`${id}-nav`)?.contains(event.target) ? 'nav' : document.getElementById(`${id}-secondary`)?.contains(event.target) ? 'secondary' : null; }}
|
|
88
|
+
onBlurCapture={event => { if (event.relatedTarget && event.relatedTarget !== document.body && !event.currentTarget.contains(event.relatedTarget)) { focusedPanel.current = null; focusedToggle.current = null; } }}>
|
|
89
|
+
{(controls === 'header' || header) && <header className="rhine-shell__header">
|
|
90
|
+
{controls === 'header' && <><Panel {...navigationControl} />{secondaryControl && <Panel {...secondaryControl} />}</>}
|
|
63
91
|
{header}
|
|
64
|
-
</header>
|
|
92
|
+
</header>}
|
|
65
93
|
<div className="rhine-shell__body">
|
|
66
94
|
<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
95
|
<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
|
-
<
|
|
96
|
+
<div className="rhine-shell__main">{contentHeader != null && <header className="rhine-shell__content-header">{contentHeader}</header>}
|
|
97
|
+
<Background className="rhine-shell__content" decoration={decoration} tabIndex={0} role="region" aria-label="正文">{children}</Background></div>
|
|
69
98
|
</div>
|
|
70
|
-
</div>;
|
|
99
|
+
</div></ShellControls.Provider>;
|
|
71
100
|
}
|
package/components/Media.tsx
CHANGED
|
@@ -11,17 +11,17 @@ export interface MediaCardProps extends Omit<ComponentPropsWithRef<'article'>, '
|
|
|
11
11
|
}
|
|
12
12
|
export function MediaCard({ title, date, kind, preview, state = preview != null && preview !== false ? 'ready' : 'empty', aspectRatio = '4 / 3', onOpen, selected = false, onSelectedChange, disabled, actions, className = '', ...props }: MediaCardProps) {
|
|
13
13
|
const statusId = useId();
|
|
14
|
-
return <article {...props} className={`rhine-media-card ${className}`} data-selected={selected}>
|
|
14
|
+
return <article {...props} className={`rhine-media-card ${className}`} data-selected={selected} data-state={state} data-disabled={disabled || undefined}>
|
|
15
15
|
<button type="button" className="rhine-media-card__open" onClick={event => { event.currentTarget.focus({ preventScroll: true }); onOpen(); }} disabled={disabled} aria-label={`打开${title}`} aria-describedby={statusId}>
|
|
16
16
|
<span className="rhine-media-card__preview" style={{ aspectRatio }} aria-busy={state === 'loading'}>
|
|
17
17
|
{(state === 'ready' || state === 'loading') && <span className="rhine-media-card__asset" hidden={state === 'loading'}>{preview}</span>}
|
|
18
|
-
{state !== 'ready' && <span>{state === 'loading' ? '正在加载预览…' : state === 'error' ? '预览无法解码' : '暂无预览'}</span>}
|
|
18
|
+
{state !== 'ready' && <span className="rhine-media-card__status"><span aria-hidden="true" className="rhine-media-card__status-mark">{state === 'error' ? '!' : state === 'loading' ? '···' : '—'}</span><span>{state === 'loading' ? '正在加载预览…' : state === 'error' ? '预览无法解码' : '暂无预览'}</span></span>}
|
|
19
19
|
<span className="rhine-media-card__kind">{kind === 'photo' ? '照片' : kind === 'video' ? '视频' : kind}</span>
|
|
20
20
|
</span><span className="rhine-media-card__caption"><strong>{title}</strong><span>{date || '日期未知'}</span></span>
|
|
21
21
|
</button>
|
|
22
22
|
<span id={statusId} role="status" className="rhine-sr-only">{state === 'loading' ? `${title}:预览加载中` : state === 'error' ? `${title}:预览失败` : state === 'empty' ? `${title}:缺少预览` : ''}</span>
|
|
23
|
-
{onSelectedChange && <Checkbox label={`选择${title}`} checked={selected} disabled={disabled} onChange={event => onSelectedChange(event.target.checked)} />}
|
|
24
|
-
{actions != null && <div className="rhine-media-card__actions">{actions}</div>}
|
|
23
|
+
{(onSelectedChange || actions != null) && <div className="rhine-media-card__footer">{onSelectedChange && <Checkbox label={`选择${title}`} checked={selected} disabled={disabled} onChange={event => onSelectedChange(event.target.checked)} />}
|
|
24
|
+
{actions != null && <div className="rhine-media-card__actions">{actions}</div>}</div>}
|
|
25
25
|
</article>;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -70,9 +70,9 @@ export function MediaViewer({ trigger, title, description, children, open, defau
|
|
|
70
70
|
if (event.key === 'ArrowRight' && nextEnabled) { event.preventDefault(); onNext?.(); }
|
|
71
71
|
}}>
|
|
72
72
|
<header className="rhine-media-viewer__header"><div><DialogBase.Title>{title}</DialogBase.Title><DialogBase.Description>{description}</DialogBase.Description></div>
|
|
73
|
-
<div className="rhine-media-viewer__actions"><button ref={previousRef} type="button" disabled={!previousEnabled} aria-keyshortcuts={keyboardNavigation ? 'ArrowLeft' : undefined} onClick={onPrevious}>上一项</button><button ref={nextRef} type="button" disabled={!nextEnabled} aria-keyshortcuts={keyboardNavigation ? 'ArrowRight' : undefined} onClick={onNext}>下一项</button>
|
|
74
|
-
{hasMetadata && <button type="button" aria-controls={metadataId} aria-expanded={showMetadata} onClick={changeMetadata}>{showMetadata ? '收起信息' : '显示信息'}</button>}
|
|
75
|
-
{actions}<DialogBase.Close asChild><button type="button" aria-label="关闭媒体查看器">关闭</button></DialogBase.Close>
|
|
73
|
+
<div className="rhine-media-viewer__actions"><button className="rhine-button rhine-button--secondary" ref={previousRef} type="button" disabled={!previousEnabled} aria-keyshortcuts={keyboardNavigation ? 'ArrowLeft' : undefined} onClick={onPrevious}>上一项</button><button className="rhine-button rhine-button--secondary" ref={nextRef} type="button" disabled={!nextEnabled} aria-keyshortcuts={keyboardNavigation ? 'ArrowRight' : undefined} onClick={onNext}>下一项</button>
|
|
74
|
+
{hasMetadata && <button className="rhine-button rhine-button--secondary" type="button" aria-controls={metadataId} aria-expanded={showMetadata} onClick={changeMetadata}>{showMetadata ? '收起信息' : '显示信息'}</button>}
|
|
75
|
+
{actions}<DialogBase.Close asChild><button className="rhine-button rhine-button--primary" type="button" aria-label="关闭媒体查看器">关闭</button></DialogBase.Close>
|
|
76
76
|
</div>
|
|
77
77
|
</header>
|
|
78
78
|
<div className="rhine-media-viewer__body"><div className="rhine-media-viewer__canvas">{children}</div>{hasMetadata && <aside id={metadataId} hidden={!showMetadata} className="rhine-media-viewer__metadata" aria-label="媒体信息">{metadata}</aside>}</div>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type HTMLAttributes } from 'react';
|
|
2
|
+
import { Button } from './components';
|
|
3
|
+
import type { NeutronRenderer, NeutronState } from './neutronRenderer';
|
|
4
|
+
|
|
5
|
+
export interface NeutronStarBackdropProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/** Adds a static preference; false never overrides the system or an ancestor. */
|
|
7
|
+
reducedMotion?: boolean;
|
|
8
|
+
/** Theme follows the host palette; original preserves the initial deep-space artwork. */
|
|
9
|
+
appearance?: 'theme' | 'original';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function NeutronStarBackdrop({ reducedMotion = false, appearance = 'theme', children, className = '', ...props }: NeutronStarBackdropProps) {
|
|
13
|
+
const art = useRef<HTMLDivElement>(null);
|
|
14
|
+
const renderer = useRef<NeutronRenderer | null>(null);
|
|
15
|
+
const options = useRef({ paused: false, reducedMotion, appearance });
|
|
16
|
+
const [paused, setPaused] = useState(false);
|
|
17
|
+
const [state, setState] = useState<NeutronState>('loading');
|
|
18
|
+
options.current = { paused, reducedMotion, appearance };
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let disposed = false;
|
|
22
|
+
if (!window.WebGL2RenderingContext) { setState('unavailable'); return; }
|
|
23
|
+
// The heavy renderer is loaded only for a mounted, WebGL-capable artwork.
|
|
24
|
+
import('./neutronRenderer').then(({ mountNeutron }) => {
|
|
25
|
+
if (disposed) return;
|
|
26
|
+
renderer.current = mountNeutron(art.current!, setState, options.current);
|
|
27
|
+
}).catch(() => { if (!disposed) setState('unavailable'); });
|
|
28
|
+
return () => { disposed = true; renderer.current?.dispose(); renderer.current = null; };
|
|
29
|
+
}, []);
|
|
30
|
+
useEffect(() => { renderer.current?.setPaused(paused); }, [paused]);
|
|
31
|
+
useEffect(() => { renderer.current?.setReducedMotion(reducedMotion); }, [reducedMotion]);
|
|
32
|
+
useEffect(() => { renderer.current?.setAppearance(appearance); }, [appearance]);
|
|
33
|
+
|
|
34
|
+
const canControl = state === 'playing' || state === 'paused';
|
|
35
|
+
const label = state === 'reduced' ? '已减少动态' : canControl ? paused ? '继续动效' : '暂停动效' : '静态画面';
|
|
36
|
+
return <div {...props} className={`rhine-neutron ${className}`} data-rhine-neutron-state={state} data-rhine-neutron-appearance={appearance}>
|
|
37
|
+
<div ref={art} className="rhine-neutron-art" aria-hidden="true">
|
|
38
|
+
<span className="rhine-neutron-fallback"><i /><b /></span>
|
|
39
|
+
</div>
|
|
40
|
+
<div className="rhine-neutron-content">{children}</div>
|
|
41
|
+
<div className="rhine-neutron-controls"><Button variant="secondary" disabled={!canControl} onClick={() => setPaused(value => !value)}>{label}</Button></div>
|
|
42
|
+
</div>;
|
|
43
|
+
}
|
|
@@ -3,11 +3,13 @@ import { Button } from './components';
|
|
|
3
3
|
import { mountOrbit, type OrbitRenderer, type OrbitState } from './orbitRenderer';
|
|
4
4
|
|
|
5
5
|
export interface OrbitBackdropProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/** Follows the host theme by default; original preserves the initial dark artwork. */
|
|
7
|
+
appearance?: 'theme' | 'original';
|
|
6
8
|
/** Adds a static preference; false never overrides the system or an ancestor. */
|
|
7
9
|
reducedMotion?: boolean;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export function OrbitBackdrop({ reducedMotion = false, children, className = '', ...props }: OrbitBackdropProps) {
|
|
12
|
+
export function OrbitBackdrop({ appearance = 'theme', reducedMotion = false, children, className = '', ...props }: OrbitBackdropProps) {
|
|
11
13
|
const canvas = useRef<HTMLCanvasElement>(null);
|
|
12
14
|
const renderer = useRef<OrbitRenderer | null>(null);
|
|
13
15
|
const [paused, setPaused] = useState(false);
|
|
@@ -25,7 +27,7 @@ export function OrbitBackdrop({ reducedMotion = false, children, className = '',
|
|
|
25
27
|
const label = state === 'reduced' ? '已减少动态' : state === 'compact' ? '窄屏静态画面'
|
|
26
28
|
: canControl ? paused ? '继续动效' : '暂停动效' : '静态画面';
|
|
27
29
|
|
|
28
|
-
return <div {...props} className={`rhine-orbit ${className}`} data-rhine-orbit-state={state}>
|
|
30
|
+
return <div {...props} className={`rhine-orbit ${className}`} data-rhine-orbit-appearance={appearance} data-rhine-orbit-state={state}>
|
|
29
31
|
<canvas ref={canvas} className="rhine-orbit-canvas" aria-hidden="true" />
|
|
30
32
|
<div className="rhine-orbit-content">{children}</div>
|
|
31
33
|
<div className="rhine-orbit-controls"><Button variant="secondary" disabled={!canControl} onClick={() => setPaused(value => !value)}>{label}</Button></div>
|
package/components/Overlays.tsx
CHANGED
|
@@ -7,6 +7,10 @@ import * as TooltipBase from '@radix-ui/react-tooltip';
|
|
|
7
7
|
import { Button, Notice } from './components';
|
|
8
8
|
import { IconButton } from './Actions';
|
|
9
9
|
import './drawer-placement.css';
|
|
10
|
+
import './dialog-settings.css';
|
|
11
|
+
import { useFloatingScale, useFloatingSide } from './useFloatingScale';
|
|
12
|
+
|
|
13
|
+
const dialogDismiss = new WeakMap<Element, () => void>();
|
|
10
14
|
|
|
11
15
|
// Keep portal content inside the root theme so tokens and reduced-motion overrides inherit.
|
|
12
16
|
function useScope() {
|
|
@@ -15,17 +19,36 @@ function useScope() {
|
|
|
15
19
|
useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
16
20
|
return { ref, container };
|
|
17
21
|
}
|
|
18
|
-
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode) }
|
|
22
|
+
export interface DialogProps { trigger: ReactElement; title: string; description: string; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
|
|
19
23
|
export function Dialog(props: DialogProps) {
|
|
20
24
|
return <DialogFrame {...props}/>;
|
|
21
25
|
}
|
|
22
|
-
export function Drawer(props: Omit<DialogProps, 'sidePanel'> & { side?: 'left' | 'right' }) { return <DialogFrame {...props} drawer />; }
|
|
23
|
-
function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, drawer = false, side = 'right' }: DialogProps & { drawer?: boolean; side?: 'left' | 'right' }) {
|
|
26
|
+
export function Drawer(props: Omit<DialogProps, 'sidePanel' | 'navigation'> & { side?: 'left' | 'right' }) { return <DialogFrame {...props} drawer />; }
|
|
27
|
+
function DialogFrame({ trigger, title, description, children, open, onOpenChange, sidePanel, navigation, onCloseAutoFocus, drawer = false, side = 'right' }: DialogProps & { drawer?: boolean; side?: 'left' | 'right' }) {
|
|
24
28
|
const { ref, container } = useScope();
|
|
29
|
+
const [localOpen, setLocalOpen] = useState(false);
|
|
30
|
+
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
|
31
|
+
const changeOpen = (next: boolean) => { setLocalOpen(next); onOpenChange?.(next); };
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
if (!content) return;
|
|
34
|
+
dialogDismiss.set(content, () => { setLocalOpen(false); onOpenChange?.(false); });
|
|
35
|
+
return () => { dialogDismiss.delete(content); };
|
|
36
|
+
}, [content, onOpenChange]);
|
|
37
|
+
const escape = (event: KeyboardEvent) => {
|
|
38
|
+
if (event.defaultPrevented || !(event.target instanceof Element)) return;
|
|
39
|
+
const owner = event.target.closest('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]');
|
|
40
|
+
if (!owner || owner === content) return;
|
|
41
|
+
// Radix's top layer may lag a newly focused nested dialog until its passive
|
|
42
|
+
// effect runs. Redirect only that stale callback; keep Tooltip/Menu priority.
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
dialogDismiss.get(owner)?.();
|
|
45
|
+
};
|
|
25
46
|
const sideNavigation = typeof sidePanel === 'function';
|
|
47
|
+
const settings = navigation != null && navigation !== false;
|
|
48
|
+
if (settings && sidePanel) throw new Error('Dialog navigation and sidePanel are mutually exclusive.');
|
|
26
49
|
const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
|
|
27
|
-
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open} onOpenChange={
|
|
28
|
-
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div><DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description><div className="rhine-dialog__body">{children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</DialogBase.Content>
|
|
50
|
+
return <span className="rhine-overlay-anchor" ref={ref}><DialogBase.Root open={open ?? localOpen} onOpenChange={changeOpen}><DialogBase.Trigger asChild>{trigger}</DialogBase.Trigger><DialogBase.Portal container={container}>
|
|
51
|
+
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} data-side={drawer ? side : undefined} className={`rhine-dialog${drawer ? ' rhine-drawer' : ''}${settings ? ' rhine-dialog--settings' : ''}${sidePanel ? ' rhine-dialog--split' : ''}${sideNavigation ? ' rhine-dialog--side-navigation' : ''}`}><div className="rhine-dialog__main"><div className="rhine-dialog__head"><DialogBase.Title className="rhine-text rhine-text--section">{title}</DialogBase.Title>{!sideNavigation && closeButton}</div><DialogBase.Description className="rhine-text rhine-text--body rhine-text--muted">{description}</DialogBase.Description><div className="rhine-dialog__body">{settings ? <div className="rhine-dialog__settings-grid"><div className="rhine-dialog__navigation">{navigation}</div><div className="rhine-dialog__settings-content">{children}</div></div> : children}</div></div>{sidePanel && <div className="rhine-dialog__side">{typeof sidePanel === 'function' ? sidePanel(closeButton) : sidePanel}</div>}</DialogBase.Content>
|
|
29
52
|
</DialogBase.Portal></DialogBase.Root></span>;
|
|
30
53
|
}
|
|
31
54
|
export function ConfirmDialog({ trigger, title, description, confirmLabel = '确认操作', danger = false, onConfirm }: { trigger: ReactElement; title: string; description: string; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void }) {
|
|
@@ -47,14 +70,20 @@ export function ConfirmDialog({ trigger, title, description, confirmLabel = '确
|
|
|
47
70
|
interface OverlayControl { open?: boolean; onOpenChange?: (open: boolean) => void; side?: 'top' | 'right' | 'bottom' | 'left' }
|
|
48
71
|
export function Popover({ trigger, title, children, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; title: string; children: ReactNode }) {
|
|
49
72
|
const { ref, container } = useScope();
|
|
50
|
-
|
|
73
|
+
const floatingRef = useFloatingScale(undefined, 'popover');
|
|
74
|
+
const floatingSide = useFloatingSide(ref, side, 300);
|
|
75
|
+
return <span className="rhine-overlay-anchor" ref={ref}><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Trigger asChild>{trigger}</PopoverBase.Trigger><PopoverBase.Portal container={container}><PopoverBase.Content ref={floatingRef} className="rhine-popover" aria-label={title} side={floatingSide} sideOffset={8} collisionPadding={12}><div className="rhine-dialog__head"><strong>{title}</strong><PopoverBase.Close asChild><IconButton label="关闭详情" icon="close" /></PopoverBase.Close></div>{children}</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></span>;
|
|
51
76
|
}
|
|
52
77
|
export interface MenuItem { id: string; label: string; disabled?: boolean; danger?: boolean; onSelect: () => void }
|
|
53
78
|
export function Menu({ trigger, label, items, open, onOpenChange, side }: OverlayControl & { trigger: ReactElement; label: string; items: MenuItem[] }) {
|
|
54
79
|
const { ref, container } = useScope();
|
|
55
|
-
|
|
80
|
+
const floatingRef = useFloatingScale(undefined, 'dropdown-menu');
|
|
81
|
+
const floatingSide = useFloatingSide(ref, side, 180);
|
|
82
|
+
return <span className="rhine-overlay-anchor" ref={ref}><MenuBase.Root open={open} onOpenChange={onOpenChange}><MenuBase.Trigger asChild>{trigger}</MenuBase.Trigger><MenuBase.Portal container={container}><MenuBase.Content ref={floatingRef} className="rhine-menu" aria-label={label} side={floatingSide} sideOffset={8} collisionPadding={12}>{items.map(item => <MenuBase.Item key={item.id} disabled={item.disabled} onSelect={item.onSelect} className="rhine-menu__item" data-danger={item.danger}>{item.label}</MenuBase.Item>)}</MenuBase.Content></MenuBase.Portal></MenuBase.Root></span>;
|
|
56
83
|
}
|
|
57
84
|
export function Tooltip({ children, content, open, onOpenChange, side }: OverlayControl & { children: ReactElement; content: string }) {
|
|
58
85
|
const { ref, container } = useScope();
|
|
59
|
-
|
|
86
|
+
const floatingRef = useFloatingScale(undefined, 'tooltip');
|
|
87
|
+
const floatingSide = useFloatingSide(ref, side, 280);
|
|
88
|
+
return <span className="rhine-overlay-anchor" ref={ref}><TooltipBase.Provider delayDuration={300}><TooltipBase.Root open={open} onOpenChange={onOpenChange}><TooltipBase.Trigger asChild>{children}</TooltipBase.Trigger><TooltipBase.Portal container={container}><TooltipBase.Content ref={floatingRef} className="rhine-tooltip" side={floatingSide} sideOffset={8} collisionPadding={12}>{content}</TooltipBase.Content></TooltipBase.Portal></TooltipBase.Root></TooltipBase.Provider></span>;
|
|
60
89
|
}
|
package/components/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
本目录提供[莱茵设计 · Rhine Design](../README.md)的 React 组件、设计变量和 API 契约。组件接收应用传入的数据与动作,不绑定特定后端或原生平台。包名为`@linhey/rhine-design`;样式变量和类名使用`rhine-`命名空间。
|
|
4
4
|
|
|
5
|
-
0.6.0
|
|
5
|
+
0.8.0 共66个组件,新增 NeutronStarBackdrop、StatusTimelineList、AppShellToggle 与 ButtonLink;0.6.0 为60个;0.5.0为59个,0.4.0为48个,0.3.0为40个。新增导航、表单流程及健康时间轴组件见[场景与交互规范](../docs/INTERACTION_PATTERNS.md),原24个扩展组件见[常用组件契约](./COMMON_COMPONENTS.md)。[字体与文字规范](./TYPOGRAPHY.md)与[主流系统对照与缺口](../docs/DESIGN_SYSTEM_AUDIT.md)共同约束复用。静态页面样稿也使用同源Toolbar/Select CSS,不运行React。
|
|
6
|
+
|
|
7
|
+
中子星艺术、主题物品和生命周期见[中子星契约](../docs/NEUTRON_STAR_BACKDROP.md)与[轨道背景契约](../docs/ORBIT_BACKDROP.md)。安装版本与源码导出分别核对。
|
|
6
8
|
|
|
7
9
|
页面改动遵循:优先复用组件 → 先在设计稿/展台修改并验证 → 再按任务授权实现到App。缺组件先补组件与展台,不在业务页另写同用途样式;同一任务已授权设计和实现时连续推进,无需重复确认。
|
|
8
10
|
|
|
@@ -51,6 +53,7 @@ export function Example() {
|
|
|
51
53
|
| 组件 | 关键输入 | 状态与边界 |
|
|
52
54
|
| --- | --- | --- |
|
|
53
55
|
| Button | variant=primary/secondary/quiet/danger;busy;busyLabel;原生按钮属性 | 默认 type=button;busy 保留焦点并拦截再次 click,不自动把业务标记成功;disabled 使用原生禁用 |
|
|
56
|
+
| ButtonLink(0.8.0) | 必填 href;原生 anchor 属性与 ref;Button 同款 variant/busy/busyLabel;disabled | 原生导航/下载;disabled 退出 Tab;busy 保焦点;两者移除 href、阻止主/辅助点击;[契约](../docs/COMPACT_ACCOUNT_PATTERNS.md) |
|
|
54
57
|
| Logo | variant=mark/icon/lockup;theme=light/dark/auto;size;animate;decorative;label;wordmark | 第三版三角构架;默认静态、48px、浅色单标志;最小16px;无点击/业务状态 |
|
|
55
58
|
| Text | variant;as;tone;numeric;HTML属性 | 默认p/body;视觉层级不决定HTML语义;长文本可换行;不转换数值或截断文本 |
|
|
56
59
|
| Select | label;hint;error;原生单选select属性;option子节点 | 标签与描述自动关联;原生菜单和键盘;受控或defaultValue;不支持multiple/size列表模式;箭头装饰性且不接收点击 |
|
|
@@ -58,7 +61,7 @@ export function Example() {
|
|
|
58
61
|
| Badge | tone=neutral/success/warning/error;children | 只读短状态,颜色必须配文字,不做可点击按钮 |
|
|
59
62
|
| Surface | HTML div 属性 | 静态分区,无点击语义;不是弹窗或导航 |
|
|
60
63
|
| Background(0.5.0) | roleTone=canvas/content/sidebar/surface/overlay/media;decoration=auto/theme/none;appearance={color,textColor,texture,textureSize};HTML div 属性 | 默认正文纯色;仅画布默认使用主题纹理;局部覆盖不创建主题根或定位上下文;完整配置见[场景背景](../docs/THEMING.md#场景背景050) |
|
|
61
|
-
| SelectionCard | label;selected;onSelectedChange;disabled;children | 原生 checkbox
|
|
64
|
+
| SelectionCard | label;selected;onSelectedChange;disabled;showIndicator=true;children | 原生 checkbox 可取消;是否单选由上层控制;showIndicator=false 仅视觉隐藏勾选框,保留键盘与读屏,卡片边界表达选中;内部不嵌套按钮/链接/其他输入 |
|
|
62
65
|
| TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
|
|
63
66
|
| Tabs | label;items[{value,label,content,disabled?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树 |
|
|
64
67
|
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange | 原生 radio,独立 name 防多组干扰;方向键和禁用行为由浏览器提供 |
|
|
@@ -70,6 +73,8 @@ export function Example() {
|
|
|
70
73
|
|
|
71
74
|
新增24项API与组合示例见[常用组件契约](./COMMON_COMPONENTS.md)。TextField封装单行输入,Select使用原生单选;需要查询候选时用Combobox。复杂浮层采用Radix Dialog、AlertDialog、Popover、DropdownMenu、Tooltip的无样式基础,莱茵设计提供视觉与动效。Toast只处理单条通知;不加入全局队列、表格引擎、图表包或业务数据层。
|
|
72
75
|
|
|
76
|
+
0.8.0 的 `StatusTimelineList` 与 `TimelineLegend compact/items` 提供紧凑历史组合及一行短图例;宿主传入完整规则,悬停/聚焦/点按查看。无参 TimelineLegend 仍兼容既有标签。真实样机 `/#compact-account` 同时验证无框额度摘要、配对事实与渐进展示的凭证操作,见[紧凑组合规范](../docs/COMPACT_ACCOUNT_PATTERNS.md)。npm 0.6.0 不提供这些扩展。
|
|
77
|
+
|
|
73
78
|
浏览状态不等于执行结果;页面使用宿主的真实状态决定selected/busy/notice,视觉反馈不能提前宣称业务完成。
|
|
74
79
|
|
|
75
80
|
## 动效规范
|
|
@@ -151,6 +156,25 @@ export function Example() {
|
|
|
151
156
|
|
|
152
157
|
新增 `Background` 与 `AppShell`,0.5.0 共 59 个组件;npm 0.4.0 仍为 48 个。Background 支持六类 roleTone、auto/theme/none 装饰策略及 appearance 局部覆盖;AppShell 提供组合展台使用的固定顶部操作区与响应式侧栏。完整 API、最小接入、背景滚动及 Portal 边界见[背景与应用外壳契约](../docs/APPLICATION_COMPONENTS.md)。
|
|
153
158
|
|
|
159
|
+
0.8.0 新增可选 `AppShell.contentHeader`:工作区导航常驻正文列顶部,正文独立滚动;组合现有 `AppShellToggle`、`Inline`、标题与操作。0.8.0 媒体组件同步更新主题边界:卡片、标签、查看器工具栏与信息栏跟随宿主主题,仅媒体画布保持中性。以上不包含于 npm 0.6.0,交互与验证边界见[应用组件契约](../docs/APPLICATION_COMPONENTS.md)。
|
|
160
|
+
|
|
154
161
|
## 轨道背景(0.6.0)
|
|
155
162
|
|
|
156
|
-
`OrbitBackdrop`
|
|
163
|
+
`OrbitBackdrop` 自 0.6.0 提供,接收 `HTMLAttributes<HTMLDivElement>` 和 `reducedMotion?: boolean`,包含可选 `children`。0.6.0 提供固定深色轨道艺术与内置暂停 / 继续按钮,Canvas 为 `aria-hidden` 装饰。系统、显式减少动态、祖先偏好、窄窗、页面隐藏、离屏及零尺寸时停帧;恢复沿冻结时间继续,不追赶。Canvas 不可用时保留静态艺术与内容。宿主只组合布局,不复制动画或覆盖组件状态。完整生命周期、主题及接入约束见[轨道背景契约](../docs/ORBIT_BACKDROP.md),真实组件展台为 `/#orbit`。自 0.6.0 起共 60 个组件。
|
|
164
|
+
|
|
165
|
+
0.8.0 新增 `appearance?: 'theme' | 'original'`:莱茵展示暖白纸纹、浅绿折面、轻微振翅与悬浮纸屑的折纸鸟;战术展示带倒角晶面、矿物纹理、琥珀裂纹与漂浮碎片的源石晶簇;橙趣展示有烘焙纹理、糖霜、糖针与糖尘的草莓甜甜圈。三套物品按需加载 Three.js,窄屏可播放,遵循同一暂停、减少动态、隐藏与离屏规则;WebGL 失败回退 Canvas2D,Canvas 不可用时使用本地 SVG。`original` 保留初版完整深色轨道。切换主题复用时钟和绘制器,保留暂停与内容草稿。详见[轨道背景契约](../docs/ORBIT_BACKDROP.md)。
|
|
166
|
+
|
|
167
|
+
0.8.0 新增 `AppShellToggle` 与 `AppShell.controls="inline"`。品牌区/正文区成对放置与可达性要求见[内联控制契约](../docs/APPLICATION_COMPONENTS.md#内联控制按钮080)。
|
|
168
|
+
## 中子星背景(0.8.0)
|
|
169
|
+
|
|
170
|
+
`NeutronStarBackdrop` 接收 `HTMLAttributes<HTMLDivElement>`、`reducedMotion?: boolean`、`appearance?: 'theme' | 'original'` 和可选 `children`。默认从当前主题取得艺术配色,`original` 保留原始深空;切换颜色不重置时间,实体内容控件仍用宿主主题。按需加载 Three.js,保留 28 条磁场线、560 个流粒、700 颗星尘、极向喷流与星核;星系以 28 秒有效播放时间完成一轮深度公转,镜头保持固定。
|
|
171
|
+
|
|
172
|
+
内置暂停 / 继续;系统、显式和祖先减少动态、隐藏、离屏或零尺寸停帧,恢复不追赶。SSR 不访问浏览器绘制环境,异步初始化晚于卸载时不创建渲染器;无 WebGL、导入失败或 context lost 保留静态艺术和业务内容,context restored 按当前偏好恢复,卸载释放监听、帧循环及 GPU 资源。宿主负责尺寸和普通内容布局,正文不模糊;不以装饰状态控制业务。完整用法与验收见[中子星背景契约](../docs/NEUTRON_STAR_BACKDROP.md),真实源码展台为 `/#neutron-star`。
|
|
173
|
+
|
|
174
|
+
内容层只预留底部 `44px + 2 × --rhine-space-4` 控件安全区,不强制正文四边 padding;宿主在自己的 Stack 或页面容器设置边距,避免重复叠加。静态 fallback 保留当前配色的星核、磁场意图,不保证与三维画面逐像素一致。
|
|
175
|
+
|
|
176
|
+
## 0.8.0 补充
|
|
177
|
+
|
|
178
|
+
- [浮层缩放与边界](../docs/FLOATING_OVERLAYS.md):Popover、Menu、Tooltip 与时间轴共享 CSS zoom 修正。
|
|
179
|
+
- [设置 Dialog](../docs/SETTINGS_DIALOG.md):`navigation` 插槽及窄窗/缩放重排。
|
|
180
|
+
- [主题选择](../docs/THEME_PICKER.md):`ThemePicker` / `ThemePreview`,候选源码新增两项组件至 66;旧版安装包仍按自身导出判断。
|