@linhey/rhine-design 0.11.0 → 0.11.2
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 +35 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +9 -4
- package/components/Actions.tsx +1 -0
- package/components/AdvancedForms.tsx +13 -5
- package/components/AppShell.tsx +3 -2
- package/components/COMMON_COMPONENTS.md +5 -1
- package/components/Chat.tsx +160 -0
- package/components/Forms.tsx +66 -6
- package/components/Navigation.tsx +15 -4
- package/components/Overlays.tsx +37 -7
- package/components/README.md +11 -1
- package/components/chat.css +32 -0
- package/components/common.css +2 -0
- package/components/components.tsx +19 -9
- package/components/dialog-settings.css +54 -1
- package/components/extensions.css +4 -0
- package/components/index.ts +8 -3
- package/components/patterns.css +4 -0
- package/components/styles.css +11 -2
- package/components/theme-arknights.css +4 -2
- package/components/useFloatingScale.ts +7 -3
- package/docs/AGENT_CHAT.md +76 -0
- package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
- package/docs/APPLICATION_COMPONENTS.md +4 -2
- package/docs/BUTTON_ACTIONS.md +32 -0
- package/docs/BUTTON_ACTIONS_VERIFICATION.md +14 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +16 -0
- package/docs/COMPACT_SELECTION_VERIFICATION.md +15 -0
- package/docs/FLOATING_OVERLAYS.md +20 -0
- package/docs/INTERACTION_PATTERNS.md +4 -0
- package/docs/SETTINGS_COMPOSITION_VERIFICATION.md +12 -0
- package/docs/SETTINGS_DIALOG.md +42 -4
- package/docs/SIDE_NAV_ACTIONS.md +9 -0
- package/docs/SINGLE_SEARCH.md +19 -0
- package/docs/SINGLE_SEARCH_VERIFICATION.md +15 -0
- package/docs/SUMMARY_TABS.md +7 -1
- package/docs/TRAY_USAGE.md +34 -0
- package/docs/TRAY_USAGE_VERIFICATION.md +13 -0
- package/docs/assets/chat/after.png +0 -0
- package/docs/assets/chat/arknights.png +0 -0
- package/docs/assets/chat/before.png +0 -0
- package/docs/assets/chat/neo-brutalism.png +0 -0
- package/package.json +1 -1
- package/preview/ButtonActionsGallery.tsx +25 -0
- package/preview/ChatGallery.tsx +98 -0
- package/preview/CompactSelectionGallery.tsx +76 -0
- package/preview/DenseFilterControlsGallery.tsx +5 -2
- package/preview/GalleryShell.tsx +2 -2
- package/preview/NavActionsGallery.tsx +24 -0
- package/preview/SettingsCompositionGallery.tsx +38 -0
- package/preview/SettingsDialogGallery.tsx +54 -14
- package/preview/SingleSearchGallery.tsx +50 -0
- package/preview/SummaryTabsGallery.tsx +7 -3
- package/preview/TrayUsageGallery.tsx +82 -0
- package/preview/chat-gallery.css +1 -0
- package/preview/compact-selection.css +9 -0
- package/preview/main.tsx +15 -1
- package/preview/packageInfo.ts +1 -1
- package/preview/settings-dialog-gallery.css +5 -0
- package/preview/tray-usage.css +4 -0
- package/scripts/check-button-actions.js +34 -0
- package/scripts/check-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-compact-selection.js +40 -0
- package/scripts/check-dialog-scroll-boundaries.js +41 -0
- package/scripts/check-multiselect-completion.js +46 -0
- package/scripts/check-nav-actions.js +41 -0
- package/scripts/check-settings-bounded.js +49 -0
- package/scripts/check-settings-composition.js +50 -0
- package/scripts/check-settings-interactions.js +93 -0
- package/scripts/check-settings-theme.js +1 -5
- package/scripts/check-single-search.js +43 -0
- package/scripts/check-tray-usage.js +68 -0
- package/scripts/prepare-settings-consumer.mjs +21 -0
- package/skills/rhine-design/SKILL.md +2 -0
- package/skills/rhine-design/references/API_MAP.md +11 -0
- package/skills/rhine-design/references/TASK_MAP.md +1 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/Chat.test.tsx +84 -0
- package/tests/CompactSelection.test.tsx +47 -0
- package/tests/FormParity.test.tsx +104 -2
- package/tests/NavActions.test.tsx +29 -0
- package/tests/SettingsDialog.test.tsx +102 -9
- package/tests/SingleSearch.test.tsx +42 -0
- package/tests/SkillInstallation.test.tsx +77 -0
- package/tests/TrayUsage.test.tsx +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# 版本记录
|
|
2
2
|
|
|
3
|
+
## 0.11.2(2026-10-28)
|
|
4
|
+
|
|
5
|
+
#18 设置 Dialog 修复基于精确候选 `02cc2c2`,默认行为保持兼容;正式发布状态以 registry 回读为准。
|
|
6
|
+
|
|
7
|
+
- settings宽度不再被后置公共CSS覆盖;复用 `height` / `footer`,固定标题/关闭与右对齐底部动作,正文和长导航独立有界滚动。
|
|
8
|
+
- 极窄容器的底部操作间距压缩至 2px,覆盖 400px 带边框 iframe 实际 398px / 200% CSS zoom;保留 44px 点击目标,避免三项短动作仅因边框差值换行挤压正文。
|
|
9
|
+
- `Dialog.description` 可省略,省略时没有说明占位或悬空 `aria-describedby`;普通短 Dialog、Drawer 及 sidePanel 策略保留。
|
|
10
|
+
- 自动高度 footer 按可用视口及祖先 CSS zoom 限制,纯文本有界正文提供命名键盘滚动入口;长操作末项保持可达。
|
|
11
|
+
- 真实展台覆盖30行、长导航/按钮、异步栏目、错误/冲突、嵌套搜索多选与草稿行为;补充生产构建次序、固定 tarball 消费和短屏回归。
|
|
12
|
+
- npm 制品:`@linhey/rhine-design@0.11.2`;消费方仍需固定版本并提交 lockfile。正式发布与 registry 回读见本次发布记录。
|
|
13
|
+
|
|
14
|
+
## 未发布
|
|
15
|
+
|
|
16
|
+
- #46:战术主题danger按钮使用可读的深色文字/红底配对(修复原默认约2.77:1文字对比);Button新增size="standard"(默认)/"large",补动作语义→variant矩阵和真实并排展台;退出登录不自动等同不可逆删除,large为16/24、至少52px。未包含在0.11.0。
|
|
17
|
+
|
|
18
|
+
- #41:紧凑选择组合,Button内容两端对齐、Tabs显式content/fill布局(新默认content)、Dialog实例height/footer及内部滚动,草稿预览/保存契约和展台;不进入冻结0.11.0制品。摘要Tabs若需保持旧隐式铺满,显式传layout="fill"。
|
|
19
|
+
|
|
20
|
+
- #42:TextField 新增 `labelVisibility` 与公开 `TextFieldProps`,默认可见;非空标签、正文级单输入搜索组合及异步/IME展台见[契约](docs/SINGLE_SEARCH.md)。此项不包含在 0.11.0 发布候选中。
|
|
21
|
+
|
|
22
|
+
- #45:SideNav 支持可选行尾 actions,空动作不占位,复用 Menu/IconButton,提供受控 Dialog 归焦组合与真实展台;不包含在0.11.0中。
|
|
23
|
+
- #43:新增 `ChatThread` / `ChatCard` 聊天组合候选,提供多成员分组、卡片状态与详情、只读动作约束、历史锚点和显式恢复跟随;附三组合成展台及[规范与参考取舍](docs/AGENT_CHAT.md)。不包含模型调用、消费迁移或依赖发布。Button 尊重 `aria-disabled` 并阻止激活,支持保留不可操作控件焦点。
|
|
24
|
+
|
|
25
|
+
其余候选仍按各自发布记录管理;消费方应固定精确版本并提交 registry lockfile。
|
|
26
|
+
|
|
27
|
+
## 0.11.1(2026-10-27)
|
|
28
|
+
|
|
29
|
+
- 补齐嵌套下拉退出到父Modal焦点约束恢复之间的快速Tab保护;连续Shift+Tab不再把焦点留在背景,正常导航恢复后仍可循环。
|
|
30
|
+
|
|
31
|
+
- 候选列表保持240px上限并按视口缩放约束,避免可用高度反馈;多选内部动作等click完成再收起,避免窄屏缩放中pointerdown/抬起之间位置移动丢失清空。
|
|
32
|
+
|
|
33
|
+
- 修复 Combobox / RemoteMultiSelect 在祖先 CSS zoom 下的候选浮层定位与视口边界,复用既有浮层缩放处理;保留主题 Portal、搜索、多选与键盘行为。
|
|
34
|
+
- RemoteMultiSelect 可再次点击或方向键+Enter取消已选项,保留真正禁用/锁定项;“完成选择”只收起候选并归焦,不提交宿主表单;支持输入框Tab进入完成、Shift+Tab返回及继续Tab到后继控件;标签×移除和清空后保持收起,输入框Backspace保留原展开态,无可移除项时隐藏清空按钮。
|
|
35
|
+
- `#compact-filters` 补充弹窗内多选与真实浏览器回归;参见[浮层契约](docs/FLOATING_OVERLAYS.md)。
|
|
36
|
+
- 补丁制品从 `v0.11.0` 回补上述修复,主入口仍为67项;本节上方的未发布新功能不包含在0.11.1中。消费方固定版本并提交 registry lockfile。
|
|
37
|
+
|
|
3
38
|
## 0.11.0(2026-10-27)
|
|
4
39
|
|
|
5
40
|
本版本新增产品评审工具及以下已合并能力;npm 0.10.0 不包含这些新增 API。组件主入口仍为 67 项,默认行为保持兼容;消费方固定 0.11.0 并提交 lockfile。实际发布可用性以 registry 为准。
|
package/CONTRIBUTING.md
CHANGED
|
@@ -35,6 +35,8 @@ README 面向首次接触项目的开发者,优先解释用途、安装、示
|
|
|
35
35
|
|
|
36
36
|
组件 API 变更时,更新契约、示例以及 skill 的 `references/API_MAP.md` 或 `TASK_MAP.md`。索引指向真实源码,不复制第二份 API 定义;skill 的版本信息随包版本更新。
|
|
37
37
|
|
|
38
|
+
skill 会被单独复制安装,Markdown 相对链接必须留在 `skills/rhine-design/` 内。包根路径使用代码格式;在线契约链接固定提交或版本,并说明与已安装包的关系。运行 `npm test -- tests/SkillInstallation.test.tsx`,在仓库外的临时空目录验证 README 复制安装与独立 skill 安装,检查内部文件/标题链接、越界路径及浮动源码链接。此检查不发网络请求,新增在线快照仍需核对对应远端文件可访问。
|
|
39
|
+
|
|
38
40
|
展台包版本由 `preview/packageInfo.ts` 从 `package.json` 读取;组件数量只统计 `components/index.ts` 的公开组件,不计类型与独立主题入口。新增导出时同步该数量、README 完整清单与组件入口。发布后的源码新增能力写入 CHANGELOG 的“未发布”部分,并在相关契约标明旧包不包含;不要因为源码仍使用同一版本字段,就将新能力归入已发布版本。历史版本介绍与验收记录保留原时点。
|
|
39
41
|
|
|
40
42
|
## 提交 Pull Request
|
package/README.md
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
<a href="CHANGELOG.md">版本记录</a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
-
Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、
|
|
19
|
+
Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、管理界面和数据密集型应用。它将设计规范、69 个组件和动效示例放在一起,帮助团队建立一致、可复用的界面语言。
|
|
20
20
|
|
|
21
21
|
- **一致的视觉语言**:色彩、字体、文字层级、间距、几何与焦点规则。
|
|
22
|
-
- **
|
|
22
|
+
- **69 个可组合组件**:覆盖布局、表单、数据、导航、浮层和反馈。
|
|
23
23
|
- **可验证的动效**:12 组播放 / 重播 / 复位演示,以及三角构架启动编排。
|
|
24
24
|
- **面向开发协作**:组件契约、交互展台和 AI skill 帮助设计与实现保持一致。
|
|
25
25
|
|
|
@@ -27,8 +27,9 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
27
27
|
|
|
28
28
|
## 版本与源码范围
|
|
29
29
|
|
|
30
|
-
- **本源码包版本:0.11.0
|
|
30
|
+
- **本源码包版本:0.11.2**。0.11.0 制品主入口为 67 个组件;当前源码含未发布 ChatThread / ChatCard,共 69 个组件,统计不含类型及独立 `/theme` 入口的 ThemeProvider、hook 和元数据。发布是否可安装以 npm registry 为准。
|
|
31
31
|
- **0.11.0 新增**:产品评审 `/review`、`/review/vite`,摘要页签 `Tabs description/status`、`Toast placement="floating"`、`SegmentedControl size="compact"` 及相邻 Disclosure 分隔线与生产动效修正。npm 0.10.0 不包含这些新增 API。
|
|
32
|
+
- **未发布聊天组合**:消息分组、结构化卡片及历史跟随见 [Agent Chat](docs/AGENT_CHAT.md),不在 0.11.0 制品中。
|
|
32
33
|
- **展台随源码更新**,在线部署跟随 main;安装包的能力以精确版本和实际导出/实现为准。上述源码变更、历史版本与接入边界统一见[版本记录](CHANGELOG.md)。
|
|
33
34
|
|
|
34
35
|
三套主题、轨道艺术、设置导航与主题选择等已有能力见下方组件和规范入口;应用组件的 Ant Design / MUI 对照见[对标清单](docs/ANTD_MUI_AUDIT.md)。
|
|
@@ -42,7 +43,7 @@ Rhine Design 是面向 React 应用的开源设计系统,适合桌面工具、
|
|
|
42
43
|
在你的 React 项目中安装:
|
|
43
44
|
|
|
44
45
|
```sh
|
|
45
|
-
npm install --save-exact @linhey/rhine-design@0.11.
|
|
46
|
+
npm install --save-exact @linhey/rhine-design@0.11.2
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
包与版本信息见 [npm](https://www.npmjs.com/package/@linhey/rhine-design)。如需使用未发版的源码,可安装 `git+https://github.com/linhay/rhine-design.git#<commit>`;建议固定版本并提交 lockfile。
|
|
@@ -133,6 +134,7 @@ export function App() {
|
|
|
133
134
|
| 布局与背景 | Surface · Toolbar · Divider · Stack · Inline · Grid · Background² · AppShell² · AppShellToggle⁴ · OrbitBackdrop³ · NeutronStarBackdrop⁴ |
|
|
134
135
|
| 表单与选择 | TextField · Fieldset¹ · ErrorSummary¹ · Textarea · Select · Checkbox · RadioGroup · Switch · SearchField · Combobox · SelectionCard · DateField² · DateRangePicker² · NumberField² · TreeSelect² · RemoteMultiSelect² · ThemePicker⁴ · ThemePreview⁴ |
|
|
135
136
|
| 导航与展开 | SideNav¹ · Breadcrumbs¹ · Steps¹ · Tabs · SegmentedControl · Disclosure |
|
|
137
|
+
| 聊天组合(源码候选) | ChatThread · ChatCard |
|
|
136
138
|
| 搜索与筛选组合 | ListFilterBar⁵ |
|
|
137
139
|
| 数据与媒体 | Metric · KeyValue · DataTable · Pagination · StatusTimeline¹ · StatusTimelineList⁴ · TimelineLegend¹ · TrendChart² · StackedBar² · MediaCard² · MediaViewer² |
|
|
138
140
|
| 浮层 | Drawer¹ · Dialog · ConfirmDialog · Popover · Menu · Tooltip |
|
|
@@ -150,6 +152,7 @@ export function App() {
|
|
|
150
152
|
|
|
151
153
|
| 主题 | 文档 | 在线预览 |
|
|
152
154
|
| --- | --- | --- |
|
|
155
|
+
| 聊天消息、卡片与历史(源码候选) | [Agent Chat](docs/AGENT_CHAT.md) | [聊天消息与记录](https://linhay.github.io/rhine-design/#agent-chat) |
|
|
153
156
|
| 视觉基础与组合原则 | [设计规范](docs/DESIGN_SYSTEM.md) | [视觉基础](https://linhay.github.io/rhine-design/#foundation) |
|
|
154
157
|
| 间距、分组与卡片组合 | [Spacing](components/SPACING.md) | [间距与组合](https://linhay.github.io/rhine-design/#spacing) |
|
|
155
158
|
| 字体、文字层级与数值 | [Typography](components/TYPOGRAPHY.md) | [字体与文字](https://linhay.github.io/rhine-design/#typography) |
|
|
@@ -190,6 +193,8 @@ mkdir -p .agents/skills
|
|
|
190
193
|
cp -R node_modules/@linhey/rhine-design/skills/rhine-design .agents/skills/
|
|
191
194
|
```
|
|
192
195
|
|
|
196
|
+
复制后,skill 内部链接仍以 skill 目录为基准;以代码格式列出的 `docs/`、`components/` 等路径以已核对版本的组件包根目录为基准。独立安装 skill 且尚未安装组件包时,可使用其标明固定提交的在线契约后备链接;该快照不会跟随 main,也不代表当前安装版本的 API。
|
|
197
|
+
|
|
193
198
|
然后向助手提出具体任务,例如:
|
|
194
199
|
|
|
195
200
|
> 使用 $rhine-design 设计一个设置页面,包含搜索、选项开关和保存反馈;复用现有组件,并提供键盘操作与减少动态效果。
|
package/components/Actions.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { forwardRef, useEffect, useRef, useState, type AnchorHTMLAttributes, typ
|
|
|
2
2
|
import { Button, type ButtonProps } from './components';
|
|
3
3
|
|
|
4
4
|
const paths = {
|
|
5
|
+
settings: 'M3 5h3m4 0h7M3 15h3m4 0h7M3 10h8m4 0h2M6 3h4v4H6zM11 8h4v4h-4zM6 13h4v4H6z',
|
|
5
6
|
menu: 'M3 5h14M3 10h14M3 15h14',
|
|
6
7
|
close: 'm5 5 10 10M15 5 5 15',
|
|
7
8
|
chevronDown: 'm5 8 5 5 5-5',
|
|
@@ -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
|
}
|
package/components/AppShell.tsx
CHANGED
|
@@ -15,6 +15,7 @@ export interface AppShellProps {
|
|
|
15
15
|
header?: ReactNode; contentHeader?: ReactNode; children: ReactNode; label?: string; decoration?: BackgroundDecoration;
|
|
16
16
|
className?: string; style?: CSSProperties;
|
|
17
17
|
controls?: 'header' | 'inline';
|
|
18
|
+
collapseEmptyContentHeader?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
interface PanelControl { panel: ShellPanel; overlay: boolean; side: 'left' | 'right'; id: string; drawerOpen: boolean; setDrawerOpen: (open: boolean) => void }
|
|
@@ -45,7 +46,7 @@ function Panel({ panel, overlay, side, id, drawerOpen, setDrawerOpen, hidden = f
|
|
|
45
46
|
</div>;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
export function AppShell({ layout, navigation, secondary, header, contentHeader, children, label = '应用工作区', decoration = 'auto', className = '', style, controls = 'header' }: AppShellProps) {
|
|
49
|
+
export function AppShell({ layout, navigation, secondary, header, contentHeader, children, label = '应用工作区', decoration = 'auto', className = '', style, controls = 'header', collapseEmptyContentHeader = false }: AppShellProps) {
|
|
49
50
|
const ref = useRef<HTMLDivElement>(null);
|
|
50
51
|
const focusedPanel = useRef<'nav' | 'secondary' | null>(null);
|
|
51
52
|
const focusedToggle = useRef<string | null>(null);
|
|
@@ -93,7 +94,7 @@ export function AppShell({ layout, navigation, secondary, header, contentHeader,
|
|
|
93
94
|
<div className="rhine-shell__body">
|
|
94
95
|
<aside id={`${id}-nav`} className="rhine-shell__nav" style={{ flexBasis: navigation.width }} aria-label={navigation.label} hidden={navOverlay || navigationDrawer || !navigation.open}>{!navigationDrawer && navigation.content}</aside>
|
|
95
96
|
<aside id={`${id}-secondary`} className="rhine-shell__secondary" style={{ flexBasis: secondary?.width }} aria-label={secondary?.label} hidden={!hasSecondary || secondaryOverlay || secondaryDrawer || !secondary?.open}>{hasSecondary && !secondaryDrawer && secondary?.content}</aside>
|
|
96
|
-
<div className="rhine-shell__main">{contentHeader != null && <header className="rhine-shell__content-header">{contentHeader}</header>}
|
|
97
|
+
<div className="rhine-shell__main">{contentHeader != null && <header className="rhine-shell__content-header" data-collapse-empty={collapseEmptyContentHeader || undefined}>{contentHeader}</header>}
|
|
97
98
|
<Background className="rhine-shell__content" decoration={decoration} tabIndex={0} role="region" aria-label="正文">{children}</Background></div>
|
|
98
99
|
</div>
|
|
99
100
|
</div></ShellControls.Provider>;
|
|
@@ -26,7 +26,7 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
26
26
|
| Combobox | 受控查询与选中值;候选由调用方返回;方向键/Enter/Escape,IME保护,加载/无结果/错误 |
|
|
27
27
|
| Tooltip | 鼠标与键盘说明、Escape、可悬停;不可放交互控件或关键信息 |
|
|
28
28
|
| Toast | 单条通知、关闭;成功可定时、警告错误持久;hover/focus/页面隐藏暂停计时,无全局队列 |
|
|
29
|
-
| Dialog / ConfirmDialog |
|
|
29
|
+
| Dialog / ConfirmDialog | 必填标题(Dialog说明可省略)、焦点限制及归还、Escape、滚动;确认默认聚焦取消,异步失败保留内容 |
|
|
30
30
|
| Popover | 按钮触发非模态详情、边缘避让、Escape与焦点归还;不替代Tooltip |
|
|
31
31
|
| Menu | 操作项/禁用/危险项;方向键/Home/End/输入字符跳转;不是表单选择器 |
|
|
32
32
|
| Divider / Stack / Inline / Grid | 语义分隔线、纵排、可换行横排、自适应网格;只消费间距tokens |
|
|
@@ -75,3 +75,7 @@ SideNav、Breadcrumbs、Steps、Drawer、Fieldset、ErrorSummary、StatusTimelin
|
|
|
75
75
|
展台使用当前源码的真实组件;本页重点覆盖数据、表单、操作/布局、反馈/浮层四组。合成数据只演示事件与状态;Clipboard仅复制明确的演示字符串。测试覆盖分页边界、请求状态、异步复制失败、IME、候选列表、通知计时、浮层焦点/Escape与窄窗;原生宿主原生平台和屏幕阅读器仍需App接入后单独验收。
|
|
76
76
|
|
|
77
77
|
Toast 浮动通知(自 0.11.0,npm 0.10.0 不含,见[版本记录](../CHANGELOG.md#01102026-10-27)):`placement="floating"` 通过 Portal 挂到最近主题根,位于视口右下角,距边缘 16px、最大宽度 400px,窄窗两侧各留 16px。默认 inline 行为保持;浮动通知不占内容流、不抢焦点,关闭后不拦截点击;继承主题与减少动态。反馈展台使用浮动模式。成功沿用 5 秒关闭及悬停/聚焦/隐藏暂停,关键错误仍持久。
|
|
78
|
+
|
|
79
|
+
SideNav 可选 `NavItem.actions` / `NavAction`,空数组无按钮/空位;菜单触发器与链接为兄弟。菜单到受控 Dialog 的归焦见[导航行操作](../docs/SIDE_NAV_ACTIONS.md)(未发布)。
|
|
80
|
+
|
|
81
|
+
Popover可选 `showHeader={false}`(未发布,默认true)省略可见标题/关闭行;非空title仍为可访问名称,触发器必须具名可聚焦。适用范围、退出与组合契约见[托盘用量](../docs/TRAY_USAGE.md)。
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createContext, useContext, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Badge, Button, Disclosure, Surface, type Tone } from './components';
|
|
3
|
+
|
|
4
|
+
export type ChatStatus = 'pending' | 'accepted' | 'declined' | 'blocked' | 'running' | 'partial' | 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';
|
|
5
|
+
const statuses: Record<ChatStatus, [string, Tone]> = {
|
|
6
|
+
pending: ['待回应', 'neutral'], accepted: ['已接受', 'neutral'], declined: ['已拒绝', 'neutral'],
|
|
7
|
+
blocked: ['等待依赖', 'warning'], running: ['运行中', 'neutral'], partial: ['部分结果', 'warning'],
|
|
8
|
+
succeeded: ['已完成', 'success'], failed: ['失败', 'error'], cancelled: ['已取消', 'neutral'],
|
|
9
|
+
expired: ['已过期', 'warning'], unknown: ['状态未知', 'warning'],
|
|
10
|
+
};
|
|
11
|
+
const ReadOnlyContext = createContext(false);
|
|
12
|
+
export interface ChatCardProps {
|
|
13
|
+
title: string;
|
|
14
|
+
status: ChatStatus;
|
|
15
|
+
/** Keep error/recovery or unknown-state context here, outside the disclosure. */
|
|
16
|
+
summary: ReactNode;
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
actions?: ReactNode;
|
|
19
|
+
readOnly?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Structured content only. Business transitions and permissions belong to the host. */
|
|
22
|
+
export function ChatCard({ title, status, summary, children, actions, readOnly = false }: ChatCardProps) {
|
|
23
|
+
const observing = useContext(ReadOnlyContext) || readOnly;
|
|
24
|
+
const [label, tone] = Object.hasOwn(statuses, status) ? statuses[status] : statuses.unknown;
|
|
25
|
+
return <Surface className="rhine-chat-card" padding="none">
|
|
26
|
+
<div className="rhine-chat-card__heading"><strong>{title}</strong><Badge tone={tone}>{label}</Badge></div>
|
|
27
|
+
<div className="rhine-chat-card__summary">{summary}</div>
|
|
28
|
+
{children && <Disclosure title={`${title} · 详情`}>{children}</Disclosure>}
|
|
29
|
+
{!observing && actions && <div className="rhine-chat-actions">{actions}</div>}
|
|
30
|
+
</Surface>;
|
|
31
|
+
}
|
|
32
|
+
export interface ChatMember { id: string; name: string; avatar?: ReactNode }
|
|
33
|
+
interface ChatEntryBase { id: string; /** Host-localized day label, also a grouping boundary. */ day: string; content: ReactNode }
|
|
34
|
+
export interface ChatMessage extends ChatEntryBase {
|
|
35
|
+
kind: 'message'; member: ChatMember;
|
|
36
|
+
/** ISO 8601 timestamp with explicit offset. */ dateTime: string;
|
|
37
|
+
/** Host-localized display time. */ time: string;
|
|
38
|
+
actions?: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
export interface ChatEvent extends ChatEntryBase { kind: 'event' }
|
|
41
|
+
export type ChatEntry = ChatMessage | ChatEvent;
|
|
42
|
+
export interface ChatThreadProps {
|
|
43
|
+
label: string;
|
|
44
|
+
/** Stable unique ids in chronological order; prepend older entries without replacing existing ids. */
|
|
45
|
+
entries: readonly ChatEntry[];
|
|
46
|
+
readOnly?: boolean;
|
|
47
|
+
hasEarlier?: boolean;
|
|
48
|
+
loadingEarlier?: boolean;
|
|
49
|
+
historyError?: string;
|
|
50
|
+
onLoadEarlier?: () => void;
|
|
51
|
+
/** A coarse host-owned announcement. Do not pass every streamed token. */
|
|
52
|
+
announcement?: string;
|
|
53
|
+
}
|
|
54
|
+
function isContinuation(previous: ChatEntry | undefined, entry: ChatMessage) {
|
|
55
|
+
if (previous?.kind !== 'message' || previous.member.id !== entry.member.id || previous.day !== entry.day) return false;
|
|
56
|
+
const gap = Date.parse(entry.dateTime) - Date.parse(previous.dateTime);
|
|
57
|
+
return gap >= 0 && gap <= 5 * 60_000;
|
|
58
|
+
}
|
|
59
|
+
type Anchor = { id: string; offset: number };
|
|
60
|
+
|
|
61
|
+
/** A non-virtualized, chronological reading viewport. No network, model or read receipts. */
|
|
62
|
+
export function ChatThread({ label, entries, readOnly = false, hasEarlier = false, loadingEarlier = false, historyError, onLoadEarlier, announcement = '' }: ChatThreadProps) {
|
|
63
|
+
const id = useId();
|
|
64
|
+
const viewport = useRef<HTMLDivElement>(null);
|
|
65
|
+
const content = useRef<HTMLOListElement>(null);
|
|
66
|
+
const follow = useRef(true);
|
|
67
|
+
const anchor = useRef<Anchor | null>(null);
|
|
68
|
+
const previousFirst = useRef<string | undefined>(undefined);
|
|
69
|
+
const initialized = useRef(false);
|
|
70
|
+
const previousIds = useRef(new Set<string>());
|
|
71
|
+
const [hasNewMessages, setHasNewMessages] = useState(false);
|
|
72
|
+
const [following, setFollowing] = useState(true);
|
|
73
|
+
const rows = () => Array.from(content.current?.querySelectorAll<HTMLElement>('[data-chat-entry]') ?? []);
|
|
74
|
+
function remember() {
|
|
75
|
+
const el = viewport.current;
|
|
76
|
+
if (!el) return;
|
|
77
|
+
const row = rows().find(row => row.offsetTop + row.offsetHeight > el.scrollTop);
|
|
78
|
+
anchor.current = row ? { id: row.dataset.chatEntry!, offset: row.offsetTop - el.scrollTop } : null;
|
|
79
|
+
}
|
|
80
|
+
function pause() { follow.current = false; setFollowing(false); remember(); }
|
|
81
|
+
function restore() {
|
|
82
|
+
const el = viewport.current;
|
|
83
|
+
if (!el) return;
|
|
84
|
+
if (follow.current) el.scrollTop = el.scrollHeight;
|
|
85
|
+
else if (anchor.current) {
|
|
86
|
+
const row = rows().find(row => row.dataset.chatEntry === anchor.current!.id);
|
|
87
|
+
if (row) el.scrollTop = row.offsetTop - anchor.current.offset;
|
|
88
|
+
}
|
|
89
|
+
remember();
|
|
90
|
+
}
|
|
91
|
+
// Commit before paint: a prepend preserves the same visible row, even if streaming also appends.
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
const first = entries[0]?.id;
|
|
94
|
+
const prepended = initialized.current && first !== previousFirst.current && entries.some(entry => entry.id === previousFirst.current);
|
|
95
|
+
if (prepended) { follow.current = false; setFollowing(false); }
|
|
96
|
+
const firstExisting = entries.findIndex(entry => previousIds.current.has(entry.id));
|
|
97
|
+
if (initialized.current && !follow.current && firstExisting >= 0 && entries.slice(firstExisting).some(entry => !previousIds.current.has(entry.id))) setHasNewMessages(true);
|
|
98
|
+
previousIds.current = new Set(entries.map(entry => entry.id));
|
|
99
|
+
restore();
|
|
100
|
+
previousFirst.current = first;
|
|
101
|
+
initialized.current = true;
|
|
102
|
+
});
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
if (!content.current || !viewport.current || typeof ResizeObserver === 'undefined') return;
|
|
105
|
+
const observer = new ResizeObserver(restore);
|
|
106
|
+
observer.observe(content.current);
|
|
107
|
+
observer.observe(viewport.current);
|
|
108
|
+
return () => observer.disconnect();
|
|
109
|
+
// The observer reads mutable viewport/anchor/follow refs, not entry props.
|
|
110
|
+
}, []);
|
|
111
|
+
return <ReadOnlyContext.Provider value={readOnly}><div className="rhine-chat">
|
|
112
|
+
<div className="rhine-chat-toolbar">
|
|
113
|
+
<Button variant="quiet" aria-disabled={!hasEarlier || loadingEarlier || !onLoadEarlier} busy={loadingEarlier} busyLabel="正在加载更早消息"
|
|
114
|
+
onClick={() => { if (!hasEarlier || loadingEarlier || !onLoadEarlier) return; pause(); onLoadEarlier(); }}>
|
|
115
|
+
{loadingEarlier ? '正在加载更早消息' : hasEarlier ? '加载更早消息' : '已到最早消息'}
|
|
116
|
+
</Button>
|
|
117
|
+
{readOnly && <Badge>只读观察</Badge>}
|
|
118
|
+
</div>
|
|
119
|
+
{historyError && <p className="rhine-chat-history-error" role="alert">{historyError}</p>}
|
|
120
|
+
<div ref={viewport} id={id} className="rhine-chat-viewport" role="region" aria-label={label} tabIndex={0}
|
|
121
|
+
onScroll={() => {
|
|
122
|
+
const el = viewport.current!;
|
|
123
|
+
// Reaching the bottom does not silently cancel a disclosure/history pause.
|
|
124
|
+
if (el.scrollHeight - el.clientHeight - el.scrollTop > 8 && follow.current) { follow.current = false; setFollowing(false); }
|
|
125
|
+
remember();
|
|
126
|
+
}}
|
|
127
|
+
onClickCapture={event => {
|
|
128
|
+
const target = event.target as HTMLElement;
|
|
129
|
+
if (target.closest('summary, [aria-expanded]:not([aria-haspopup])')) pause();
|
|
130
|
+
}}
|
|
131
|
+
onFocusCapture={event => { if (event.target !== event.currentTarget) pause(); }}
|
|
132
|
+
onWheel={event => { if (event.deltaY < 0) pause(); }}
|
|
133
|
+
onKeyDown={event => { if (['ArrowUp', 'PageUp', 'Home'].includes(event.key)) pause(); }}>
|
|
134
|
+
<ol ref={content} className="rhine-chat-list">
|
|
135
|
+
{entries.map((entry, index) => {
|
|
136
|
+
const previous = entries[index - 1];
|
|
137
|
+
const continuation = entry.kind === 'message' && isContinuation(previous, entry);
|
|
138
|
+
return <li key={entry.id} data-chat-entry={entry.id} className="rhine-chat-entry" data-continuation={continuation}>
|
|
139
|
+
{previous?.day !== entry.day && <div className="rhine-chat-day">{entry.day}</div>}
|
|
140
|
+
{entry.kind === 'event' ? <div className="rhine-chat-event">{entry.content}</div> : <article aria-label={`${entry.member.name} · ${entry.time}`}>
|
|
141
|
+
<header className={continuation ? 'rhine-sr-only' : 'rhine-chat-meta'}>
|
|
142
|
+
{entry.member.avatar && <span aria-hidden="true" className="rhine-chat-avatar">{entry.member.avatar}</span>}
|
|
143
|
+
<strong>{entry.member.name}</strong><time dateTime={entry.dateTime}>{entry.time}</time>
|
|
144
|
+
</header>
|
|
145
|
+
<div className="rhine-chat-body">{entry.content}</div>
|
|
146
|
+
{!readOnly && entry.actions && <div className="rhine-chat-actions">{entry.actions}</div>}
|
|
147
|
+
</article>}
|
|
148
|
+
</li>;
|
|
149
|
+
})}
|
|
150
|
+
</ol>
|
|
151
|
+
{!entries.length && <p className="rhine-chat-empty">暂无消息</p>}
|
|
152
|
+
</div>
|
|
153
|
+
<div className="rhine-chat-footer">
|
|
154
|
+
<span role="status" aria-live="polite">{[announcement, following ? '正在跟随最新消息' : `${hasNewMessages ? '有新消息 · ' : ''}已暂停跟随,可继续阅读历史`].filter(Boolean).join(' · ')}</span>
|
|
155
|
+
<Button variant="quiet" aria-controls={id} aria-pressed={following} onClick={() => {
|
|
156
|
+
follow.current = true; setFollowing(true); setHasNewMessages(false); restore();
|
|
157
|
+
}}>回到最新并跟随</Button>
|
|
158
|
+
</div>
|
|
159
|
+
</div></ReadOnlyContext.Provider>;
|
|
160
|
+
}
|
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>}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { useRef, type HTMLAttributes, type MouseEvent } from 'react';
|
|
2
|
+
import { Menu, type MenuItem } from './Overlays';
|
|
3
|
+
import { Icon, IconButton, type IconName } from './Actions';
|
|
3
4
|
|
|
4
|
-
export interface
|
|
5
|
+
export interface NavAction extends Omit<MenuItem, 'onSelect'> { onSelect: (trigger: HTMLButtonElement) => void }
|
|
6
|
+
export interface NavItem { id: string; label: string; href: string; icon?: IconName; actions?: readonly NavAction[] }
|
|
5
7
|
export interface NavGroup { label: string; items: readonly NavItem[] }
|
|
6
8
|
export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
|
7
9
|
groups: readonly NavGroup[]; currentId: string;
|
|
@@ -10,11 +12,20 @@ export interface SideNavProps extends HTMLAttributes<HTMLElement> {
|
|
|
10
12
|
export function SideNav({ groups, currentId, onNavigate, className = '', 'aria-label': label = '主导航', ...props }: SideNavProps) {
|
|
11
13
|
return <nav {...props} aria-label={label} className={`rhine-side-nav ${className}`}>
|
|
12
14
|
{groups.map(group => <div className="rhine-side-nav__group" key={group.label}><p>{group.label}</p><ul>
|
|
13
|
-
{group.items.map(item => <
|
|
15
|
+
{group.items.map(item => <SideNavRow key={item.id} item={item} current={currentId === item.id} onNavigate={onNavigate} />)}
|
|
14
16
|
</ul></div>)}
|
|
15
17
|
</nav>;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
function SideNavRow({ item, current, onNavigate }: { item: NavItem; current: boolean; onNavigate?: SideNavProps['onNavigate'] }) {
|
|
21
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
22
|
+
const hasActions = !!item.actions?.length;
|
|
23
|
+
return <li className={hasActions ? 'rhine-side-nav__row--actions' : undefined}>
|
|
24
|
+
<a href={item.href} aria-current={current ? 'page' : undefined} onClick={event => onNavigate?.(item, event)}>{item.icon && <Icon name={item.icon} size={20}/>}<span className="rhine-side-nav__label">{item.label}</span></a>
|
|
25
|
+
{hasActions && <Menu label={`${item.label}:操作`} trigger={<IconButton ref={trigger} label={`${item.label}:更多操作`} icon="more" />} items={item.actions!.map(action => ({ ...action, onSelect: () => { if (trigger.current) action.onSelect(trigger.current); } }))} />}
|
|
26
|
+
</li>;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export interface BreadcrumbItem { label: string; href?: string }
|
|
19
30
|
export interface BreadcrumbsProps { items: readonly BreadcrumbItem[]; label?: string }
|
|
20
31
|
export function Breadcrumbs({ items, label = '当前位置' }: BreadcrumbsProps) {
|