@linhey/rhine-design 0.9.1 → 0.11.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 +21 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +18 -15
- package/components/AdvancedForms.tsx +22 -8
- package/components/COMMON_COMPONENTS.md +6 -3
- package/components/Forms.tsx +14 -1
- package/components/ListFilterBar.tsx +5 -3
- package/components/Overlays.tsx +3 -2
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/TYPOGRAPHY.md +2 -0
- package/components/Toast.tsx +10 -4
- package/components/checkbox-row.css +12 -0
- package/components/common.css +3 -1
- package/components/compact-forms.css +20 -0
- package/components/components.tsx +13 -7
- package/components/index.ts +1 -0
- package/components/media-parity.css +4 -4
- package/components/patterns.css +3 -3
- package/components/styles.css +17 -2
- package/components/theme-picker.css +2 -2
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/CHECKBOX_ROWS.md +36 -0
- package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
- package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/INTERACTION_PATTERNS.md +9 -1
- package/docs/LIST_FILTER_BAR.md +5 -1
- package/docs/REVIEW_TOOL.md +128 -0
- package/docs/REVIEW_VERIFICATION.md +56 -0
- package/docs/SUMMARY_TABS.md +77 -0
- package/docs/THEMING.md +1 -1
- package/docs/assets/disclosure/arknights-400.png +0 -0
- package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
- package/docs/assets/disclosure/rhine-400.png +0 -0
- package/docs/assets/disclosure/rhine-native200.png +0 -0
- package/examples/review/http/App.tsx +46 -0
- package/examples/review/http/catalog.tsx +44 -0
- package/examples/review/http/details.ts +1 -0
- package/examples/review/http/vite.config.ts +8 -0
- package/examples/review/platform/Workspace.tsx +34 -0
- package/examples/review/platform/catalog.tsx +46 -0
- package/examples/review/platform/vite.config.ts +8 -0
- package/examples/review/product.css +3 -0
- package/package.json +17 -5
- package/preview/CheckboxRowGallery.tsx +26 -0
- package/preview/CommonGallery.tsx +1 -1
- package/preview/CompactFilterBarGallery.tsx +19 -0
- package/preview/ComponentTypographyGallery.tsx +23 -0
- package/preview/DenseFilterControlsGallery.tsx +21 -0
- package/preview/GalleryShell.tsx +4 -3
- package/preview/OrbitGallery.tsx +1 -1
- package/preview/SpacingGallery.tsx +9 -1
- package/preview/SummaryTabsGallery.tsx +37 -0
- package/preview/main.tsx +14 -3
- package/preview/packageInfo.ts +5 -0
- package/review/canvas.tsx +107 -0
- package/review/dev-client.mjs +17 -0
- package/review/feedback.ts +17 -0
- package/review/index.ts +2 -0
- package/review/isolation.ts +64 -0
- package/review/lifecycle.ts +59 -0
- package/review/protocol.ts +7 -0
- package/review/review.css +41 -0
- package/review/shell.tsx +165 -0
- package/review/state.ts +41 -0
- package/review/types.ts +59 -0
- package/review/vite.d.mts +10 -0
- package/review/vite.mjs +107 -0
- package/scripts/check-checkbox-rows.js +65 -0
- package/scripts/check-component-typography.js +76 -0
- package/scripts/check-dense-filters.js +60 -0
- package/scripts/check-review-package.mjs +11 -0
- package/scripts/check-review-recovery.js +47 -0
- package/scripts/check-review.js +153 -0
- package/skills/rhine-design/SKILL.md +2 -2
- package/skills/rhine-design/references/API_MAP.md +6 -0
- package/skills/rhine-design/references/TASK_MAP.md +2 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/CheckboxRow.test.tsx +53 -0
- package/tests/CommonComponents.test.tsx +8 -0
- package/tests/CompactFilterBar.test.tsx +50 -0
- package/tests/DenseFilterControls.test.tsx +79 -0
- package/tests/DesignSystem.test.tsx +32 -0
- package/tests/Review.test.tsx +117 -0
- package/tests/ReviewBuild.test.tsx +59 -0
- package/tests/ReviewShell.test.tsx +63 -0
- package/tsconfig.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.11.0(2026-10-27)
|
|
4
|
+
|
|
5
|
+
本版本新增产品评审工具及以下已合并能力;npm 0.10.0 不包含这些新增 API。组件主入口仍为 67 项,默认行为保持兼容;消费方固定 0.11.0 并提交 lockfile。实际发布可用性以 registry 为准。
|
|
6
|
+
|
|
7
|
+
- #40:新增 `/review`、`/review/vite` 产品评审工具,提供场景协议、目录/控件、URL 状态、合成 iframe、生命周期、独立静态构建与正式包排除检查;附 HTTP 与平台 client 两类例子,静态资源支持项目型站点的嵌套部署路径;补充连接/清理超时恢复、持久错误反馈、折叠高级设置与实际画布尺寸。已在两类真实 App 的隔离副本试用,消费迁移按产品分别验证,见[契约](docs/REVIEW_TOOL.md)。
|
|
8
|
+
|
|
9
|
+
- `Tabs` / `TabItem` 新增 `description`、`status`,提供带身份摘要与只读状态的页签;契约见[Summary Tabs](docs/SUMMARY_TABS.md),展台为 `#summary-tabs`。
|
|
10
|
+
- `Toast` 新增 `placement="floating"`,通过主题根 Portal 呈现浮动通知;默认 inline 保持兼容,见[通知契约](components/COMMON_COMPONENTS.md)。
|
|
11
|
+
- `SegmentedControl` 新增 `size="compact"`,支持卡片局部指标切换;见[间距规范](components/SPACING.md)。0.10.0 已有的 `fullWidth` 不受影响。
|
|
12
|
+
- 相邻 `Disclosure` 共用分隔线;补充上下文帮助与主题背景/文字配对规范。
|
|
13
|
+
- 修复 `Disclosure` 在生产 CSS 将 `200ms` 压缩为 `.2s` 时误用 0.2ms 动画的问题;按单位换算为毫秒,保持 200ms 开合、快速反向和减少动态行为。
|
|
14
|
+
- 展台首页和页脚按当前主入口统一为 67 个组件,源码包版本改从 `package.json` 读取;修正轨道主题艺术的版本标记及文档中的旧数量、候选状态。
|
|
15
|
+
|
|
16
|
+
## 0.10.0(2026-10-26)
|
|
17
|
+
|
|
18
|
+
- #31:MediaViewer 说明补齐 caption 12/18/400,EmptyState 标题采用 heading 16/24 与主题标题字重;同类复制反馈、步骤/表单组说明、错误摘要和主题预览状态补齐语义配对,保留既有密度例外。真实回归入口 `#component-typography`。
|
|
19
|
+
- #29:Checkbox 新增可选 `layout="row"` 与公开 `CheckboxProps`,提供窄侧栏双行文字对齐、对称内距、整行原生点击和主题选择/焦点,保留默认表单布局;展台见 `#checkbox-rows`。
|
|
20
|
+
- #27:SegmentedControl 新增等宽 `fullWidth`;ListFilterBar 支持用 `drawerDescription` / `drawerResult` 省略重复说明与摘要,保留加载和错误反馈。
|
|
21
|
+
- DateRangePicker 新增 `compact` 一体日期边框,窄容器内部重排;RemoteMultiSelect 新增 `maxVisibleTags`、总数与有界展开,保留完整表单值和键盘移除,极窄容器标签换行,清空后重新选择恢复折叠。
|
|
22
|
+
- 新增 `#compact-filters` 真实组件展台与回归。上述能力自 0.10.0 提供,默认行为保持兼容,无需迁移;启用新选项需升级。修复分别见 [PR #28](https://github.com/linhay/rhine-design/pull/28)、[PR #30](https://github.com/linhay/rhine-design/pull/30) 和 [PR #32](https://github.com/linhay/rhine-design/pull/32)。
|
|
23
|
+
|
|
3
24
|
## 0.9.1(2026-10-25)
|
|
4
25
|
|
|
5
26
|
- #23:移除 StatusTimelineList 末项重复留白,保留 44px 命中区;紧凑账号展台的基础信息与今日/本周期读数按稳定列对齐,窄容器自然换行。
|
package/CONTRIBUTING.md
CHANGED
|
@@ -25,7 +25,7 @@ npm run dev
|
|
|
25
25
|
npm run check
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
它包含测试、类型检查、展台构建和 CSS/SVG
|
|
28
|
+
它包含测试、类型检查、展台构建和 CSS/SVG 产物检查,并构建两个独立产品评审示例。产品评审入口、模拟生命周期与浏览器脚本见 [评审工具](docs/REVIEW_TOOL.md) 和 [验收记录](docs/REVIEW_VERIFICATION.md)。视觉变化还需检查 400/520/880 宽度、200% 缩放和相关交互。纯文档修改检查链接与示例即可。
|
|
29
29
|
|
|
30
30
|
`dist/` 是展台构建结果;包导出源码。修改打包配置时,请在仓库外安装实际 tarball 验证。不要删除入口和 CSS 的 `sideEffects`;Logo 依赖 SVG `?no-inline`。本地链接消费项目时,Vite 使用 `resolve.dedupe: ['react', 'react-dom']` 避免重复 React。
|
|
31
31
|
|
|
@@ -35,6 +35,8 @@ README 面向首次接触项目的开发者,优先解释用途、安装、示
|
|
|
35
35
|
|
|
36
36
|
组件 API 变更时,更新契约、示例以及 skill 的 `references/API_MAP.md` 或 `TASK_MAP.md`。索引指向真实源码,不复制第二份 API 定义;skill 的版本信息随包版本更新。
|
|
37
37
|
|
|
38
|
+
展台包版本由 `preview/packageInfo.ts` 从 `package.json` 读取;组件数量只统计 `components/index.ts` 的公开组件,不计类型与独立主题入口。新增导出时同步该数量、README 完整清单与组件入口。发布后的源码新增能力写入 CHANGELOG 的“未发布”部分,并在相关契约标明旧包不包含;不要因为源码仍使用同一版本字段,就将新能力归入已发布版本。历史版本介绍与验收记录保留原时点。
|
|
39
|
+
|
|
38
40
|
## 提交 Pull Request
|
|
39
41
|
|
|
40
42
|
说明解决的问题、变更后的行为,以及已执行的验证。涉及视觉或动效时附上截图或录屏;涉及破坏性 API 变更时提供迁移说明并更新 [CHANGELOG](CHANGELOG.md)。
|
package/README.md
CHANGED
|
@@ -16,29 +16,25 @@
|
|
|
16
16
|
<a href="CHANGELOG.md">版本记录</a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
-
Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、
|
|
19
|
+
Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、67 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
|
|
20
20
|
|
|
21
21
|
- **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
|
|
22
|
-
- **
|
|
22
|
+
- **67 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
|
|
23
23
|
- **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
|
|
24
24
|
- **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
|
|
25
25
|
|
|
26
|
-
**0.4.0 新增能力:**主题 API、三套内置主题,以及导航、表单流程与健康时间轴组件;在线展台跟随 main,安装前请核对[版本记录](CHANGELOG.md)。新增组件及选型见[交互规范](docs/INTERACTION_PATTERNS.md),参考项目见[GitHub 调研](docs/REFERENCE_RESEARCH.md)。
|
|
27
|
-
|
|
28
26
|
基于 React 与 Radix UI,采用 [Apache-2.0](LICENSE) 许可证。
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
**0.8.0 新增能力**:三主题 Three.js 艺术分别为折纸鸟、源石晶簇与草莓甜甜圈,保留原始轨道切换;新增 `NeutronStarBackdrop`、`StatusTimelineList` 、`AppShellToggle` 与 `ButtonLink`,公共组件总数为 66。包含暂停、减少动态、静态降级与资源清理。安装前核对[版本记录](CHANGELOG.md)与 registry;在线展台跟随 main。
|
|
28
|
+
## 版本与源码范围
|
|
33
29
|
|
|
34
|
-
0.
|
|
30
|
+
- **本源码包版本:0.11.0**。主入口导出 67 个组件,统计不含类型及独立 `/theme` 入口的 ThemeProvider、hook 和元数据。发布是否可安装以 npm registry 为准。
|
|
31
|
+
- **0.11.0 新增**:产品评审 `/review`、`/review/vite`,摘要页签 `Tabs description/status`、`Toast placement="floating"`、`SegmentedControl size="compact"` 及相邻 Disclosure 分隔线与生产动效修正。npm 0.10.0 不包含这些新增 API。
|
|
32
|
+
- **展台随源码更新**,在线部署跟随 main;安装包的能力以精确版本和实际导出/实现为准。上述源码变更、历史版本与接入边界统一见[版本记录](CHANGELOG.md)。
|
|
35
33
|
|
|
36
|
-
|
|
34
|
+
三套主题、轨道艺术、设置导航与主题选择等已有能力见下方组件和规范入口;应用组件的 Ant Design / MUI 对照见[对标清单](docs/ANTD_MUI_AUDIT.md)。
|
|
37
35
|
|
|
38
36
|
<a id="quick-start"></a>
|
|
39
37
|
|
|
40
|
-
**0.8.0 新增**:新增 [主题预览与选择](docs/THEME_PICKER.md)、[设置弹窗导航](docs/SETTINGS_DIALOG.md)及[浮层缩放修复](docs/FLOATING_OVERLAYS.md)。0.7.0 候选未发布;本版承接其全部已接受功能。
|
|
41
|
-
|
|
42
38
|
## 快速接入
|
|
43
39
|
|
|
44
40
|
需要 **React 19**。当前已验证的构建环境为 **Vite**,开发建议使用 **Node 24**。
|
|
@@ -46,12 +42,12 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
46
42
|
在你的 React 项目中安装:
|
|
47
43
|
|
|
48
44
|
```sh
|
|
49
|
-
npm install --save-exact @linhey/rhine-design@0.
|
|
45
|
+
npm install --save-exact @linhey/rhine-design@0.11.0
|
|
50
46
|
```
|
|
51
47
|
|
|
52
48
|
包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
|
|
53
49
|
|
|
54
|
-
**0.
|
|
50
|
+
**0.10.0 新增与修复**:紧凑日期、有限多选标签与等宽分段控件;可省略重复说明的筛选抽屉;侧栏双行 `Checkbox layout="row"`;MediaViewer、EmptyState 等组件的字号/行高/字重配对。默认行为保持兼容,完整说明见[版本记录](CHANGELOG.md#01002026-10-26)。
|
|
55
51
|
|
|
56
52
|
```tsx
|
|
57
53
|
import { Button, Inline, Stack, Surface, Text } from '@linhey/rhine-design';
|
|
@@ -135,13 +131,14 @@ export function App() {
|
|
|
135
131
|
| 品牌与文字 | Logo · Text |
|
|
136
132
|
| 操作 | Button · ButtonLink⁴ · Icon · IconButton · Link · CopyButton |
|
|
137
133
|
| 布局与背景 | Surface · Toolbar · Divider · Stack · Inline · Grid · Background² · AppShell² · AppShellToggle⁴ · OrbitBackdrop³ · NeutronStarBackdrop⁴ |
|
|
138
|
-
| 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard · DateField² · DateRangePicker² · NumberField² · TreeSelect² · RemoteMultiSelect² |
|
|
134
|
+
| 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard · DateField² · DateRangePicker² · NumberField² · TreeSelect² · RemoteMultiSelect² · ThemePicker⁴ · ThemePreview⁴ |
|
|
139
135
|
| 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
|
|
136
|
+
| 搜索与筛选组合 | ListFilterBar⁵ |
|
|
140
137
|
| 数据与媒体 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · StatusTimelineList⁴ · TimelineLegend¹ · TrendChart² · StackedBar² · MediaCard² · MediaViewer² |
|
|
141
138
|
| 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
|
|
142
139
|
| 状态与反馈 | Badge · Notice · Toast · Progress · Skeleton · EmptyState |
|
|
143
140
|
|
|
144
|
-
¹ 自 0.4.0 提供。² 自 0.5.0 提供。³ 自 0.6.0 提供。⁴ 自 0.8.0 提供。
|
|
141
|
+
¹ 自 0.4.0 提供。² 自 0.5.0 提供。³ 自 0.6.0 提供。⁴ 自 0.8.0 提供。⁵ 自 0.9.0 提供。
|
|
145
142
|
|
|
146
143
|
参数、状态和组合示例见[组件 API](components/README.md)与[常用组件契约](components/COMMON_COMPONENTS.md)。
|
|
147
144
|
|
|
@@ -208,6 +205,12 @@ skill 中提供以下参考入口:
|
|
|
208
205
|
|
|
209
206
|
升级组件时请同步更新 skill;如果已做本地定制,先比较差异再合并。其他工具请将同一 skill 目录放入其支持的技能路径。
|
|
210
207
|
|
|
208
|
+
## 产品设计稿评审工具(自 0.11.0)
|
|
209
|
+
|
|
210
|
+
新增独立 `/review` 与 `/review/vite` 入口,统一产品场景目录、主题/尺寸/状态控制、URL 分享、隔离画布和静态构建。产品保留页面、HTTP/平台模拟与业务知识,无需复制通用预览壳。**npm 0.10.0 尚不包含此工具**。
|
|
211
|
+
|
|
212
|
+
源码可运行 `npm run review:http`、`npm run review:platform`,或 `npm run review:build` 构建两个合成消费示例。接入、生命周期、部署与迁移见[产品评审契约](docs/REVIEW_TOOL.md),实际覆盖与剩余边界见[验收记录](docs/REVIEW_VERIFICATION.md)。
|
|
213
|
+
|
|
211
214
|
## 参与贡献
|
|
212
215
|
|
|
213
216
|
欢迎提交问题、文档改进和组件提案。遇到问题时,请在 [Issues](https://github.com/linhay/rhine-design/issues) 提供组件名、版本、预期行为及最小复现。
|
|
@@ -1,6 +1,7 @@
|
|
|
1
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
|
+
import './compact-forms.css';
|
|
4
5
|
|
|
5
6
|
export interface DateFieldProps {
|
|
6
7
|
label: string; value: string; onValueChange: (value: string) => void;
|
|
@@ -55,10 +56,11 @@ export interface DateRangePickerProps {
|
|
|
55
56
|
min?: string; max?: string; disabled?: boolean; error?: string;
|
|
56
57
|
presets?: { label: string; value: DateRange }[];
|
|
57
58
|
readOnly?: boolean; required?: boolean; clearable?: boolean; hint?: string;
|
|
59
|
+
compact?: boolean;
|
|
58
60
|
startLabel?: string; endLabel?: string; startName?: string; endName?: string;
|
|
59
61
|
onValidationChange?: (error: string | null) => void;
|
|
60
62
|
}
|
|
61
|
-
export function DateRangePicker({ label, value, onValueChange, min, max, disabled, error, presets = [], readOnly = false, required, clearable = true, hint, startLabel = '开始日期', endLabel = '结束日期', startName, endName, onValidationChange }: DateRangePickerProps) {
|
|
63
|
+
export function DateRangePicker({ label, value, onValueChange, min, max, disabled, error, presets = [], readOnly = false, required, clearable = true, compact = false, hint, startLabel = '开始日期', endLabel = '结束日期', startName, endName, onValidationChange }: DateRangePickerProps) {
|
|
62
64
|
const id = useId();
|
|
63
65
|
const [fieldErrors, setFieldErrors] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
64
66
|
const [resetRevision, setResetRevision] = useState(0);
|
|
@@ -71,14 +73,15 @@ export function DateRangePicker({ label, value, onValueChange, min, max, disable
|
|
|
71
73
|
setResetRevision(revision => revision + 1);
|
|
72
74
|
onValueChange(next);
|
|
73
75
|
};
|
|
74
|
-
return <div className="rhine-date-range-container"><fieldset className=
|
|
76
|
+
return <div className="rhine-date-range-container"><fieldset className={`rhine-date-range${compact ? ' rhine-date-range--compact' : ''}`} disabled={disabled} aria-describedby={[hint && `${id}-hint`, invalid && id].filter(Boolean).join(' ') || undefined}>
|
|
75
77
|
<legend>{label}</legend><div className="rhine-date-range__fields">
|
|
76
78
|
<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)} />
|
|
79
|
+
{compact && <span className="rhine-date-range__separator" aria-hidden="true">至</span>}
|
|
77
80
|
<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)} />
|
|
78
|
-
</div
|
|
81
|
+
</div>{(presets.length > 0 || clearable) && <div className="rhine-inline">
|
|
79
82
|
{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
83
|
{clearable && <Button variant="quiet" disabled={disabled || readOnly || (!value.start && !value.end && !fieldErrors.start && !fieldErrors.end)} onClick={() => replace({ start: '', end: '' })}>清空日期</Button>}
|
|
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>}
|
|
84
|
+
</div>}{hint && <p id={`${id}-hint`} className="rhine-field__hint">{hint}</p>}{invalid && <p id={id} role="alert" className="rhine-field__error">{invalid}</p>}
|
|
82
85
|
</fieldset></div>;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -125,19 +128,30 @@ export interface RemoteMultiSelectProps {
|
|
|
125
128
|
loading?: boolean; error?: string; disabled?: boolean;
|
|
126
129
|
readOnly?: boolean; clearable?: boolean; closeOnSelect?: boolean; clearQueryOnSelect?: boolean;
|
|
127
130
|
placeholder?: string; hint?: string; name?: string;
|
|
131
|
+
maxVisibleTags?: number;
|
|
128
132
|
}
|
|
129
|
-
export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, ...props }: RemoteMultiSelectProps) {
|
|
133
|
+
export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, maxVisibleTags, ...props }: RemoteMultiSelectProps) {
|
|
130
134
|
const inputId = useId();
|
|
135
|
+
const [expanded, setExpanded] = useState(false);
|
|
136
|
+
const limit = maxVisibleTags !== undefined && Number.isFinite(maxVisibleTags) ? Math.max(0, Math.floor(maxVisibleTags)) : undefined;
|
|
137
|
+
const collapsible = limit !== undefined && value.length > limit;
|
|
138
|
+
// Clearing or replacing the selection below the limit starts a new compact view.
|
|
139
|
+
if (expanded && !collapsible) setExpanded(false);
|
|
140
|
+
const shown = collapsible && !expanded ? value.slice(0, limit) : value;
|
|
131
141
|
const focusInput = () => document.getElementById(inputId)?.focus();
|
|
132
142
|
const remove = (key: string) => {
|
|
133
143
|
if (props.disabled || readOnly || value.find(item => item.value === key)?.disabled) return;
|
|
134
144
|
onValueChange(value.filter(item => item.value !== key)); focusInput();
|
|
135
145
|
};
|
|
136
|
-
return <div className=
|
|
146
|
+
return <div className={`rhine-multi-select${limit !== undefined ? ' rhine-multi-select--compact' : ''}`}>
|
|
137
147
|
<Combobox {...props} id={inputId} readOnly={readOnly} selectedValues={value.map(item => item.value)} closeOnSelect={closeOnSelect} onEmptyBackspace={() => { const last = [...value].reverse().find(item => !item.disabled); if (last) remove(last.value); }} options={options.map(option => ({ ...option, disabled: option.disabled || value.some(item => item.value === option.value) }))} value={null} onValueChange={option => { const original = options.find(item => item.value === option.value); if (original && !value.some(item => item.value === original.value)) { onValueChange([...value, original]); if (clearQueryOnSelect) props.onQueryChange(''); } }} />
|
|
138
148
|
{name && value.map(item => <input key={item.value} type="hidden" name={name} value={item.value} disabled={props.disabled} />)}
|
|
139
|
-
<ul className="rhine-multi-select__tags" aria-label={`${props.label}已选项`}>{
|
|
140
|
-
|
|
149
|
+
<ul id={`${inputId}-tags`} className="rhine-multi-select__tags" aria-label={`${props.label}已选项`} data-expanded={collapsible && expanded || undefined}>{shown.map(item => <li key={item.value}><span>{item.label}</span><Button variant="quiet" disabled={props.disabled || readOnly || item.disabled} aria-label={`移除${item.label}`} onClick={() => remove(item.value)}>×</Button></li>)}</ul>
|
|
150
|
+
<div className="rhine-multi-select__actions">
|
|
151
|
+
{limit !== undefined && <span className="rhine-text rhine-text--caption rhine-text--muted">已选 {value.length} 项</span>}
|
|
152
|
+
{collapsible && <Button variant="quiet" aria-expanded={expanded} aria-controls={`${inputId}-tags`} onClick={() => setExpanded(current => !current)}>{expanded ? '收起已选项' : '展开已选项'}</Button>}
|
|
153
|
+
{clearable && <Button variant="quiet" disabled={props.disabled || readOnly || !value.some(item => !item.disabled)} onClick={() => { onValueChange(value.filter(item => item.disabled)); focusInput(); }}>清空选择</Button>}
|
|
154
|
+
</div>
|
|
141
155
|
</div>;
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 莱茵设计 · 常用组件契约
|
|
1
|
+
# 莱茵设计 · 常用组件契约
|
|
2
2
|
|
|
3
3
|
本文件定义[莱茵设计](../README.md)的常用组件契约。先固定契约和组件展台,再验证;默认优先复用本目录,页面先改设计稿,再按授权接入App。具体业务由宿主定义,不进入通用契约。
|
|
4
4
|
|
|
@@ -18,6 +18,7 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
18
18
|
| Icon / IconButton | 少量常用16/20px线性图标、44px操作目标、可访问名称 |
|
|
19
19
|
| CopyButton | 显式点击后复制;等待真实Promise成功才提示,失败可重试;可注入原生clipboard适配器 |
|
|
20
20
|
| Checkbox | 原生复选框、混合态、label/hint;选中由调用方控制 |
|
|
21
|
+
| Checkbox `layout="row"`(自 0.10.0) | 侧栏整行选择,名称/说明同列、对称内距与主题选中/焦点;默认 form 不变,见[双行多选契约](../docs/CHECKBOX_ROWS.md) |
|
|
21
22
|
| RadioGroup | fieldset/legend与原生radio,独立组名、禁用项、键盘导航 |
|
|
22
23
|
| Switch | 原生checkbox加switch语义;只用于二元偏好,不替代账号选择 |
|
|
23
24
|
| Textarea | 可见label、hint/error、只读/禁用;允许垂直调整,不做富文本 |
|
|
@@ -30,7 +31,7 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
30
31
|
| Menu | 操作项/禁用/危险项;方向键/Home/End/输入字符跳转;不是表单选择器 |
|
|
31
32
|
| Divider / Stack / Inline / Grid | 语义分隔线、纵排、可换行横排、自适应网格;只消费间距tokens |
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
上表以早期 V1.4 文档的 24 个扩展组件为基础,当时与已有 16 个合计 40 个;V1.4 是历史文档阶段编号,不是 npm 包版本。当前完整清单与版本边界见[组件入口](README.md)。复杂浮层使用Radix无样式基础,避免维护重复焦点/菜单/碰撞实现;样式由Rhine Design定义。原生表单、数据与布局不引入额外框架。
|
|
34
35
|
|
|
35
36
|
## 动效与状态
|
|
36
37
|
|
|
@@ -71,4 +72,6 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
71
72
|
|
|
72
73
|
## 设计展台验收
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
展台使用当前源码的真实组件;本页重点覆盖数据、表单、操作/布局、反馈/浮层四组。合成数据只演示事件与状态;Clipboard仅复制明确的演示字符串。测试覆盖分页边界、请求状态、异步复制失败、IME、候选列表、通知计时、浮层焦点/Escape与窄窗;原生宿主原生平台和屏幕阅读器仍需App接入后单独验收。
|
|
76
|
+
|
|
77
|
+
Toast 浮动通知(自 0.11.0,npm 0.10.0 不含,见[版本记录](../CHANGELOG.md#01102026-10-27)):`placement="floating"` 通过 Portal 挂到最近主题根,位于视口右下角,距边缘 16px、最大宽度 400px,窄窗两侧各留 16px。默认 inline 行为保持;浮动通知不占内容流、不抢焦点,关闭后不拦截点击;继承主题与减少动态。反馈展台使用浮动模式。成功沿用 5 秒关闭及悬停/聚焦/隐藏暂停,关键错误仍持久。
|
package/components/Forms.tsx
CHANGED
|
@@ -2,11 +2,24 @@ import { useEffect, useId, useLayoutEffect, useRef, useState, type InputHTMLAttr
|
|
|
2
2
|
import * as PopoverBase from '@radix-ui/react-popover';
|
|
3
3
|
import { Button, type Choice } from './components';
|
|
4
4
|
import { Icon, IconButton } from './Actions';
|
|
5
|
+
import './checkbox-row.css';
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
+
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
8
|
+
label: string;
|
|
9
|
+
hint?: string;
|
|
10
|
+
indeterminate?: boolean;
|
|
11
|
+
layout?: 'form' | 'row';
|
|
12
|
+
}
|
|
13
|
+
export function Checkbox({ label, hint, indeterminate = false, layout = 'form', id: suppliedId, className = '', ...props }: CheckboxProps) {
|
|
7
14
|
const generatedId = useId(), id = suppliedId || generatedId;
|
|
8
15
|
const ref = useRef<HTMLInputElement>(null);
|
|
9
16
|
useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);
|
|
17
|
+
if (layout === 'row') return <div className={`rhine-check rhine-check--row ${className}`}><label htmlFor={id}>
|
|
18
|
+
<input {...props} ref={ref} id={id} type="checkbox" aria-checked={indeterminate ? 'mixed' : undefined}
|
|
19
|
+
aria-labelledby={props['aria-labelledby'] || (!props['aria-label'] ? `${id}-label` : undefined)}
|
|
20
|
+
aria-describedby={[props['aria-describedby'], hint && `${id}-hint`].filter(Boolean).join(' ') || undefined} />
|
|
21
|
+
<span className="rhine-check__text"><span id={`${id}-label`} className="rhine-check__name">{label}</span>{hint && <span id={`${id}-hint`} className="rhine-check__hint">{hint}</span>}</span>
|
|
22
|
+
</label></div>;
|
|
10
23
|
return <div className={`rhine-check ${className}`}><label htmlFor={id}><input {...props} ref={ref} id={id} type="checkbox" aria-checked={indeterminate ? 'mixed' : undefined} aria-describedby={[props['aria-describedby'], hint && `${id}-hint`].filter(Boolean).join(' ') || undefined} /><span>{label}</span></label>{hint && <p id={`${id}-hint`} className="rhine-text rhine-text--caption rhine-text--muted">{hint}</p>}</div>;
|
|
11
24
|
}
|
|
12
25
|
export function RadioGroup({ label, items, value, onValueChange, disabled = false, name }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void; disabled?: boolean; name?: string }) {
|
|
@@ -20,6 +20,8 @@ export interface ListFilterBarProps {
|
|
|
20
20
|
refresh?: { onClick: () => void; busy?: boolean; disabled?: boolean };
|
|
21
21
|
action?: ReactElement;
|
|
22
22
|
result?: ReactNode;
|
|
23
|
+
drawerDescription?: ReactNode;
|
|
24
|
+
drawerResult?: ReactNode;
|
|
23
25
|
pending?: boolean;
|
|
24
26
|
error?: string;
|
|
25
27
|
className?: string;
|
|
@@ -37,7 +39,7 @@ function Feedback({ result, pending, error, announce = true }: Pick<ListFilterBa
|
|
|
37
39
|
|
|
38
40
|
const focusable = 'input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), a[href]';
|
|
39
41
|
|
|
40
|
-
export function ListFilterBar({ label, search, primary = [], filters = [], onReset, refresh, action, result, pending = false, error, className = '' }: ListFilterBarProps) {
|
|
42
|
+
export function ListFilterBar({ label, search, primary = [], filters = [], onReset, refresh, action, result, drawerDescription = '更改后立即更新结果。', drawerResult, pending = false, error, className = '' }: ListFilterBarProps) {
|
|
41
43
|
const [open, setOpen] = useState(false);
|
|
42
44
|
const root = useRef<HTMLDivElement>(null);
|
|
43
45
|
const drawerBody = useRef<HTMLDivElement>(null);
|
|
@@ -77,13 +79,13 @@ export function ListFilterBar({ label, search, primary = [], filters = [], onRes
|
|
|
77
79
|
{(drawerFields.length > 0 || refresh || action) && <div className="rhine-list-filter-bar__actions">
|
|
78
80
|
{drawerFields.length > 0 && <Drawer
|
|
79
81
|
title={label}
|
|
80
|
-
description=
|
|
82
|
+
description={drawerDescription}
|
|
81
83
|
open={open}
|
|
82
84
|
onOpenChange={setOpen}
|
|
83
85
|
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
86
|
>
|
|
85
87
|
<div ref={drawerBody} className="rhine-list-filter-bar__drawer">
|
|
86
|
-
<Feedback result={result} pending={pending} error={error} />
|
|
88
|
+
<Feedback result={drawerResult === undefined ? result : drawerResult} pending={pending} error={error} />
|
|
87
89
|
{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
90
|
{canReset && <div className="rhine-list-filter-bar__drawer-actions"><Button variant="quiet" onClick={reset}>重置筛选</Button></div>}
|
|
89
91
|
</div>
|
package/components/Overlays.tsx
CHANGED
|
@@ -19,7 +19,7 @@ function useScope() {
|
|
|
19
19
|
useLayoutEffect(() => { setContainer((ref.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
20
20
|
return { ref, container };
|
|
21
21
|
}
|
|
22
|
-
export interface DialogProps { trigger: ReactElement; title: string; description:
|
|
22
|
+
export interface DialogProps { trigger: ReactElement; title: string; description: ReactNode; children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; navigation?: ReactNode; sidePanel?: ReactNode | ((closeButton: ReactElement) => ReactNode); onCloseAutoFocus?: (event: Event) => void }
|
|
23
23
|
export function Dialog(props: DialogProps) {
|
|
24
24
|
return <DialogFrame {...props}/>;
|
|
25
25
|
}
|
|
@@ -44,11 +44,12 @@ function DialogFrame({ trigger, title, description, children, open, onOpenChange
|
|
|
44
44
|
dialogDismiss.get(owner)?.();
|
|
45
45
|
};
|
|
46
46
|
const sideNavigation = typeof sidePanel === 'function';
|
|
47
|
+
const hasDescription = description != null && description !== false && description !== '';
|
|
47
48
|
const settings = navigation != null && navigation !== false;
|
|
48
49
|
if (settings && sidePanel) throw new Error('Dialog navigation and sidePanel are mutually exclusive.');
|
|
49
50
|
const closeButton = <DialogBase.Close asChild><IconButton label={drawer ? '关闭抽屉' : '关闭对话框'} icon="close" /></DialogBase.Close>;
|
|
50
51
|
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
|
|
52
|
+
<DialogBase.Overlay className="rhine-overlay" /><DialogBase.Content ref={setContent} onEscapeKeyDown={escape} onCloseAutoFocus={onCloseAutoFocus} {...(!hasDescription ? { 'aria-describedby': undefined } : {})} 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>{hasDescription && <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>
|
|
52
53
|
</DialogBase.Portal></DialogBase.Root></span>;
|
|
53
54
|
}
|
|
54
55
|
export function ConfirmDialog({ trigger, title, description, confirmLabel = '确认操作', danger = false, onConfirm }: { trigger: ReactElement; title: string; description: string; confirmLabel?: string; danger?: boolean; onConfirm: () => Promise<void> | void }) {
|
package/components/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
本目录提供[莱茵设计 · Rhine Design](../README.md)的 React 组件、设计变量和 API 契约。组件接收应用传入的数据与动作,不绑定特定后端或原生平台。包名为`@linhey/rhine-design`;样式变量和类名使用`rhine-`命名空间。
|
|
4
4
|
|
|
5
|
-
0.8.0
|
|
5
|
+
当前主入口 `components/index.ts` 共 67 个组件(不含类型及独立 `/theme` 入口);0.9.0 新增 ListFilterBar 后,0.10.0 保持此数量。0.8.0 为 66 个,包含 NeutronStarBackdrop、StatusTimelineList、AppShellToggle、ButtonLink、ThemePicker 与 ThemePreview;0.6.0 为 60 个,0.5.0 为 59 个,0.4.0 为 48 个,0.3.0 为 40 个。当前源码中的摘要 Tabs、浮动 Toast、紧凑 SegmentedControl 和相邻 Disclosure 分隔线调整不在 npm 0.10.0 中,见[0.11.0 记录](../CHANGELOG.md#01102026-10-27)。新增导航、表单流程及健康时间轴组件见[场景与交互规范](../docs/INTERACTION_PATTERNS.md),原24个扩展组件见[常用组件契约](./COMMON_COMPONENTS.md)。[字体与文字规范](./TYPOGRAPHY.md)与[主流系统对照与缺口](../docs/DESIGN_SYSTEM_AUDIT.md)共同约束复用。静态页面样稿也使用同源Toolbar/Select CSS,不运行React。
|
|
6
6
|
|
|
7
7
|
中子星艺术、主题物品和生命周期见[中子星契约](../docs/NEUTRON_STAR_BACKDROP.md)与[轨道背景契约](../docs/ORBIT_BACKDROP.md)。安装版本与源码导出分别核对。
|
|
8
8
|
|
|
@@ -64,12 +64,12 @@ export function Example() {
|
|
|
64
64
|
| 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) |
|
|
65
65
|
| SelectionCard | label;selected;onSelectedChange;disabled;showIndicator=true;children | 原生 checkbox 可取消;是否单选由上层控制;showIndicator=false 仅视觉隐藏勾选框,保留键盘与读屏,卡片边界表达选中;内部不嵌套按钮/链接/其他输入 |
|
|
66
66
|
| TextField | label;hint;error;原生 input 属性 | label 与 id 自动关联;错误与提示组成 aria-describedby;输入值由调用方管理 |
|
|
67
|
-
| Tabs | label;items[{value,label,content,disabled?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End
|
|
68
|
-
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange |
|
|
67
|
+
| Tabs | label;items[{value,label,content,disabled?,description?,status?}];value;onValueChange | value 必须指向有效可用项;唯一 value;左右/Home/End,跳过禁用项;隐藏面板保留挂载但退出可访问树;description/status自0.11.0新增,npm 0.10.0 不含,见下文 |
|
|
68
|
+
| SegmentedControl | label;items[{value,label,disabled?}];value;onValueChange;fullWidth?;size=default/compact | compact为卡片局部操作的32px视觉/44px命中(自 0.11.0,npm 0.10.0 不含);原生 radio,独立 name 防多组干扰;方向键和禁用行为由浏览器提供;fullWidth 为整行等宽选项,标签和选中底色撑满且文字居中 |
|
|
69
69
|
| Notice | tone;title;children;action | error 使用 alert,其余 status;调用方决定显示/移除,不自动倒计时关闭错误 |
|
|
70
70
|
| Progress | label;value:number/null;valueText? | null/NaN 为未知进度;数值钳制 0–100;组件不执行或估算真实进度 |
|
|
71
71
|
| Skeleton | label;lines | 1–8 行,默认 3 行;单一可访问加载描述,装饰行隐藏 |
|
|
72
|
-
| Disclosure | title;children;defaultOpen | 非受控原生 details;挂载后 defaultOpen
|
|
72
|
+
| Disclosure | title;children;defaultOpen | 非受控原生 details;挂载后 defaultOpen 不作远程控制;支持开合中断与反向,关闭内容退出键盘顺序;连续分组不加组间 gap,相邻 Disclosure 共用一条分隔线(自 0.11.0,npm 0.10.0 不含) |
|
|
73
73
|
| EmptyState | title;description;action | 说明当前状态并可给一个下一步动作;不将错误伪装成空数据 |
|
|
74
74
|
|
|
75
75
|
新增24项API与组合示例见[常用组件契约](./COMMON_COMPONENTS.md)。TextField封装单行输入,Select使用原生单选;需要查询候选时用Combobox。复杂浮层采用Radix Dialog、AlertDialog、Popover、DropdownMenu、Tooltip的无样式基础,莱茵设计提供视觉与动效。Toast只处理单条通知;不加入全局队列、表格引擎、图表包或业务数据层。
|
|
@@ -141,6 +141,14 @@ export function Example() {
|
|
|
141
141
|
- 动画不承诺异步任务一定成功;不使用定时器模拟业务完成。预览中的 900ms 保存仅为明确标记的本地演示。
|
|
142
142
|
- 组件不提供全局开启/关闭业务状态的开关;动画是视觉层的可选增强。
|
|
143
143
|
|
|
144
|
+
### 带说明与状态的 Tabs(自 0.11.0,npm 0.10.0 不含)
|
|
145
|
+
|
|
146
|
+
完整规格见[带摘要的页签](../docs/SUMMARY_TABS.md):名称14/22/600、摘要12/18/400,12px上下内距、8px名称/状态、4px摘要间隔、16px页签间距及24px面板间距。独立可交互展台 `/#summary-tabs` 提供局部宽度与状态切换;普通Tabs保持原有间距。
|
|
147
|
+
|
|
148
|
+
`description?: string` 显示对象身份等辅助信息,`status?: ReactNode` 用只读 Badge 或纯文本表达状态,不嵌套按钮、链接等交互元素。存在这两项时页签等分可用宽度,标题/状态及辅助文字自然换行;普通纯标题 Tabs 布局保持不变。页签可访问名称始终为 `label`,描述和状态经 `aria-describedby` 关联;选中与焦点保持原语义和动效,减少动态沿用现有规则。
|
|
149
|
+
|
|
150
|
+
状态由宿主持有,每项同时呈现自己的摘要,不能用当前选中项的摘要填充所有页签。身份变化不得改变 `value` 或重建焦点;状态变化不自动切页。展台 `/#selection` 保留普通Tabs,`/#summary-tabs` 集中提供摘要页签的正常、禁用/加载、错误和长说明,支持局部宽度、键盘、主题及减少动态控件。
|
|
151
|
+
|
|
144
152
|
## 页面采用顺序
|
|
145
153
|
|
|
146
154
|
先 Button、TextField、Notice、Badge;再 Tabs、SegmentedControl 与 Surface;随后 SelectionCard、Progress、Skeleton、Disclosure、EmptyState。逐页替换时保持业务 DTO/动作调用不变。
|
|
@@ -178,4 +186,4 @@ export function Example() {
|
|
|
178
186
|
|
|
179
187
|
- [浮层缩放与边界](../docs/FLOATING_OVERLAYS.md):Popover、Menu、Tooltip 与时间轴共享 CSS zoom 修正。
|
|
180
188
|
- [设置 Dialog](../docs/SETTINGS_DIALOG.md):`navigation` 插槽及窄窗/缩放重排。
|
|
181
|
-
- [主题选择](../docs/THEME_PICKER.md):`ThemePicker` / `ThemePreview
|
|
189
|
+
- [主题选择](../docs/THEME_PICKER.md):`ThemePicker` / `ThemePreview`,自 0.8.0 提供,当时组件总数增至 66;0.9.0 新增 ListFilterBar 后为 67。
|
package/components/SPACING.md
CHANGED
|
@@ -52,6 +52,14 @@
|
|
|
52
52
|
|
|
53
53
|
页面已有h1时卡片通常用h2;标题等级由结构决定,不能用字号决定。不要为布局引入空Text、空段落或连续br。
|
|
54
54
|
|
|
55
|
+
### 卡片标题与局部指标切换
|
|
56
|
+
|
|
57
|
+
卡片继续使用 Surface 24px 内距和 Stack gap=4 的头部/内容间距。头部用 Inline gap=4、justifyContent: space-between:标题使用 Text heading(通常 h2),操作靠右、按内容宽度排列;空间不足自然换行,不挤压标题或固定卡高。只有说明时才加标题/说明 8px 间距。
|
|
58
|
+
|
|
59
|
+
局部指标切换采用 `SegmentedControl size="compact"`(自 0.11.0,npm 0.10.0 不含,见[版本记录](../CHANGELOG.md#01102026-10-27)):可见选项最小32px高,文字12/18,水平内距8px;上下各6px透明命中区域保持44px操作高度,单项最小44px宽。控件不使用业务CSS缩放;默认尺寸和整行模式保持原契约。视觉大小与点击范围分别验收,透明命中区计入头部布局,不覆盖相邻内容。三主题沿用选中、禁用与焦点语义。
|
|
60
|
+
|
|
61
|
+
真实组合见 `#spacing` 的“统计分布”卡片:默认、加载/禁用、错误、长标题与窄窗,以及键盘选择、200%缩放和减少动态。
|
|
62
|
+
|
|
55
63
|
### 只承载表格的 Surface
|
|
56
64
|
|
|
57
65
|
纯表格使用 `<Surface padding="none"><DataTable … /></Surface>`,取消容器外围24px内距,表头和单元格仍使用 DataTable 自身的阅读间距。该公开 prop 自 0.9.1 提供,见 [#24](https://github.com/linhay/rhine-design/issues/24);默认 `padding="default"` 保持原来行为。
|
|
@@ -70,6 +78,7 @@
|
|
|
70
78
|
| 数据列表 | 同项说明4–8px | 列表项16px;筛选/表格/分页16–24px | Stack +DataTable/Pagination |
|
|
71
79
|
| 浮层 | 标题/说明由浮层组件负责 | 正文组16px;操作区24px | Dialog/Drawer +Stack/Inline |
|
|
72
80
|
| 反馈 | 提示内部由Notice负责 | 与前后内容16px | Notice放入Stack,不覆盖其内部样式 |
|
|
81
|
+
| 带摘要的页签 | 名称/状态8px,名称组/身份4px,上下各12px | 页签16px;面板顶部24px | Tabs description/status;[完整规格](../docs/SUMMARY_TABS.md) |
|
|
73
82
|
|
|
74
83
|
这些是组合默认值,不重新覆盖现有组件内部的padding、gap或状态样式。要改变组件密度,先在库中定义并验证,不能在业务页用后代选择器临时压缩。
|
|
75
84
|
|
package/components/TYPOGRAPHY.md
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
|
|
39
39
|
字号与行高成对使用,`Text`按variant同时设置两者;不在业务页单改字号而继承父级行高,不使用`normal`或固定容器高度猜测文本尺寸。上表是100%缩放的CSS尺寸,缩放后保持比例与自然换行,不以压低行高塞回原高度。
|
|
40
40
|
|
|
41
|
+
公共组件从正文切换到辅助说明或标题时,同样显式配对字号、行高和字重。MediaViewer 说明为 caption 12/18/400,EmptyState 标题为 heading 16/24 与主题标题字重(默认 600,橙趣 750);完整检查范围、保留例外与真实浏览器回归见[组件文字复核](../docs/COMPONENT_TYPOGRAPHY_AUDIT.md)。此修复自 npm 0.10.0 提供。
|
|
42
|
+
|
|
41
43
|
| 内容 | 字号 / 行高 | Token | 多行规则 |
|
|
42
44
|
| --- | --- | --- | --- |
|
|
43
45
|
| 大标题 | 30 / 38px | `--rhine-leading-title` | 换行仍为38px,不压成单行 |
|
package/components/Toast.tsx
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import { IconButton } from './Actions';
|
|
3
4
|
import type { Tone } from './components';
|
|
4
5
|
|
|
5
|
-
export function Toast({ open, onOpenChange, title, description, tone = 'success', duration = 5000 }: {
|
|
6
|
-
open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: string; tone?: Tone; duration?: number;
|
|
6
|
+
export function Toast({ open, onOpenChange, title, description, tone = 'success', duration = 5000, placement = 'inline' }: {
|
|
7
|
+
open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: string; tone?: Tone; duration?: number; placement?: 'inline' | 'floating';
|
|
7
8
|
}) {
|
|
9
|
+
const anchor = useRef<HTMLSpanElement>(null);
|
|
10
|
+
const [container, setContainer] = useState<HTMLElement>();
|
|
11
|
+
// Escape scrolling panels while inheriting the host theme and reduced-motion setting.
|
|
12
|
+
useLayoutEffect(() => { if (placement === 'floating') setContainer((anchor.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, [placement]);
|
|
8
13
|
const [hover, setHover] = useState(false), [focus, setFocus] = useState(false), [hidden, setHidden] = useState(false);
|
|
9
14
|
const remaining = useRef(duration), close = useRef(onOpenChange);
|
|
10
15
|
close.current = onOpenChange;
|
|
@@ -16,10 +21,11 @@ export function Toast({ open, onOpenChange, title, description, tone = 'success'
|
|
|
16
21
|
const started = Date.now(); const timer = setTimeout(() => close.current(false), Math.max(0, remaining.current));
|
|
17
22
|
return () => { clearTimeout(timer); remaining.current = Math.max(0, remaining.current - (Date.now() - started)); };
|
|
18
23
|
}, [open, hover, focus, hidden, tone, duration, title, description]);
|
|
19
|
-
|
|
24
|
+
const notification = <div className="rhine-toast-region" data-placement={placement} aria-label="通知">
|
|
20
25
|
<div role={tone === 'error' ? 'alert' : 'status'} aria-atomic="true" className={open ? `rhine-toast rhine-tone-${tone}` : 'rhine-sr-only'}
|
|
21
26
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onFocus={() => setFocus(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) setFocus(false); }}>
|
|
22
27
|
{open && <><div><strong>{title}</strong>{description && <p>{description}</p>}</div><IconButton label="关闭通知" icon="close" onClick={() => onOpenChange(false)} /></>}
|
|
23
28
|
</div>
|
|
24
29
|
</div>;
|
|
30
|
+
return placement === 'floating' ? <><span ref={anchor} hidden />{container && createPortal(notification, container)}</> : notification;
|
|
25
31
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.rhine-check--row { min-width:0; }
|
|
2
|
+
.rhine-check.rhine-check--row > label { display:grid; grid-template-columns:18px minmax(0,1fr); align-items:start; align-content:center; gap:var(--rhine-space-2); min-height:44px; padding:var(--rhine-space-2); border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); background:var(--rhine-surface); color:var(--rhine-ink); }
|
|
3
|
+
.rhine-check--row input { margin-top:2px; }
|
|
4
|
+
.rhine-check__text { display:grid; gap:var(--rhine-space-1); min-width:0; overflow-wrap:anywhere; }
|
|
5
|
+
.rhine-check__name { font-size:var(--rhine-text-body); line-height:var(--rhine-leading-body); font-weight:var(--rhine-weight-label); }
|
|
6
|
+
.rhine-check__hint { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:400; color:var(--rhine-muted); }
|
|
7
|
+
.rhine-check--row > label:has(input:checked),.rhine-check--row > label:has(input:indeterminate) { background:var(--rhine-selected); border-color:var(--rhine-green); }
|
|
8
|
+
.rhine-check--row > label:hover:has(input:not(:disabled)) { border-color:var(--rhine-green); }
|
|
9
|
+
.rhine-check--row > label:has(input:focus-visible) { box-shadow:inset 0 0 0 2px var(--rhine-green); }
|
|
10
|
+
.rhine-theme .rhine-check.rhine-check--row input:focus-visible { outline:none; }
|
|
11
|
+
.rhine-check--row > label:has(input:disabled) { cursor:default; }
|
|
12
|
+
.rhine-check--row input:disabled + .rhine-check__text { color:var(--rhine-muted); }
|
package/components/common.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
.rhine-link:hover { text-decoration-thickness:2px; }
|
|
5
5
|
.rhine-link>.rhine-icon { margin-left:4px; }
|
|
6
6
|
.rhine-copy { display:inline-flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
|
7
|
-
.rhine-copy>[role] { font-size:var(--rhine-text-caption); }
|
|
7
|
+
.rhine-copy>[role] { font-size:var(--rhine-text-caption); line-height:var(--rhine-leading-caption); font-weight:var(--rhine-weight-regular); }
|
|
8
8
|
.rhine-stack { display:flex; flex-direction:column; min-width:0; }
|
|
9
9
|
.rhine-inline { display:flex; align-items:center; flex-wrap:wrap; gap:var(--rhine-space-3); min-width:0; }
|
|
10
10
|
.rhine-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,var(--rhine-grid-min)),1fr)); min-width:0; }
|
|
@@ -72,6 +72,8 @@
|
|
|
72
72
|
.rhine-tooltip { overflow:auto; z-index:60; background:var(--rhine-ink); color:var(--rhine-surface); padding:8px 12px; border-radius:3px; max-width:min(280px,calc(100vw - 24px)); font-size:12px; line-height:18px; overflow-wrap:anywhere; }
|
|
73
73
|
.rhine-toast-region { position:relative; }.rhine-toast { display:flex; align-items:start; gap:12px; justify-content:space-between; padding:16px; background:var(--rhine-tone-bg); border-left:3px solid var(--rhine-tone); color:var(--rhine-ink); animation:rhine-enter var(--rhine-duration-panel) var(--rhine-ease-standard); }
|
|
74
74
|
.rhine-toast p { margin:4px 0 0; overflow-wrap:anywhere; }.rhine-toast strong { overflow-wrap:anywhere; }.rhine-toast>div { min-width:0; }
|
|
75
|
+
.rhine-toast-region[data-placement="floating"] { position:fixed; z-index:60; inset-inline-end:16px; bottom:calc(16px + env(safe-area-inset-bottom, 0px)); width:min(400px,calc(100% - 32px)); pointer-events:none; }
|
|
76
|
+
.rhine-toast-region[data-placement="floating"] .rhine-toast { pointer-events:auto; box-shadow:0 8px 24px #242b2820; }
|
|
75
77
|
.rhine-dialog[data-state=open],.rhine-overlay[data-state=open] { animation:rhine-overlay-fade var(--rhine-duration-overlay) var(--rhine-ease-standard); }
|
|
76
78
|
.rhine-popover[data-state=open],.rhine-menu[data-state=open],.rhine-combobox__list[data-state=open],.rhine-tooltip[data-state=delayed-open],.rhine-tooltip[data-state=instant-open] { animation:rhine-enter var(--rhine-duration-overlay) var(--rhine-ease-standard); }
|
|
77
79
|
@keyframes rhine-overlay-fade { from { opacity:0; } to { opacity:1; } }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.rhine-date-range.rhine-date-range--compact { padding:0; border:0; background:transparent; }
|
|
2
|
+
.rhine-date-range--compact > legend,
|
|
3
|
+
.rhine-date-range--compact .rhine-date-field > label { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
|
|
4
|
+
.rhine-date-range--compact .rhine-date-range__fields { grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:start; gap:var(--rhine-space-1); margin-bottom:0; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); background:var(--rhine-surface); }
|
|
5
|
+
.rhine-date-range--compact .rhine-date-field { min-width:0; }
|
|
6
|
+
.rhine-theme .rhine-date-range--compact .rhine-date-field input { border:0; border-radius:0; background:transparent; }
|
|
7
|
+
.rhine-date-range--compact .rhine-date-range__separator { display:grid; place-items:center; min-height:44px; color:var(--rhine-muted); }
|
|
8
|
+
.rhine-date-range--compact .rhine-inline:not(:empty) { margin-top:var(--rhine-space-2); }
|
|
9
|
+
.rhine-date-range--compact .rhine-field__error { margin:var(--rhine-space-1); }
|
|
10
|
+
.rhine-theme[data-rhine-theme=neo-brutalism] .rhine-date-range--compact .rhine-date-range__fields { border-width:2px; }
|
|
11
|
+
@container rhine-date-range (max-width:340px) {
|
|
12
|
+
.rhine-date-range--compact .rhine-date-range__fields { grid-template-columns:minmax(0,1fr); gap:0; }
|
|
13
|
+
.rhine-date-range--compact .rhine-date-range__separator { min-height:var(--rhine-space-4); }
|
|
14
|
+
}
|
|
15
|
+
.rhine-multi-select__actions { display:flex; flex-wrap:wrap; align-items:center; gap:var(--rhine-space-2); }
|
|
16
|
+
.rhine-multi-select--compact .rhine-multi-select__tags { flex-wrap:wrap; min-width:0; }
|
|
17
|
+
.rhine-multi-select--compact .rhine-multi-select__tags > li { min-width:0; flex-shrink:0; }
|
|
18
|
+
.rhine-multi-select--compact .rhine-multi-select__tags > li > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
19
|
+
.rhine-multi-select--compact .rhine-multi-select__tags[data-expanded=true] { flex-wrap:wrap; max-height:224px; overflow:auto; overscroll-behavior:contain; }
|
|
20
|
+
.rhine-multi-select--compact .rhine-multi-select__tags[data-expanded=true] > li > span { white-space:normal; overflow-wrap:anywhere; }
|
|
@@ -45,12 +45,12 @@ export function TextField({ label, hint, error, id: providedId, className, ...pr
|
|
|
45
45
|
</div>;
|
|
46
46
|
}
|
|
47
47
|
export interface Choice { value: string; label: string; disabled?: boolean }
|
|
48
|
-
export interface TabItem extends Choice { content: ReactNode }
|
|
48
|
+
export interface TabItem extends Choice { content: ReactNode; description?: string; status?: ReactNode }
|
|
49
49
|
export function Tabs({ label, items, value, onValueChange }: { label: string; items: TabItem[]; value: string; onValueChange: (value: string) => void }) {
|
|
50
50
|
const id = useId();
|
|
51
51
|
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
|
52
52
|
return <div className="rhine-tabs">
|
|
53
|
-
<div role="tablist" aria-label={label} className=
|
|
53
|
+
<div role="tablist" aria-label={label} className={classes('rhine-tabs__list', items.some(item => item.description || item.status) && 'rhine-tabs__list--detailed')} onKeyDown={event => {
|
|
54
54
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
55
55
|
const available = items.map((item, index) => ({ ...item, index })).filter(item => !item.disabled);
|
|
56
56
|
if (!available.length) return;
|
|
@@ -61,15 +61,19 @@ export function Tabs({ label, items, value, onValueChange }: { label: string; it
|
|
|
61
61
|
}}>
|
|
62
62
|
{items.map((item, index) => <button key={item.value} ref={node => { buttons.current[index] = node; }}
|
|
63
63
|
type="button" role="tab" disabled={item.disabled} id={`${id}-tab-${index}`} aria-controls={`${id}-panel-${index}`}
|
|
64
|
-
aria-
|
|
64
|
+
aria-label={item.label} aria-describedby={[item.description && `${id}-description-${index}`, item.status && `${id}-status-${index}`].filter(Boolean).join(' ') || undefined}
|
|
65
|
+
aria-selected={value === item.value} tabIndex={value === item.value ? 0 : -1} onClick={() => onValueChange(item.value)}>
|
|
66
|
+
{item.description || item.status ? <><span className="rhine-tabs__title">{item.label}{item.status && <span id={`${id}-status-${index}`} className="rhine-tabs__status">{item.status}</span>}</span>
|
|
67
|
+
{item.description && <span id={`${id}-description-${index}`} className="rhine-tabs__description">{item.description}</span>}</> : item.label}
|
|
68
|
+
</button>)}
|
|
65
69
|
</div>
|
|
66
70
|
{items.map((item, index) => <div key={item.value} role="tabpanel" id={`${id}-panel-${index}`} aria-labelledby={`${id}-tab-${index}`}
|
|
67
71
|
hidden={value !== item.value} tabIndex={0} className="rhine-tabs__panel">{item.content}</div>)}
|
|
68
72
|
</div>;
|
|
69
73
|
}
|
|
70
|
-
export function SegmentedControl({ label, items, value, onValueChange }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void }) {
|
|
74
|
+
export function SegmentedControl({ label, items, value, onValueChange, fullWidth = false, size = 'default' }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void; fullWidth?: boolean; size?: 'default' | 'compact' }) {
|
|
71
75
|
const name = useId();
|
|
72
|
-
return <fieldset className=
|
|
76
|
+
return <fieldset className={classes('rhine-segmented', fullWidth && 'rhine-segmented--full-width', size === 'compact' && 'rhine-segmented--compact')}><legend className="rhine-sr-only">{label}</legend>
|
|
73
77
|
{items.map(item => <label key={item.value} className="rhine-segmented__option">
|
|
74
78
|
<input type="radio" name={name} value={item.value} checked={value === item.value} disabled={item.disabled} onChange={() => onValueChange(item.value)} />
|
|
75
79
|
<span>{item.label}</span>
|
|
@@ -133,8 +137,10 @@ export function Disclosure({ title, children, defaultOpen = false }: { title: st
|
|
|
133
137
|
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
134
138
|
if (reduced || !el.animate) { el.open = expanded; setOpen(expanded); return; }
|
|
135
139
|
const style = getComputedStyle(el);
|
|
136
|
-
|
|
137
|
-
const
|
|
140
|
+
// Production CSS may normalize 200ms to .2s; Web Animations always takes milliseconds.
|
|
141
|
+
const durationValue = style.getPropertyValue('--rhine-duration-notice').trim();
|
|
142
|
+
const parsedDuration = parseFloat(durationValue);
|
|
143
|
+
const duration = Number.isFinite(parsedDuration) ? parsedDuration * (durationValue.endsWith('s') && !durationValue.endsWith('ms') ? 1000 : 1) : 200;
|
|
138
144
|
if (duration === 0) { el.open = expanded; setOpen(expanded); return; }
|
|
139
145
|
const to = expanded ? el.getBoundingClientRect().height : summary.getBoundingClientRect().height + parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
|
|
140
146
|
const next = el.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration, easing: style.getPropertyValue(expanded ? '--rhine-ease-standard' : '--rhine-ease-exit').trim() || 'ease' });
|
package/components/index.ts
CHANGED