@linhey/rhine-design 0.6.0 → 0.9.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 +39 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AdvancedForms.tsx +38 -8
- package/components/AppShell.tsx +46 -17
- package/components/ListFilterBar.tsx +104 -0
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +11 -3
- package/components/Overlays.tsx +37 -8
- package/components/README.md +28 -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 +12 -5
- package/components/list-filter-bar.css +33 -0
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +100 -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/BACKGROUND_FILTER_VERIFICATION.md +12 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +11 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +10 -2
- package/docs/LIST_FILTER_BAR.md +73 -0
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +61 -44
- 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/BackgroundFiltersFixture.tsx +28 -0
- 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/ListFilterBarGallery.tsx +170 -0
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +49 -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 +19 -3
- package/preview/preview.css +23 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-background-filters.js +58 -0
- package/scripts/check-background-motion.js +169 -0
- package/scripts/check-filter-native-date.js +47 -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/DateNativeInput.test.tsx +147 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/ListFilterBar.test.tsx +198 -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 +273 -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,44 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.9.0(2026-10-24)
|
|
4
|
+
|
|
5
|
+
- #17:OrbitBackdrop 新增 controls、composition 和 framed;背景画布与静态降级同置右下角,新增按容器宽度重排的入口组合,保留输入和减少动态。
|
|
6
|
+
- #19:新增 ListFilterBar,最多两个主筛选,超出进入 Drawer;保留可见搜索标签,统一条件摘要、清除、重置、刷新和请求反馈,业务查询与权限由宿主管理。
|
|
7
|
+
- 修复原生日期未完成片段被当作空值提交的问题;DateRangePicker 清空和同值预设可恢复本地草稿;有效输入继续服从宿主受控值。展台整体重置清除同值日期的未完成草稿。清除最后一个不可聚焦字段的摘要后,焦点保留在筛选区域。
|
|
8
|
+
- 独立验收覆盖三主题、窄容器与 200% CSS zoom、抽屉焦点、日期键盘、真实 GPU 播放与动态偏好、静态降级及卸载清理。
|
|
9
|
+
|
|
10
|
+
## 0.8.0(2026-10-24)
|
|
11
|
+
|
|
12
|
+
- 审查修复:保留 Tooltip 与嵌套 Dialog 的 Escape 层级;主题元数据独立于可选 CSS;设置示例保存中重开时同步已保存值并保留后续编辑。
|
|
13
|
+
- 对应 #18:Dialog 新增 `navigation` 插槽,提供按实际容器宽度重排的设置导航布局,保留嵌套焦点、草稿与宿主状态,并修复快速打开内层后 Escape 误关外层。
|
|
14
|
+
- 对应 #20:新增受控原生单选 `ThemePicker` 和静态 `ThemePreview`;三套官方组件缩略图随包提供,覆盖加载/失败/禁用,主题切换不重挂载业务子树。
|
|
15
|
+
|
|
16
|
+
- 修复 #16:Popover、Menu、Tooltip 和时间轴共用浮层 CSS zoom 归一化;保留文字与点击目标放大,并在边缘限制实际浮层边界。新增文档/主题根/正文缩放的独立文档展台。
|
|
17
|
+
|
|
18
|
+
### 承接未发布的 0.7.0 候选
|
|
19
|
+
|
|
20
|
+
0.7.0 未成功发布至 npm;下列已接受功能随 0.8.0 一并交付,历史候选制品保持不变。
|
|
21
|
+
|
|
22
|
+
- 橙趣草莓甜甜圈增强为按需加载的 Three.js 艺术:立体糖霜、程序烘焙纹理、280 根糖针、110 颗糖珠、100 根环绕糖粒与 460 颗糖尘;共用原版倾角、62%/57%中心与缩放,校准主体尺寸,保留环面旋转、共享暂停与减少动态时钟,并提供 Canvas2D 降级和 GPU 清理。
|
|
23
|
+
- OrbitBackdrop 新增 `appearance="theme" | "original"`:莱茵为折纸鸟(578 个三角折面、扇形褶皱与叠层双翼、暖白纸纹、弧线飞行和随行纸屑),战术为源石晶簇(倒角晶面、矿物纹理、琥珀裂纹和漂浮碎片),橙趣为草莓甜甜圈。三套物品均按需加载 Three.js;窄屏也可播放,共用暂停、减少动态和可见性时钟,切换主题复用已创建的绘制器。WebGL 失败回退 Canvas2D,Canvas 不可用时展示本地 SVG。`original` 保留初版完整深色轨道。
|
|
24
|
+
- 莱茵纸鸟在同一组折面上连续变形成四瓣纸蝴蝶:单程 14 秒,依次收翼、松折、展平停顿、重新折叠,翼根至翼尖错开动作;全周期 42 秒。变形中暂缓扑翼,暂停或减少动态冻结当前形态,恢复后接续;WebGL 与 Canvas2D 共用几何、变形和飞行姿态,静态 SVG 保留初始纸鸟。
|
|
25
|
+
- 新增 `StatusTimelineList` 多条健康历史组合;与 `AppShellToggle`、`NeutronStarBackdrop`、`ButtonLink` 一起将公共组件增至 64。
|
|
26
|
+
- AppShell 新增可选 `contentHeader` 导航插槽,位于正文滚动区外;展台按应用栏模式组合当前页、侧栏入口与上下文操作,窄窗使用菜单入口,保留草稿和焦点。三主题边界同步;默认 header 模式保持兼容。
|
|
27
|
+
- 重做媒体卡片与查看器:卡片边框、类型标签、选中态及独立操作区跟随三套主题;查看器仅画布保持中性深色,工具栏和信息栏使用宿主主题。展台改为自适应资源网格并单列异常状态,修复 CSS zoom 下查看器高度重复放大。
|
|
28
|
+
- 补齐 Neo Brutalism 的 Badge 墨色描边与字重,中性标签使用主题文字色。
|
|
29
|
+
|
|
30
|
+
- 对应 [#14](https://github.com/linhay/rhine-design/issues/14):新增原生 anchor 语义的 `ButtonLink`,复用 Button 视觉及 busy/disabled;移交 `StatusTimelineList` 稳定行/互斥详情组合,并扩展 `TimelineLegend compact/items` 为一行短标签与悬停、键盘、触屏规则详情。无参图例保持兼容。
|
|
31
|
+
- 时间轴与图例详情在祖先 CSS zoom 下归一化定位包装层,保留正文放大与视口内滚动;鼠标离开跨卡详情延迟关闭,移入浮层可继续阅读,不更改键盘或触屏入口。
|
|
32
|
+
- 新增[紧凑摘要与凭证操作](docs/COMPACT_ACCOUNT_PATTERNS.md)及 `#compact-account` 真实样机:无框额度摘要、配对事实、短必要提示、按需历史;消融不减小业务字体、不隐藏未知/失败或混淆不同成本与到期。以上能力随 0.8.0 提供。
|
|
33
|
+
|
|
34
|
+
- AppShell 新增可选 inline 控制模式与 AppShellToggle:侧栏品牌区收起、正文导航区展开/打开抽屉,不再强制独立顶部横栏;默认 header 模式保持兼容。补充焦点迁移、断点和 Escape 回归及真实展台。
|
|
35
|
+
- 对应 [#12](https://github.com/linhay/rhine-design/issues/12):新增 `NeutronStarBackdrop`,承接完整中子星艺术:28 条磁场线、560 个流动粒子、700 颗星尘、极向喷流与星核;星系以 28 秒有效播放时间完成一轮深度公转,镜头固定。默认跟随现有主题 tokens,`appearance="original"` 保留原始深空;主题切换不重置时间,正文与实体控件保持清晰。
|
|
36
|
+
- Three.js 绘制按需加载,内置暂停 / 继续,系统、显式、祖先减少动态及隐藏 / 离屏 / 零尺寸停帧,恢复不追赶。SSR 安全结构、异步卸载保护、GPU 资源清理与静态降级;无 WebGL、导入失败和 context lost 不影响内容,context restored 按当前偏好恢复。
|
|
37
|
+
- 外层流粒与对应磁场线对齐,避免使用生成参数的错误边界裁剪粒子;暂停控件的焦点框绘制于按钮内部,保持原始深空下的键盘焦点可见。
|
|
38
|
+
- 修复长页窄屏中星核随公转移出画幅:艺术层独立限高并渐隐,窄画幅由库内相机取景适配;保留原始几何与时间公式,正文和控件布局不受艺术高度限制。
|
|
39
|
+
- 新增艺术优先的 `#neutron-star` 真实展台,演示控制与内容组合默认折叠;重播不丢表单草稿。[组件契约](docs/NEUTRON_STAR_BACKDROP.md)、API / 任务索引及回归测试同步。此 API 自 0.8.0 提供。
|
|
40
|
+
- 对应 [#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,不新增独立日期筛选组件。
|
|
41
|
+
|
|
3
42
|
## 0.6.0(2026-10-24)
|
|
4
43
|
|
|
5
44
|
- 对应 [#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);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type SyntheticEvent } from 'react';
|
|
2
2
|
import { Button, TextField, type Choice } from './components';
|
|
3
3
|
import { Combobox } from './Forms';
|
|
4
4
|
|
|
@@ -23,9 +23,31 @@ function useDateValidation(error: string | undefined, onValidationChange?: (erro
|
|
|
23
23
|
}
|
|
24
24
|
/** Date-only ISO strings: conversion to timestamps and time zones belongs to the host. */
|
|
25
25
|
export function DateField({ onValueChange, onValidationChange, error, ...props }: DateFieldProps) {
|
|
26
|
-
const
|
|
26
|
+
const [editing, setEditing] = useState({ accepted: props.value, draft: props.value, badInput: false });
|
|
27
|
+
const lastEvent = useRef<Event | null>(null);
|
|
28
|
+
const nativeBadInput = useRef(false);
|
|
29
|
+
let current = editing;
|
|
30
|
+
if (editing.accepted !== props.value) {
|
|
31
|
+
current = { accepted: props.value, draft: props.value, badInput: false };
|
|
32
|
+
nativeBadInput.current = false;
|
|
33
|
+
setEditing(current);
|
|
34
|
+
}
|
|
35
|
+
const invalid = error || (current.badInput ? '请填写完整有效的日期。' : dateError(current.draft, props.min, props.max));
|
|
27
36
|
useDateValidation(invalid, onValidationChange);
|
|
28
|
-
|
|
37
|
+
const change = (event: SyntheticEvent<HTMLInputElement>) => {
|
|
38
|
+
if (props.disabled || props.readOnly || lastEvent.current === event.nativeEvent) return;
|
|
39
|
+
lastEvent.current = event.nativeEvent;
|
|
40
|
+
const { value, validity } = event.currentTarget;
|
|
41
|
+
nativeBadInput.current = validity.badInput;
|
|
42
|
+
// Native date fragments also expose an empty value; they are not a cleared boundary.
|
|
43
|
+
// Only incomplete native fragments are local drafts. Valid values remain
|
|
44
|
+
// controlled by the host, which may reject a proposed change.
|
|
45
|
+
setEditing({ accepted: props.value, draft: validity.badInput ? value : props.value, badInput: validity.badInput });
|
|
46
|
+
if (!validity.badInput) onValueChange(value);
|
|
47
|
+
};
|
|
48
|
+
// Chromium can change an empty calendar fragment without firing input/change.
|
|
49
|
+
const checkFragments = (event: SyntheticEvent<HTMLInputElement>) => { if (event.currentTarget.validity.badInput !== nativeBadInput.current) change(event); };
|
|
50
|
+
return <TextField {...props} value={current.draft} error={invalid} type="date" onInput={change} onChange={change} onKeyUp={checkFragments} onBlur={checkFragments} />;
|
|
29
51
|
}
|
|
30
52
|
export interface DateRange { start: string; end: string }
|
|
31
53
|
export interface DateRangePickerProps {
|
|
@@ -38,16 +60,24 @@ export interface DateRangePickerProps {
|
|
|
38
60
|
}
|
|
39
61
|
export function DateRangePicker({ label, value, onValueChange, min, max, disabled, error, presets = [], readOnly = false, required, clearable = true, hint, startLabel = '开始日期', endLabel = '结束日期', startName, endName, onValidationChange }: DateRangePickerProps) {
|
|
40
62
|
const id = useId();
|
|
63
|
+
const [fieldErrors, setFieldErrors] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
64
|
+
const [resetRevision, setResetRevision] = useState(0);
|
|
41
65
|
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);
|
|
66
|
+
const invalid = error || rangeError(value) || fieldErrors.start || fieldErrors.end || undefined;
|
|
43
67
|
useDateValidation(invalid, onValidationChange);
|
|
68
|
+
const fieldValidation = (field: 'start' | 'end', message: string | null) => setFieldErrors(current => current[field] === message ? current : { ...current, [field]: message });
|
|
69
|
+
const replace = (next: DateRange) => {
|
|
70
|
+
setFieldErrors({ start: null, end: null });
|
|
71
|
+
setResetRevision(revision => revision + 1);
|
|
72
|
+
onValueChange(next);
|
|
73
|
+
};
|
|
44
74
|
return <fieldset className="rhine-date-range" disabled={disabled} aria-describedby={[hint && `${id}-hint`, invalid && id].filter(Boolean).join(' ') || undefined}>
|
|
45
75
|
<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 })}
|
|
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 })} />
|
|
76
|
+
<DateField key={`start-${resetRevision}`} label={startLabel} name={startName} value={value.start} min={min} max={max} readOnly={readOnly} required={required} disabled={disabled} onValueChange={start => onValueChange({ ...value, start })} onValidationChange={message => fieldValidation('start', message)} />
|
|
77
|
+
<DateField key={`end-${resetRevision}`} 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 })} onValidationChange={message => fieldValidation('end', message)} />
|
|
48
78
|
</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={() =>
|
|
50
|
-
{clearable && <Button variant="quiet" disabled={disabled || readOnly || (!value.start && !value.end)} onClick={() =>
|
|
79
|
+
{presets.map(preset => <Button key={preset.label} variant="secondary" disabled={disabled || readOnly || !!rangeError(preset.value) || !!(required && (!preset.value.start || !preset.value.end))} onClick={() => replace(preset.value)}>{preset.label}</Button>)}
|
|
80
|
+
{clearable && <Button variant="quiet" disabled={disabled || readOnly || (!value.start && !value.end && !fieldErrors.start && !fieldErrors.end)} onClick={() => replace({ start: '', end: '' })}>清空日期</Button>}
|
|
51
81
|
</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
82
|
</fieldset>;
|
|
53
83
|
}
|
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
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useRef, useState, type InputHTMLAttributes, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { Button, Notice, Surface, TextField } from './components';
|
|
3
|
+
import { Drawer } from './Overlays';
|
|
4
|
+
import './list-filter-bar.css';
|
|
5
|
+
|
|
6
|
+
export interface ListFilterField {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
control: ReactNode;
|
|
10
|
+
activeLabel?: string;
|
|
11
|
+
onClear?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ListFilterBarProps {
|
|
15
|
+
label: string;
|
|
16
|
+
search?: InputHTMLAttributes<HTMLInputElement> & { label: string };
|
|
17
|
+
primary?: readonly ListFilterField[];
|
|
18
|
+
filters?: readonly ListFilterField[];
|
|
19
|
+
onReset?: () => void;
|
|
20
|
+
refresh?: { onClick: () => void; busy?: boolean; disabled?: boolean };
|
|
21
|
+
action?: ReactElement;
|
|
22
|
+
result?: ReactNode;
|
|
23
|
+
pending?: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Feedback({ result, pending, error, announce = true }: Pick<ListFilterBarProps, 'result' | 'pending' | 'error'> & { announce?: boolean }) {
|
|
29
|
+
return <>
|
|
30
|
+
{(result != null || pending) && <div className="rhine-list-filter-bar__feedback" role="status" aria-live={announce ? 'polite' : 'off'}>
|
|
31
|
+
{pending && <span>正在更新结果…</span>}
|
|
32
|
+
{result != null && <div>{result}</div>}
|
|
33
|
+
</div>}
|
|
34
|
+
{error && <Notice tone="error" title="未能更新结果">{error}</Notice>}
|
|
35
|
+
</>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const focusable = 'input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), a[href]';
|
|
39
|
+
|
|
40
|
+
export function ListFilterBar({ label, search, primary = [], filters = [], onReset, refresh, action, result, pending = false, error, className = '' }: ListFilterBarProps) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const root = useRef<HTMLDivElement>(null);
|
|
43
|
+
const drawerBody = useRef<HTMLDivElement>(null);
|
|
44
|
+
const visibleFields = primary.slice(0, 2);
|
|
45
|
+
const drawerFields = [...primary.slice(2), ...filters];
|
|
46
|
+
const activeFields = [...primary, ...filters].filter(field => field.activeLabel?.trim());
|
|
47
|
+
const activeDrawerCount = activeFields.filter(field => drawerFields.some(item => item.id === field.id)).length;
|
|
48
|
+
const hasQuery = search?.value != null && String(search.value).trim() !== '';
|
|
49
|
+
const canReset = Boolean(onReset && (activeFields.length > 0 || hasQuery));
|
|
50
|
+
|
|
51
|
+
function stableFocus() {
|
|
52
|
+
const scope = open ? drawerBody.current : root.current?.querySelector('.rhine-list-filter-bar__main');
|
|
53
|
+
const target = scope?.querySelector<HTMLElement>(focusable);
|
|
54
|
+
(target ?? root.current)?.focus();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function reset() {
|
|
58
|
+
stableFocus();
|
|
59
|
+
onReset?.();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clear(field: ListFilterField) {
|
|
63
|
+
// A removed chip must not leave keyboard focus on the document body.
|
|
64
|
+
const control = Array.from(root.current?.querySelectorAll<HTMLElement>('[data-filter-id]') ?? [])
|
|
65
|
+
.find(node => node.dataset.filterId === field.id)?.querySelector<HTMLElement>(focusable);
|
|
66
|
+
if (control) control.focus(); else stableFocus();
|
|
67
|
+
field.onClear?.();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return <div ref={root} tabIndex={-1} className={`rhine-list-filter-bar ${className}`}>
|
|
71
|
+
<Surface role="group" aria-label={label} className="rhine-list-filter-bar__surface">
|
|
72
|
+
<div className="rhine-list-filter-bar__main">
|
|
73
|
+
{search && <div className="rhine-list-filter-bar__search"><TextField type="search" {...search} /></div>}
|
|
74
|
+
{visibleFields.length > 0 && <div className="rhine-list-filter-bar__primary">
|
|
75
|
+
{visibleFields.map(field => <div key={field.id} data-filter-id={field.id} className="rhine-list-filter-bar__field" role="group" aria-label={field.label}>{field.control}</div>)}
|
|
76
|
+
</div>}
|
|
77
|
+
{(drawerFields.length > 0 || refresh || action) && <div className="rhine-list-filter-bar__actions">
|
|
78
|
+
{drawerFields.length > 0 && <Drawer
|
|
79
|
+
title={label}
|
|
80
|
+
description="更改后立即更新结果。"
|
|
81
|
+
open={open}
|
|
82
|
+
onOpenChange={setOpen}
|
|
83
|
+
trigger={<Button variant="secondary" aria-label={activeDrawerCount > 0 ? `更多筛选,已选 ${activeDrawerCount} 项` : undefined}>更多筛选{activeDrawerCount > 0 && <span className="rhine-list-filter-bar__count" aria-hidden="true">{activeDrawerCount}</span>}</Button>}
|
|
84
|
+
>
|
|
85
|
+
<div ref={drawerBody} className="rhine-list-filter-bar__drawer">
|
|
86
|
+
<Feedback result={result} pending={pending} error={error} />
|
|
87
|
+
{drawerFields.map(field => <div key={field.id} data-filter-id={field.id} className="rhine-list-filter-bar__field" role="group" aria-label={field.label}>{field.control}</div>)}
|
|
88
|
+
{canReset && <div className="rhine-list-filter-bar__drawer-actions"><Button variant="quiet" onClick={reset}>重置筛选</Button></div>}
|
|
89
|
+
</div>
|
|
90
|
+
</Drawer>}
|
|
91
|
+
{refresh && <Button variant="secondary" onClick={refresh.onClick} busy={refresh.busy} disabled={refresh.disabled} busyLabel="正在刷新">刷新</Button>}
|
|
92
|
+
{action && <div className="rhine-list-filter-bar__action">{action}</div>}
|
|
93
|
+
</div>}
|
|
94
|
+
</div>
|
|
95
|
+
{(activeFields.length > 0 || canReset) && <div className="rhine-list-filter-bar__active" role="group" aria-label="已选条件">
|
|
96
|
+
{activeFields.map(field => field.onClear
|
|
97
|
+
? <Button key={field.id} variant="quiet" className="rhine-list-filter-bar__chip" onClick={() => clear(field)} aria-label={`清除${field.label}:${field.activeLabel}`}><span>{field.label}:{field.activeLabel}</span><span aria-hidden="true">×</span></Button>
|
|
98
|
+
: <span key={field.id} className="rhine-list-filter-bar__summary">{field.label}:{field.activeLabel}</span>)}
|
|
99
|
+
{canReset && <Button variant="quiet" onClick={reset}>重置筛选</Button>}
|
|
100
|
+
</div>}
|
|
101
|
+
<Feedback result={result} pending={pending} error={error} announce={!open} />
|
|
102
|
+
</Surface>
|
|
103
|
+
</div>;
|
|
104
|
+
}
|
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,19 @@ 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;
|
|
10
|
+
/** Omits the local pause control without changing motion preferences. */
|
|
11
|
+
controls?: boolean;
|
|
12
|
+
/** Reserves the upper content area by moving the artwork into the lower right. */
|
|
13
|
+
composition?: 'full' | 'corner';
|
|
14
|
+
/** Removes panel decoration when used as a page background. */
|
|
15
|
+
framed?: boolean;
|
|
8
16
|
}
|
|
9
17
|
|
|
10
|
-
export function OrbitBackdrop({ reducedMotion = false, children, className = '', ...props }: OrbitBackdropProps) {
|
|
18
|
+
export function OrbitBackdrop({ appearance = 'theme', reducedMotion = false, controls = true, composition = 'full', framed = true, children, className = '', ...props }: OrbitBackdropProps) {
|
|
11
19
|
const canvas = useRef<HTMLCanvasElement>(null);
|
|
12
20
|
const renderer = useRef<OrbitRenderer | null>(null);
|
|
13
21
|
const [paused, setPaused] = useState(false);
|
|
@@ -25,9 +33,9 @@ export function OrbitBackdrop({ reducedMotion = false, children, className = '',
|
|
|
25
33
|
const label = state === 'reduced' ? '已减少动态' : state === 'compact' ? '窄屏静态画面'
|
|
26
34
|
: canControl ? paused ? '继续动效' : '暂停动效' : '静态画面';
|
|
27
35
|
|
|
28
|
-
return <div {...props} className={`rhine-orbit ${className}`} data-rhine-orbit-state={state}>
|
|
36
|
+
return <div {...props} className={`rhine-orbit ${className}`} data-rhine-orbit-appearance={appearance} data-rhine-orbit-state={state} data-rhine-orbit-controls={controls} data-rhine-orbit-composition={composition} data-rhine-orbit-framed={framed}>
|
|
29
37
|
<canvas ref={canvas} className="rhine-orbit-canvas" aria-hidden="true" />
|
|
30
38
|
<div className="rhine-orbit-content">{children}</div>
|
|
31
|
-
<div className="rhine-orbit-controls"><Button variant="secondary" disabled={!canControl} onClick={() => setPaused(value => !value)}>{label}</Button></div>
|
|
39
|
+
{controls && <div className="rhine-orbit-controls"><Button variant="secondary" disabled={!canControl} onClick={() => setPaused(value => !value)}>{label}</Button></div>}
|
|
32
40
|
</div>;
|
|
33
41
|
}
|