@linhey/rhine-design 0.10.0 → 0.11.1
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 +24 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +17 -14
- package/components/AdvancedForms.tsx +13 -5
- package/components/COMMON_COMPONENTS.md +5 -3
- package/components/Forms.tsx +66 -6
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/Toast.tsx +10 -4
- package/components/common.css +4 -0
- package/components/components.tsx +13 -7
- package/components/styles.css +12 -0
- package/components/useFloatingScale.ts +7 -3
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/FLOATING_OVERLAYS.md +16 -0
- package/docs/INTERACTION_PATTERNS.md +9 -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/CommonGallery.tsx +1 -1
- package/preview/DenseFilterControlsGallery.tsx +5 -2
- package/preview/GalleryShell.tsx +3 -2
- package/preview/OrbitGallery.tsx +1 -1
- package/preview/SpacingGallery.tsx +9 -1
- package/preview/SummaryTabsGallery.tsx +37 -0
- package/preview/main.tsx +7 -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-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-multiselect-completion.js +46 -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/references/API_MAP.md +4 -0
- package/skills/rhine-design/references/TASK_MAP.md +2 -0
- package/tests/CommonComponents.test.tsx +8 -0
- package/tests/DesignSystem.test.tsx +32 -0
- package/tests/FormParity.test.tsx +104 -2
- 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,29 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.11.1(2026-10-27)
|
|
4
|
+
|
|
5
|
+
- 补齐嵌套下拉退出到父Modal焦点约束恢复之间的快速Tab保护;连续Shift+Tab不再把焦点留在背景,正常导航恢复后仍可循环。
|
|
6
|
+
|
|
7
|
+
- 候选列表保持240px上限并按视口缩放约束,避免可用高度反馈;多选内部动作等click完成再收起,避免窄屏缩放中pointerdown/抬起之间位置移动丢失清空。
|
|
8
|
+
|
|
9
|
+
- 修复 Combobox / RemoteMultiSelect 在祖先 CSS zoom 下的候选浮层定位与视口边界,复用既有浮层缩放处理;保留主题 Portal、搜索、多选与键盘行为。
|
|
10
|
+
- RemoteMultiSelect 可再次点击或方向键+Enter取消已选项,保留真正禁用/锁定项;“完成选择”只收起候选并归焦,不提交宿主表单;支持输入框Tab进入完成、Shift+Tab返回及继续Tab到后继控件;标签×移除和清空后保持收起,输入框Backspace保留原展开态,无可移除项时隐藏清空按钮。
|
|
11
|
+
- `#compact-filters` 补充弹窗内多选与真实浏览器回归;参见[浮层契约](docs/FLOATING_OVERLAYS.md)。
|
|
12
|
+
- 补丁制品从 `v0.11.0` 回补上述修复,主入口仍为67项;main 后续未发布新功能不包含在0.11.1中。消费方固定版本并提交 registry lockfile。
|
|
13
|
+
|
|
14
|
+
## 0.11.0(2026-10-27)
|
|
15
|
+
|
|
16
|
+
本版本新增产品评审工具及以下已合并能力;npm 0.10.0 不包含这些新增 API。组件主入口仍为 67 项,默认行为保持兼容;消费方固定 0.11.0 并提交 lockfile。实际发布可用性以 registry 为准。
|
|
17
|
+
|
|
18
|
+
- #40:新增 `/review`、`/review/vite` 产品评审工具,提供场景协议、目录/控件、URL 状态、合成 iframe、生命周期、独立静态构建与正式包排除检查;附 HTTP 与平台 client 两类例子,静态资源支持项目型站点的嵌套部署路径;补充连接/清理超时恢复、持久错误反馈、折叠高级设置与实际画布尺寸。已在两类真实 App 的隔离副本试用,消费迁移按产品分别验证,见[契约](docs/REVIEW_TOOL.md)。
|
|
19
|
+
|
|
20
|
+
- `Tabs` / `TabItem` 新增 `description`、`status`,提供带身份摘要与只读状态的页签;契约见[Summary Tabs](docs/SUMMARY_TABS.md),展台为 `#summary-tabs`。
|
|
21
|
+
- `Toast` 新增 `placement="floating"`,通过主题根 Portal 呈现浮动通知;默认 inline 保持兼容,见[通知契约](components/COMMON_COMPONENTS.md)。
|
|
22
|
+
- `SegmentedControl` 新增 `size="compact"`,支持卡片局部指标切换;见[间距规范](components/SPACING.md)。0.10.0 已有的 `fullWidth` 不受影响。
|
|
23
|
+
- 相邻 `Disclosure` 共用分隔线;补充上下文帮助与主题背景/文字配对规范。
|
|
24
|
+
- 修复 `Disclosure` 在生产 CSS 将 `200ms` 压缩为 `.2s` 时误用 0.2ms 动画的问题;按单位换算为毫秒,保持 200ms 开合、快速反向和减少动态行为。
|
|
25
|
+
- 展台首页和页脚按当前主入口统一为 67 个组件,源码包版本改从 `package.json` 读取;修正轨道主题艺术的版本标记及文档中的旧数量、候选状态。
|
|
26
|
+
|
|
3
27
|
## 0.10.0(2026-10-26)
|
|
4
28
|
|
|
5
29
|
- #31:MediaViewer 说明补齐 caption 12/18/400,EmptyState 标题采用 heading 16/24 与主题标题字重;同类复制反馈、步骤/表单组说明、错误摘要和主题预览状态补齐语义配对,保留既有密度例外。真实回归入口 `#component-typography`。
|
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,7 +42,7 @@ 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.1
|
|
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。
|
|
@@ -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) 提供组件名、版本、预期行为及最小复现。
|
|
@@ -132,25 +132,33 @@ export interface RemoteMultiSelectProps {
|
|
|
132
132
|
}
|
|
133
133
|
export function RemoteMultiSelect({ value, onValueChange, options, readOnly = false, clearable = true, closeOnSelect = false, clearQueryOnSelect = false, name, maxVisibleTags, ...props }: RemoteMultiSelectProps) {
|
|
134
134
|
const inputId = useId();
|
|
135
|
+
const [open, setOpen] = useState(false);
|
|
135
136
|
const [expanded, setExpanded] = useState(false);
|
|
136
137
|
const limit = maxVisibleTags !== undefined && Number.isFinite(maxVisibleTags) ? Math.max(0, Math.floor(maxVisibleTags)) : undefined;
|
|
137
138
|
const collapsible = limit !== undefined && value.length > limit;
|
|
138
139
|
// Clearing or replacing the selection below the limit starts a new compact view.
|
|
139
140
|
if (expanded && !collapsible) setExpanded(false);
|
|
140
141
|
const shown = collapsible && !expanded ? value.slice(0, limit) : value;
|
|
141
|
-
const focusInput = () => document.getElementById(inputId)?.focus();
|
|
142
|
-
const remove = (key: string) => {
|
|
142
|
+
const focusInput = (nextOpen = false) => { document.getElementById(inputId)?.focus(); setOpen(nextOpen); };
|
|
143
|
+
const remove = (key: string, nextOpen = false) => {
|
|
143
144
|
if (props.disabled || readOnly || value.find(item => item.value === key)?.disabled) return;
|
|
144
|
-
onValueChange(value.filter(item => item.value !== key)); focusInput();
|
|
145
|
+
onValueChange(value.filter(item => item.value !== key)); focusInput(nextOpen);
|
|
145
146
|
};
|
|
146
147
|
return <div className={`rhine-multi-select${limit !== undefined ? ' rhine-multi-select--compact' : ''}`}>
|
|
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 => {
|
|
148
|
+
<Combobox {...props} id={inputId} open={open} onOpenChange={setOpen} readOnly={readOnly} selectedValues={value.map(item => item.value)} closeOnSelect={closeOnSelect} completionLabel={closeOnSelect ? undefined : '完成选择'} onEmptyBackspace={() => { const last = [...value].reverse().find(item => !item.disabled); if (last) remove(last.value, open); }} options={options.map(option => ({ ...option, disabled: option.disabled || value.some(item => item.value === option.value && item.disabled) }))} value={null} onValueChange={option => {
|
|
149
|
+
const original = options.find(item => item.value === option.value);
|
|
150
|
+
if (!original || original.disabled || props.disabled || readOnly) return;
|
|
151
|
+
const selected = value.find(item => item.value === original.value);
|
|
152
|
+
if (selected?.disabled) return;
|
|
153
|
+
onValueChange(selected ? value.filter(item => item.value !== original.value) : [...value, original]);
|
|
154
|
+
if (clearQueryOnSelect) props.onQueryChange('');
|
|
155
|
+
}} />
|
|
148
156
|
{name && value.map(item => <input key={item.value} type="hidden" name={name} value={item.value} disabled={props.disabled} />)}
|
|
149
157
|
<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
158
|
<div className="rhine-multi-select__actions">
|
|
151
159
|
{limit !== undefined && <span className="rhine-text rhine-text--caption rhine-text--muted">已选 {value.length} 项</span>}
|
|
152
160
|
{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
|
|
161
|
+
{clearable && value.some(item => !item.disabled) && <Button type="button" variant="quiet" disabled={props.disabled || readOnly} onClick={() => { onValueChange(value.filter(item => item.disabled)); focusInput(); }}>清空选择</Button>}
|
|
154
162
|
</div>
|
|
155
163
|
</div>;
|
|
156
164
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 莱茵设计 · 常用组件契约
|
|
1
|
+
# 莱茵设计 · 常用组件契约
|
|
2
2
|
|
|
3
3
|
本文件定义[莱茵设计](../README.md)的常用组件契约。先固定契约和组件展台,再验证;默认优先复用本目录,页面先改设计稿,再按授权接入App。具体业务由宿主定义,不进入通用契约。
|
|
4
4
|
|
|
@@ -31,7 +31,7 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
31
31
|
| Menu | 操作项/禁用/危险项;方向键/Home/End/输入字符跳转;不是表单选择器 |
|
|
32
32
|
| Divider / Stack / Inline / Grid | 语义分隔线、纵排、可换行横排、自适应网格;只消费间距tokens |
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
上表以早期 V1.4 文档的 24 个扩展组件为基础,当时与已有 16 个合计 40 个;V1.4 是历史文档阶段编号,不是 npm 包版本。当前完整清单与版本边界见[组件入口](README.md)。复杂浮层使用Radix无样式基础,避免维护重复焦点/菜单/碰撞实现;样式由Rhine Design定义。原生表单、数据与布局不引入额外框架。
|
|
35
35
|
|
|
36
36
|
## 动效与状态
|
|
37
37
|
|
|
@@ -72,4 +72,6 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
72
72
|
|
|
73
73
|
## 设计展台验收
|
|
74
74
|
|
|
75
|
-
|
|
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,6 +2,7 @@ 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 { useFloatingScale } from './useFloatingScale';
|
|
5
6
|
import './checkbox-row.css';
|
|
6
7
|
|
|
7
8
|
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
@@ -47,14 +48,21 @@ export interface ComboboxProps {
|
|
|
47
48
|
value: string | null; onValueChange: (option: Choice) => void; options: Choice[];
|
|
48
49
|
loading?: boolean; error?: string; disabled?: boolean;
|
|
49
50
|
readOnly?: boolean; placeholder?: string; hint?: string; id?: string;
|
|
50
|
-
selectedValues?: string[]; closeOnSelect?: boolean; onEmptyBackspace?: () => void;
|
|
51
|
+
selectedValues?: string[]; closeOnSelect?: boolean; onEmptyBackspace?: () => void; completionLabel?: string;
|
|
52
|
+
open?: boolean; onOpenChange?: (open: boolean) => void;
|
|
51
53
|
}
|
|
52
|
-
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false, readOnly = false, placeholder, hint, id: suppliedId, selectedValues, closeOnSelect = true, onEmptyBackspace }: ComboboxProps) {
|
|
54
|
+
export function Combobox({ label, query, onQueryChange, value, onValueChange, options, loading = false, error, disabled = false, readOnly = false, placeholder, hint, id: suppliedId, selectedValues, closeOnSelect = true, onEmptyBackspace, completionLabel, open: controlledOpen, onOpenChange }: ComboboxProps) {
|
|
53
55
|
const generatedId = useId(), id = suppliedId || generatedId; const composing = useRef(false);
|
|
56
|
+
const floatingRef = useFloatingScale(undefined, 'popover', 240);
|
|
57
|
+
const exitingWithTab = useRef(false);
|
|
58
|
+
const releaseTabGuard = useRef<(() => void) | undefined>(undefined);
|
|
59
|
+
useEffect(() => () => releaseTabGuard.current?.(), []);
|
|
54
60
|
const root = useRef<HTMLDivElement>(null);
|
|
55
61
|
const [container, setContainer] = useState<HTMLElement>();
|
|
56
62
|
useLayoutEffect(() => { setContainer((root.current?.closest('.rhine-theme') as HTMLElement) || document.body); }, []);
|
|
57
|
-
const [
|
|
63
|
+
const [internalOpen, setInternalOpen] = useState(false), [active, setActive] = useState<string | null>(null), [draft, setDraft] = useState<string | null>(null);
|
|
64
|
+
const open = controlledOpen ?? internalOpen;
|
|
65
|
+
const setOpen = (next: boolean) => { if (controlledOpen === undefined) setInternalOpen(next); onOpenChange?.(next); };
|
|
58
66
|
useEffect(() => { if (disabled || readOnly) { setOpen(false); setActive(null); setDraft(null); composing.current = false; } }, [disabled, readOnly]);
|
|
59
67
|
const available = loading || error ? [] : options.filter(option => !option.disabled);
|
|
60
68
|
const activeIndex = options.findIndex(option => option.value === active && !option.disabled);
|
|
@@ -62,20 +70,72 @@ export function Combobox({ label, query, onQueryChange, value, onValueChange, op
|
|
|
62
70
|
useEffect(() => { if (!open || !activeId) return; document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' }); }, [activeId, open]);
|
|
63
71
|
const choose = (option: Choice) => { if (readOnly || disabled || loading || error) return; onValueChange(option); if (closeOnSelect) setOpen(false); setActive(null); };
|
|
64
72
|
const visibleOpen = open && !disabled && !readOnly;
|
|
65
|
-
|
|
73
|
+
// Keep a multiselect action in place until its click finishes. Closing on
|
|
74
|
+
// pointerdown/blur can shift a zoomed page before pointerup reaches the button.
|
|
75
|
+
const containsInteractionTarget = (target: EventTarget | null) => target instanceof Node && (root.current?.closest('.rhine-multi-select') ?? root.current)?.contains(target);
|
|
76
|
+
const beginTabExit = () => {
|
|
77
|
+
exitingWithTab.current = true;
|
|
78
|
+
releaseTabGuard.current?.();
|
|
79
|
+
const dialog = root.current?.closest<HTMLElement>('.rhine-dialog[data-state="open"]');
|
|
80
|
+
if (!dialog) return;
|
|
81
|
+
const doc = dialog.ownerDocument;
|
|
82
|
+
let lastInside = doc.activeElement instanceof HTMLElement && dialog.contains(doc.activeElement) ? doc.activeElement : document.getElementById(id);
|
|
83
|
+
// The nested Radix scope resumes its parent asynchronously on unmount.
|
|
84
|
+
// Keep that short interval contained even for repeated keyboard events.
|
|
85
|
+
const guard = (event: FocusEvent) => {
|
|
86
|
+
if (!dialog.isConnected || dialog.dataset.state !== 'open') return;
|
|
87
|
+
const target = event.target;
|
|
88
|
+
if (!(target instanceof HTMLElement)) return;
|
|
89
|
+
if (dialog.contains(target)) lastInside = target;
|
|
90
|
+
else if (!document.getElementById(`${id}-popup`)?.contains(target) && lastInside?.isConnected) {
|
|
91
|
+
lastInside.focus({ preventScroll: true });
|
|
92
|
+
if (lastInside.id === id) { setOpen(false); setActive(null); }
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
doc.addEventListener('focusin', guard);
|
|
96
|
+
releaseTabGuard.current = () => { doc.removeEventListener('focusin', guard); releaseTabGuard.current = undefined; };
|
|
97
|
+
};
|
|
98
|
+
const complete = () => { document.getElementById(id)?.focus(); setOpen(false); setActive(null); };
|
|
99
|
+
return <div ref={root} className="rhine-combobox rhine-field" onBlur={event => { if (!containsInteractionTarget(event.relatedTarget) && !document.getElementById(`${id}-popup`)?.contains(event.relatedTarget)) setOpen(false); }}><label htmlFor={id}>{label}</label>
|
|
66
100
|
<PopoverBase.Root open={visibleOpen} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-combobox__input"><input id={id} role="combobox" aria-autocomplete="list" aria-expanded={visibleOpen} aria-controls={visibleOpen ? `${id}-list` : undefined} aria-activedescendant={visibleOpen ? activeId : undefined} aria-invalid={!!error || undefined} aria-describedby={[`${id}-status`, hint && `${id}-hint`].filter(Boolean).join(' ')} value={draft ?? query} disabled={disabled} readOnly={readOnly} placeholder={placeholder}
|
|
67
101
|
onClick={() => { if (!readOnly && !disabled) setOpen(true); }} onFocus={() => { if (!readOnly && !disabled) setOpen(true); }} onChange={event => { if (readOnly || disabled) return; if (composing.current) setDraft(event.target.value); else onQueryChange(event.target.value); setActive(null); setOpen(true); }}
|
|
68
102
|
onCompositionStart={() => { if (!readOnly && !disabled) { composing.current = true; setDraft(query); } }} onCompositionEnd={event => { composing.current = false; setDraft(null); if (!readOnly && !disabled) onQueryChange(event.currentTarget.value); }}
|
|
69
103
|
onKeyDown={event => {
|
|
70
104
|
if (readOnly || disabled || composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
105
|
+
if (event.key === 'Tab' && open) {
|
|
106
|
+
if (!event.shiftKey && completionLabel) {
|
|
107
|
+
const completion = document.getElementById(`${id}-complete`);
|
|
108
|
+
if (completion) { event.preventDefault(); completion.focus(); return; }
|
|
109
|
+
}
|
|
110
|
+
beginTabExit();
|
|
111
|
+
}
|
|
71
112
|
if (event.key === 'Backspace' && !event.currentTarget.value && onEmptyBackspace) { event.preventDefault(); onEmptyBackspace(); }
|
|
72
113
|
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false); setActive(null); }
|
|
73
114
|
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true); const index = available.findIndex(option => option.value === active); const next = event.key === 'ArrowDown' ? (index + 1) % available.length : (index <= 0 ? available.length : index) - 1; setActive(available[next]?.value ?? null); }
|
|
74
115
|
if (open && ['Home', 'End'].includes(event.key)) { event.preventDefault(); setActive((event.key === 'Home' ? available[0] : available.at(-1))?.value ?? null); }
|
|
75
116
|
if (open && event.key === 'Enter') { event.preventDefault(); const option = available.find(item => item.value === active); if (option) choose(option); }
|
|
76
117
|
}} /><span className="rhine-combobox__arrow"><Icon name="chevronDown" size={16} /></span></div></PopoverBase.Anchor>
|
|
77
|
-
<PopoverBase.Portal container={container}><PopoverBase.Content asChild sideOffset={4} collisionPadding={12} onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event =>
|
|
78
|
-
|
|
118
|
+
<PopoverBase.Portal container={container}><PopoverBase.Content asChild updatePositionStrategy="always" sideOffset={4} collisionPadding={12} onOpenAutoFocus={event => event.preventDefault()} onCloseAutoFocus={event => {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
releaseTabGuard.current?.();
|
|
121
|
+
if (!exitingWithTab.current) return;
|
|
122
|
+
exitingWithTab.current = false;
|
|
123
|
+
// Radix resumes the parent scope after this callback. Rapid Tab can
|
|
124
|
+
// reach a guard or the background during that interval; restore focus
|
|
125
|
+
// only for a keyboard exit from a still-open Rhine modal.
|
|
126
|
+
const dialog = root.current?.closest('.rhine-dialog[data-state="open"]');
|
|
127
|
+
const active = document.activeElement;
|
|
128
|
+
if (active?.hasAttribute('data-radix-focus-guard') || (dialog && !dialog.contains(active))) complete();
|
|
129
|
+
}} onInteractOutside={event => { if (containsInteractionTarget(event.target)) event.preventDefault(); }}>
|
|
130
|
+
<div ref={floatingRef} className="rhine-combobox__list" id={`${id}-popup`} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); complete(); } }}>
|
|
131
|
+
<div role="listbox" id={`${id}-list`} aria-label={label} aria-multiselectable={selectedValues ? true : undefined} aria-busy={loading}>{!loading && !error && options.map((option, index) => <div role="option" id={`${id}-option-${index}`} key={option.value} aria-selected={selectedValues ? selectedValues.includes(option.value) : option.value === value} aria-disabled={option.disabled || undefined} data-active={option.value === active} onMouseDown={event => event.preventDefault()} onMouseMove={() => { if (!option.disabled) setActive(option.value); }} onClick={() => { if (!option.disabled) choose(option); }}>{selectedValues && <span className="rhine-combobox__check" aria-hidden="true">{selectedValues.includes(option.value) ? '✓' : ''}</span>}{option.label}</div>)}{(loading || error || !options.length) && <div className="rhine-combobox__empty" role="presentation">{loading ? '正在读取…' : error ? '暂不可用' : '暂无匹配结果'}</div>}</div>
|
|
132
|
+
{completionLabel && <div className="rhine-combobox__completion"><Button id={`${id}-complete`} type="button" variant="quiet" onMouseDown={event => event.preventDefault()} onClick={complete} onKeyDown={event => {
|
|
133
|
+
if (event.key !== 'Tab') return;
|
|
134
|
+
if (event.shiftKey) { event.preventDefault(); document.getElementById(id)?.focus(); }
|
|
135
|
+
// Restore the input as the origin of the browser's normal forward Tab.
|
|
136
|
+
else { beginTabExit(); complete(); }
|
|
137
|
+
}}>{completionLabel}</Button></div>}
|
|
138
|
+
</div>
|
|
79
139
|
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root>
|
|
80
140
|
<span id={`${id}-status`} role="status" className={`rhine-text rhine-text--caption ${error ? 'rhine-text--error' : 'rhine-text--muted'}`}>{loading ? '正在读取候选项…' : error || (options.length ? `${options.length} 个候选项` : '暂无匹配结果')}</span>
|
|
81
141
|
{hint && <span id={`${id}-hint`} className="rhine-field__hint">{hint}</span>}
|
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;fullWidth
|
|
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/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
|
}
|
package/components/common.css
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
.rhine-combobox__list [role=option],.rhine-combobox__empty { padding:12px; min-height:44px; cursor:pointer; overflow-wrap:anywhere; }
|
|
48
48
|
.rhine-combobox__list [role=option][data-active=true],.rhine-combobox__list [role=option][aria-selected=true] { background:var(--rhine-selected); }
|
|
49
49
|
.rhine-combobox__list [role=option][aria-disabled=true],.rhine-combobox__empty { color:var(--rhine-muted); cursor:default; }
|
|
50
|
+
.rhine-combobox__check { display:inline-block; width:1.5em; font-weight:600; }
|
|
51
|
+
.rhine-combobox__completion { position:sticky; bottom:0; display:flex; justify-content:flex-end; padding:var(--rhine-space-1) var(--rhine-space-2); border-top:1px solid var(--rhine-control); background:var(--rhine-overlay-bg); }
|
|
50
52
|
.rhine-overlay-anchor { display:inline-block; vertical-align:middle; max-width:100%; }
|
|
51
53
|
.rhine-overlay { position:fixed; inset:0; background:var(--rhine-backdrop-bg); z-index:40; }
|
|
52
54
|
.rhine-dialog { position:fixed; z-index:41; top:50%; left:50%; transform:translate(-50%,-50%); width:min(480px,calc(100% - 32px)); max-height:calc(100% - 32px); overflow:auto; padding:24px; border:1px solid var(--rhine-line); border-radius:4px; background:var(--rhine-overlay-bg); color:var(--rhine-overlay-text); box-shadow:0 16px 48px #242b2826; }
|
|
@@ -72,6 +74,8 @@
|
|
|
72
74
|
.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
75
|
.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
76
|
.rhine-toast p { margin:4px 0 0; overflow-wrap:anywhere; }.rhine-toast strong { overflow-wrap:anywhere; }.rhine-toast>div { min-width:0; }
|
|
77
|
+
.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; }
|
|
78
|
+
.rhine-toast-region[data-placement="floating"] .rhine-toast { pointer-events:auto; box-shadow:0 8px 24px #242b2820; }
|
|
75
79
|
.rhine-dialog[data-state=open],.rhine-overlay[data-state=open] { animation:rhine-overlay-fade var(--rhine-duration-overlay) var(--rhine-ease-standard); }
|
|
76
80
|
.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
81
|
@keyframes rhine-overlay-fade { from { opacity:0; } to { opacity:1; } }
|
|
@@ -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, fullWidth = false }: { label: string; items: Choice[]; value: string; onValueChange: (value: string) => void; fullWidth?: boolean }) {
|
|
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={classes('rhine-segmented', fullWidth && 'rhine-segmented--full-width')}><legend className="rhine-sr-only">{label}</legend>
|
|
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/styles.css
CHANGED
|
@@ -60,7 +60,14 @@
|
|
|
60
60
|
.rhine-tabs__list button[aria-selected=true] { color:var(--rhine-ink); }
|
|
61
61
|
.rhine-tabs__list button[aria-selected=true]::after { transform:scaleX(1); }
|
|
62
62
|
.rhine-tabs__list button:disabled { color:var(--rhine-muted); opacity:.6; cursor:default; }
|
|
63
|
+
.rhine-tabs__list--detailed { align-items:stretch; gap:var(--rhine-space-4); }
|
|
64
|
+
.rhine-tabs__list--detailed>button { display:flex; flex-direction:column; align-items:stretch; flex:1 1 0; min-width:0; padding-block:var(--rhine-space-3); text-align:start; overflow-wrap:anywhere; }
|
|
65
|
+
.rhine-tabs__list--detailed>button:hover:not(:disabled) { color:var(--rhine-ink); }
|
|
66
|
+
.rhine-tabs__title { display:flex; align-items:center; flex-wrap:wrap; gap:var(--rhine-space-2); font-weight:var(--rhine-weight-heading); }
|
|
67
|
+
.rhine-tabs__status { display:inline-flex; }
|
|
68
|
+
.rhine-tabs__description { display:block; margin-top:var(--rhine-space-1); color:var(--rhine-muted); font:var(--rhine-weight-regular) var(--rhine-text-caption)/var(--rhine-leading-caption) var(--rhine-font); }
|
|
63
69
|
.rhine-tabs__panel { padding-top:20px; animation:rhine-enter var(--rhine-duration-panel) var(--rhine-ease-standard); }
|
|
70
|
+
.rhine-tabs__list--detailed~.rhine-tabs__panel { padding-top:var(--rhine-space-6); }
|
|
64
71
|
.rhine-segmented { display:inline-flex; flex-wrap:wrap; gap:3px; padding:3px; border:1px solid var(--rhine-control); border-radius:var(--rhine-radius-control); margin:0; min-width:0; }
|
|
65
72
|
.rhine-segmented__option { position:relative; display:flex; flex:1 0 auto; cursor:pointer; }
|
|
66
73
|
.rhine-segmented input { position:absolute; opacity:0; width:1px; height:1px; }
|
|
@@ -71,6 +78,10 @@
|
|
|
71
78
|
.rhine-segmented input:checked+span { background:var(--rhine-ink); color:var(--rhine-surface); }
|
|
72
79
|
.rhine-segmented input:focus-visible+span { outline:2px solid var(--rhine-green); outline-offset:3px; }
|
|
73
80
|
.rhine-segmented input:disabled+span { opacity:.6; cursor:default; }
|
|
81
|
+
.rhine-theme .rhine-segmented--compact { padding:0; border:0; gap:0; background:transparent; }
|
|
82
|
+
.rhine-segmented--compact .rhine-segmented__option { min-width:44px; min-height:44px; padding:6px 0; }
|
|
83
|
+
.rhine-segmented--compact .rhine-segmented__option span { min-height:32px; padding:6px 8px; font-size:12px; line-height:18px; background:var(--rhine-surface); box-shadow:inset 0 0 0 1px var(--rhine-control); }
|
|
84
|
+
.rhine-segmented--compact input:checked+span { background:var(--rhine-ink); color:var(--rhine-surface); }
|
|
74
85
|
.rhine-notice { padding:16px; border-left:3px solid var(--rhine-tone); background:var(--rhine-tone-bg); color:var(--rhine-tone); display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; animation:rhine-enter var(--rhine-duration-notice) var(--rhine-ease-standard); }
|
|
75
86
|
.rhine-notice>div { flex:1 1 200px; min-width:0; overflow-wrap:anywhere; }
|
|
76
87
|
.rhine-notice strong { font-size:14px; font-weight:600; }
|
|
@@ -84,6 +95,7 @@
|
|
|
84
95
|
.rhine-skeleton>span[aria-hidden] { display:block; height:16px; background:var(--rhine-line); animation:rhine-pulse var(--rhine-duration-skeleton) ease-in-out infinite; }
|
|
85
96
|
.rhine-skeleton>span[aria-hidden]:last-child { width:65%; }
|
|
86
97
|
.rhine-disclosure { border-top:1px solid var(--rhine-line); border-bottom:1px solid var(--rhine-line); overflow:hidden; }
|
|
98
|
+
.rhine-disclosure + .rhine-disclosure { border-top:0; }
|
|
87
99
|
.rhine-disclosure summary { min-height:48px; padding:12px 0; list-style:none; cursor:pointer; display:flex; justify-content:space-between; align-items:center; gap:16px; font-weight:500; }
|
|
88
100
|
.rhine-disclosure summary::-webkit-details-marker { display:none; }
|
|
89
101
|
.rhine-theme .rhine-disclosure summary:focus-visible { outline-offset:-3px; }
|
|
@@ -29,7 +29,7 @@ export function useFloatingSide(anchor: RefObject<HTMLSpanElement | null>, side:
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Keep Radix's viewport coordinates unscaled while preserving the user's content zoom. */
|
|
32
|
-
export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind = 'popover') {
|
|
32
|
+
export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind = 'popover', maxHeight?: number) {
|
|
33
33
|
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
|
34
34
|
const attach = useCallback((element: HTMLDivElement | null) => {
|
|
35
35
|
if (ref) ref.current = element;
|
|
@@ -62,7 +62,11 @@ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind =
|
|
|
62
62
|
content.style.zoom = String(scale);
|
|
63
63
|
const width = `calc((100vw - 24px) / ${scale})`;
|
|
64
64
|
content.style.maxWidth = kind === 'tooltip' ? `min(280px, ${width})` : width;
|
|
65
|
-
|
|
65
|
+
// Bounded suggestion lists retain their own height cap. Feeding Radix's
|
|
66
|
+
// available-height variable back into their size can cause resize loops.
|
|
67
|
+
content.style.maxHeight = maxHeight === undefined
|
|
68
|
+
? `calc(var(--radix-${kind}-content-available-height) / ${scale})`
|
|
69
|
+
: `min(${maxHeight}px, calc((100vh - 24px) / ${scale}))`;
|
|
66
70
|
if (kind === 'dropdown-menu') content.style.minWidth = `min(180px, ${width})`;
|
|
67
71
|
constrain();
|
|
68
72
|
};
|
|
@@ -91,6 +95,6 @@ export function useFloatingScale(ref?: RefObject<HTMLDivElement | null>, kind =
|
|
|
91
95
|
content.style.maxHeight = original.maxHeight;
|
|
92
96
|
content.style.minWidth = original.minWidth;
|
|
93
97
|
};
|
|
94
|
-
}, [content, kind]);
|
|
98
|
+
}, [content, kind, maxHeight]);
|
|
95
99
|
return attach;
|
|
96
100
|
}
|
|
@@ -108,7 +108,7 @@ SideNav 继续负责当前项、长名称和 aria-current;Drawer 继续负责
|
|
|
108
108
|
| DateRangePicker | 上述范围 + `value={start,end}`、`presets=[{label,value}]`、`compact?` | 两个原生日期输入,自定义范围、受控预设、清空;compact 合并为一体区间框;结束日期下界取 start/min 较晚者;反向范围显示错误,不静默调换 |
|
|
109
109
|
| NumberField | `value: number\|null`、`onValueChange/min/max/step/precision/disabled/error` | 编辑中保存字符串;失焦/Enter 提交;无效及越界输入保留并报错;上下键/按钮步进与钳制边界;空字符串提交 null |
|
|
110
110
|
| TreeSelect | `nodes/value/onValueChange/expandedKeys/onExpandedChange/multiple`、loading/error/disabled | 节点为 `{id,label,disabled?,children?}`;父子独立选择,默认单选;调用方提供搜索后的树,不做全量过滤 |
|
|
111
|
-
| RemoteMultiSelect | `query/onQueryChange/value/onValueChange/options`、loading/error/disabled、`maxVisibleTags?` | value 是完整 `{value,label,disabled?}[]
|
|
111
|
+
| RemoteMultiSelect | `query/onQueryChange/value/onValueChange/options`、loading/error/disabled、`maxVisibleTags?` | value 是完整 `{value,label,disabled?}[]`,已选标签不依赖本次结果;再次点击已选候选取消;清空保留禁选标签;maxVisibleTags 限制常显数量并提供展开/收起,展开区域有界滚动 |
|
|
112
112
|
|
|
113
113
|
日期 min/max 约束原生日历和表单有效性;手动编辑、业务校验及最终提交由宿主验证。原生日期面板位于浏览器 UI,不放在受裁剪的自定义浮层。日历外观、屏幕阅读器和移动 WebView 行为由具体浏览器决定,不声称提供自绘日历。预设必须使用宿主计算好的日期;不得以本机时区代替产品时区。没有“今天”隐式计算。
|
|
114
114
|
|
|
@@ -126,7 +126,7 @@ TreeSelect 使用 tree/treeitem/group,Tab 进入一个可见节点,上下/Ho
|
|
|
126
126
|
|
|
127
127
|
首次进入优先已选可见节点;字符前缀定位不改变选择,`*` 展开同层分支,Ctrl/⌘+A 仅切换可见可选节点。外部收起时焦点回可见祖先。节点 `selectable=false` 用于结构分组;readOnly 允许浏览展开而禁止选择。`name` 为选中 key 生成同名 hidden inputs,disabled 不提交。
|
|
128
128
|
|
|
129
|
-
RemoteMultiSelect 复用 Combobox 的方向键、Enter、Escape 和 IME
|
|
129
|
+
RemoteMultiSelect 复用 Combobox 的方向键、Enter、Escape 和 IME 防误提交。加载或失败时禁止选择旧候选,已选项仍显示且可移除;默认选中后保持候选列表打开,可再次点击或方向键+Enter取消已选候选;真正禁用/锁定项不可更改。打开多选候选时,输入框Tab进入“完成选择”,Shift+Tab返回输入框;从完成按钮继续Tab关闭候选并沿输入框后的正常控件前进。面板底部“完成选择”只收起候选并归焦输入框,保留查询和值,不提交宿主表单;最终保存由宿主负责。Escape或点击外部也保留草稿。`closeOnSelect=true` 恢复选完关闭。`clearQueryOnSelect` 默认 false;新增 readOnly/clearable/placeholder/hint/name。空查询 Backspace 删除最后一个可移除标签,标签×移除与清空统一归焦输入框并收起候选;输入框内空查询Backspace删除则保留原展开/收起状态,不因归焦改变候选状态。仅重新点击、输入或方向键才展开。没有可移除项(含0项或只有锁定项)时隐藏“清空选择”;锁定标签保留。name 以同名 hidden inputs 提交受控 keys。单选 Combobox 的默认关闭行为不变。异步取消、序列检查、服务端查询和时区解释全部归宿主。展台有可取消的本地延迟结果演示,但不是生产数据层。
|
|
130
130
|
|
|
131
131
|
分组底色 `--rhine-field-group-bg`;树缩进 `--rhine-tree-indent`;其余输入、选择、错误和焦点继续用既有语义 tokens,字号不低于 12px。
|
|
132
132
|
|