@linhey/rhine-design 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +18 -15
- package/components/AdvancedForms.tsx +22 -8
- package/components/COMMON_COMPONENTS.md +6 -3
- package/components/Forms.tsx +14 -1
- package/components/ListFilterBar.tsx +5 -3
- package/components/Overlays.tsx +3 -2
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/TYPOGRAPHY.md +2 -0
- package/components/Toast.tsx +10 -4
- package/components/checkbox-row.css +12 -0
- package/components/common.css +3 -1
- package/components/compact-forms.css +20 -0
- package/components/components.tsx +13 -7
- package/components/index.ts +1 -0
- package/components/media-parity.css +4 -4
- package/components/patterns.css +3 -3
- package/components/styles.css +17 -2
- package/components/theme-picker.css +2 -2
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/CHECKBOX_ROWS.md +36 -0
- package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
- package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/INTERACTION_PATTERNS.md +9 -1
- package/docs/LIST_FILTER_BAR.md +5 -1
- package/docs/REVIEW_TOOL.md +128 -0
- package/docs/REVIEW_VERIFICATION.md +56 -0
- package/docs/SUMMARY_TABS.md +77 -0
- package/docs/THEMING.md +1 -1
- package/docs/assets/disclosure/arknights-400.png +0 -0
- package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
- package/docs/assets/disclosure/rhine-400.png +0 -0
- package/docs/assets/disclosure/rhine-native200.png +0 -0
- package/examples/review/http/App.tsx +46 -0
- package/examples/review/http/catalog.tsx +44 -0
- package/examples/review/http/details.ts +1 -0
- package/examples/review/http/vite.config.ts +8 -0
- package/examples/review/platform/Workspace.tsx +34 -0
- package/examples/review/platform/catalog.tsx +46 -0
- package/examples/review/platform/vite.config.ts +8 -0
- package/examples/review/product.css +3 -0
- package/package.json +17 -5
- package/preview/CheckboxRowGallery.tsx +26 -0
- package/preview/CommonGallery.tsx +1 -1
- package/preview/CompactFilterBarGallery.tsx +19 -0
- package/preview/ComponentTypographyGallery.tsx +23 -0
- package/preview/DenseFilterControlsGallery.tsx +21 -0
- package/preview/GalleryShell.tsx +4 -3
- package/preview/OrbitGallery.tsx +1 -1
- package/preview/SpacingGallery.tsx +9 -1
- package/preview/SummaryTabsGallery.tsx +37 -0
- package/preview/main.tsx +14 -3
- package/preview/packageInfo.ts +5 -0
- package/review/canvas.tsx +107 -0
- package/review/dev-client.mjs +17 -0
- package/review/feedback.ts +17 -0
- package/review/index.ts +2 -0
- package/review/isolation.ts +64 -0
- package/review/lifecycle.ts +59 -0
- package/review/protocol.ts +7 -0
- package/review/review.css +41 -0
- package/review/shell.tsx +165 -0
- package/review/state.ts +41 -0
- package/review/types.ts +59 -0
- package/review/vite.d.mts +10 -0
- package/review/vite.mjs +107 -0
- package/scripts/check-checkbox-rows.js +65 -0
- package/scripts/check-component-typography.js +76 -0
- package/scripts/check-dense-filters.js +60 -0
- package/scripts/check-review-package.mjs +11 -0
- package/scripts/check-review-recovery.js +47 -0
- package/scripts/check-review.js +153 -0
- package/skills/rhine-design/SKILL.md +2 -2
- package/skills/rhine-design/references/API_MAP.md +6 -0
- package/skills/rhine-design/references/TASK_MAP.md +2 -0
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/CheckboxRow.test.tsx +53 -0
- package/tests/CommonComponents.test.tsx +8 -0
- package/tests/CompactFilterBar.test.tsx +50 -0
- package/tests/DenseFilterControls.test.tsx +79 -0
- package/tests/DesignSystem.test.tsx +32 -0
- package/tests/Review.test.tsx +117 -0
- package/tests/ReviewBuild.test.tsx +59 -0
- package/tests/ReviewShell.test.tsx +63 -0
- package/tsconfig.json +5 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Build both examples and serve dist at loopback port 18342; HTTP dev uses 18340.
|
|
2
|
+
// playwright-cli run-code --filename scripts/check-review.js
|
|
3
|
+
async page => {
|
|
4
|
+
const failures = [], errors = [], requests = [], consoleErrors = [], consoleChecks = [];
|
|
5
|
+
page.on('console', message => {
|
|
6
|
+
if (message.type() !== 'error') return;
|
|
7
|
+
// Firefox renders an Error argument as only "Error"; inspect the actual exception too.
|
|
8
|
+
consoleChecks.push(Promise.all(message.args().map(argument => argument.evaluate(value => value instanceof Error ? value.message : '').catch(() => ''))).then(messages => {
|
|
9
|
+
if (!message.text().includes('合成渲染失败') && !messages.includes('合成渲染失败')) consoleErrors.push(message.text());
|
|
10
|
+
}));
|
|
11
|
+
});
|
|
12
|
+
const check = (condition, message) => { if (!condition) failures.push(message); };
|
|
13
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
14
|
+
page.on('request', request => requests.push(request.url()));
|
|
15
|
+
const http = 'http://127.0.0.1:18342/review-http/';
|
|
16
|
+
const advanced = async () => { const details = page.locator('.rhine-review-options'); if (!await details.evaluate(element => element.open)) await details.locator('summary').click(); };
|
|
17
|
+
const motion = async value => { await advanced(); await page.getByRole('combobox', { name: '动态偏好' }).selectOption(value); };
|
|
18
|
+
let cases = 0;
|
|
19
|
+
for (const base of [http, 'http://127.0.0.1:18340/review-http/']) {
|
|
20
|
+
await page.goto(base);
|
|
21
|
+
const frame = page.frameLocator('iframe');
|
|
22
|
+
await frame.getByRole('heading', { name: '项目工作台' }).waitFor();
|
|
23
|
+
check(await page.locator('.rhine-review-header').evaluate(element => getComputedStyle(element).display === 'flex'), `${base} stylesheet loaded`);
|
|
24
|
+
await frame.getByRole('button', { name: '加载延迟模块' }).click();
|
|
25
|
+
await frame.getByText('延迟模块已加载 · 资源 base 与产品路由互相独立').waitFor();
|
|
26
|
+
await frame.getByRole('textbox', { name: '未保存草稿' }).fill('浏览器保留草稿');
|
|
27
|
+
await frame.getByRole('button', { name: '新建项目', exact: true }).click();
|
|
28
|
+
const input = frame.getByRole('textbox', { name: '项目名称' });
|
|
29
|
+
const handle = await input.elementHandle();
|
|
30
|
+
await input.focus();
|
|
31
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
32
|
+
await page.getByRole('combobox', { name: '界面主题' }).selectOption(theme);
|
|
33
|
+
await page.getByRole('combobox', { name: '画布宽度' }).selectOption('520');
|
|
34
|
+
check(await input.inputValue() === '浏览器保留草稿', `${base} ${theme} draft`);
|
|
35
|
+
check(await handle.evaluate(element => element.isConnected && element === element.ownerDocument.activeElement), `${base} ${theme} node/focus`);
|
|
36
|
+
check(await frame.locator('.rhine-theme').count() === 1, `${base} single theme root`);
|
|
37
|
+
await frame.locator(`.rhine-theme[data-rhine-theme="${theme}"]`).waitFor();
|
|
38
|
+
}
|
|
39
|
+
await frame.getByRole('button', { name: '保存项目', exact: true }).click();
|
|
40
|
+
await frame.getByRole('dialog').waitFor({ state: 'detached' });
|
|
41
|
+
check(await frame.getByText('浏览器保留草稿', { exact: true }).count() === 1, `${base} form submit`);
|
|
42
|
+
await frame.getByRole('button', { name: '触发未实现操作' }).click();
|
|
43
|
+
await page.getByRole('alert').filter({ hasText: '未实现模拟请求' }).waitFor();
|
|
44
|
+
// Verify real navigation defaults never escape the runtime, while same-document anchors work.
|
|
45
|
+
const child = page.frames().find(frame => frame !== page.mainFrame());
|
|
46
|
+
const before = child.url();
|
|
47
|
+
for (const href of ['/product/detail', '/api/export', 'https://synthetic.invalid/export']) {
|
|
48
|
+
await child.evaluate(href => { const link = document.createElement('a'); link.href = href; link.textContent = '测试导航'; document.body.append(link); link.click(); link.remove(); }, href);
|
|
49
|
+
check(child.url() === before, `${base} blocked ${href}`);
|
|
50
|
+
}
|
|
51
|
+
await child.evaluate(() => { const link = document.createElement('a'); link.href = '#local-section'; document.body.append(link); link.click(); link.remove(); });
|
|
52
|
+
await child.waitForFunction(() => location.hash === '#local-section');
|
|
53
|
+
check(child.url().endsWith('#local-section'), `${base} local anchor`);
|
|
54
|
+
const beforeReset = await frame.getByRole('textbox', { name: '未保存草稿' }).elementHandle();
|
|
55
|
+
await page.getByRole('button', { name: '重置场景' }).click();
|
|
56
|
+
await beforeReset.evaluate(element => new Promise((resolve, reject) => { const start = performance.now(); const poll = () => { if (!element.isConnected) resolve(true); else if (performance.now() - start > 1500) reject(new Error('reset did not unmount previous session')); else requestAnimationFrame(poll); }; poll(); }));
|
|
57
|
+
await frame.getByRole('textbox', { name: '未保存草稿' }).waitFor();
|
|
58
|
+
check(await frame.getByRole('textbox', { name: '未保存草稿' }).inputValue() === '', `${base} reset`);
|
|
59
|
+
for (const state of ['loading', 'empty', 'error', 'dense', 'normal']) {
|
|
60
|
+
await page.getByRole('combobox', { name: '列表状态' }).selectOption(state);
|
|
61
|
+
const expected = { loading: '正在加载合成项目…', empty: '暂无项目', error: '合成服务暂不可用', dense: '合成项目 1 · 跨团队设计评审与持续验证的长标题示例', normal: '合成项目 1' }[state];
|
|
62
|
+
await frame.getByText(expected, { exact: true }).waitFor();
|
|
63
|
+
}
|
|
64
|
+
await page.getByRole('combobox', { name: '角色', exact: true }).selectOption('reader');
|
|
65
|
+
await frame.getByText('合成数据 · 只读 · 独立产品路由', { exact: true }).waitFor();
|
|
66
|
+
check(await frame.getByRole('button', { name: '新建项目', exact: true }).isDisabled(), `${base} read-only`);
|
|
67
|
+
const deepLink = page.url(); await page.reload();
|
|
68
|
+
await frame.getByRole('heading', { name: '项目工作台' }).waitFor();
|
|
69
|
+
check(page.url() === deepLink, `${base} refresh URL`);
|
|
70
|
+
await page.getByRole('combobox', { name: '角色', exact: true }).selectOption('editor');
|
|
71
|
+
await page.goBack();
|
|
72
|
+
check(await page.getByRole('combobox', { name: '角色', exact: true }).inputValue() === 'reader', `${base} history back`);
|
|
73
|
+
await page.goForward();
|
|
74
|
+
check(await page.getByRole('combobox', { name: '角色', exact: true }).inputValue() === 'editor', `${base} history forward`);
|
|
75
|
+
cases++;
|
|
76
|
+
}
|
|
77
|
+
// Final artifact visual / keyboard matrix (CSS zoom, not browser zoom).
|
|
78
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880, 1280]) for (const zoom of [1, 2]) {
|
|
79
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
80
|
+
await page.goto(`${http}#scene=projects&theme=${theme}&width=fit&zoom=${zoom}&d.status=dense`);
|
|
81
|
+
const frame = page.frameLocator('iframe');
|
|
82
|
+
await frame.getByRole('button', { name: '新建项目', exact: true }).click();
|
|
83
|
+
const dialog = frame.getByRole('dialog');
|
|
84
|
+
await dialog.waitFor();
|
|
85
|
+
await frame.getByRole('textbox', { name: '项目名称' }).fill('窄屏键盘');
|
|
86
|
+
await page.keyboard.press('Tab');
|
|
87
|
+
check(await dialog.evaluate(element => element.contains(element.ownerDocument.activeElement)), `${theme}/${width}/${zoom} focus trap`);
|
|
88
|
+
await page.keyboard.press('Escape');
|
|
89
|
+
await dialog.waitFor({ state: 'detached' });
|
|
90
|
+
try { await frame.getByRole('button', { name: '新建项目', exact: true }).evaluate(element => new Promise((resolve, reject) => { const start = performance.now(); const poll = () => { if (element === document.activeElement) resolve(true); else if (performance.now() - start > 1500) reject(new Error('focus timeout')); else requestAnimationFrame(poll); }; poll(); })); } catch { check(false, `${theme}/${width}/${zoom} focus return`); }
|
|
91
|
+
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
|
|
92
|
+
check(!overflow, `${theme}/${width}/${zoom} shell overflow`);
|
|
93
|
+
if (width === 400 && zoom === 2) await page.screenshot({ path: `output/playwright/review-${theme}-400-css200.png`, fullPage: true });
|
|
94
|
+
cases++;
|
|
95
|
+
}
|
|
96
|
+
await page.setViewportSize({ width: 400, height: 1000 });
|
|
97
|
+
await page.goto('http://127.0.0.1:18342/review-platform/');
|
|
98
|
+
await page.getByRole('button', { name: '场景目录' }).click();
|
|
99
|
+
await page.getByRole('textbox', { name: '搜索场景' }).fill('没有此场景');
|
|
100
|
+
await page.getByText('没有匹配场景').waitFor();
|
|
101
|
+
await page.getByRole('textbox', { name: '搜索场景' }).fill('');
|
|
102
|
+
await page.getByRole('textbox', { name: '搜索场景' }).focus();
|
|
103
|
+
await page.keyboard.press('Escape');
|
|
104
|
+
check(await page.getByRole('button', { name: '场景目录' }).evaluate(element => element === document.activeElement), 'mobile directory escape focus');
|
|
105
|
+
await page.getByRole('button', { name: '场景目录' }).click();
|
|
106
|
+
await page.getByRole('link', { name: '慢速初始化', exact: true }).click();
|
|
107
|
+
check(await page.getByRole('button', { name: '场景目录' }).getAttribute('aria-expanded') === 'false', 'mobile directory closes after navigation');
|
|
108
|
+
await page.setViewportSize({ width: 1280, height: 1000 });
|
|
109
|
+
await page.goto('http://127.0.0.1:18342/review-platform/');
|
|
110
|
+
const frame = page.frameLocator('iframe');
|
|
111
|
+
const actualCanvas = frame.locator('canvas');
|
|
112
|
+
await actualCanvas.waitFor();
|
|
113
|
+
const frames = async () => Number(await actualCanvas.getAttribute('data-frames'));
|
|
114
|
+
let count = await frames(); await page.waitForTimeout(120); check(await frames() > count, 'canvas plays');
|
|
115
|
+
await motion('paused');
|
|
116
|
+
await frame.getByText('已暂停', { exact: true }).waitFor();
|
|
117
|
+
count = await frames(); await page.waitForTimeout(120); check(await frames() === count, 'canvas pauses');
|
|
118
|
+
await motion('full');
|
|
119
|
+
count = await frames(); await page.waitForTimeout(120); check(await frames() > count, 'canvas resumes');
|
|
120
|
+
await motion('reduced');
|
|
121
|
+
await frame.getByText('减少动态', { exact: true }).waitFor();
|
|
122
|
+
count = await frames(); await page.waitForTimeout(120); check(await frames() === count, 'manual reduced');
|
|
123
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
124
|
+
await motion('system');
|
|
125
|
+
count = await frames(); await page.waitForTimeout(120); check(await frames() === count, 'system reduced');
|
|
126
|
+
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
127
|
+
count = await frames(); await page.waitForTimeout(120); check(await frames() > count, 'system resumes');
|
|
128
|
+
const oldCanvas = await actualCanvas.elementHandle();
|
|
129
|
+
await frame.getByRole('textbox', { name: '工作区名称' }).fill('内存工作区');
|
|
130
|
+
await frame.getByRole('button', { name: '保存工作区' }).click();
|
|
131
|
+
await frame.getByText('内存工作区已保存').waitFor();
|
|
132
|
+
await page.getByRole('link', { name: '慢速初始化', exact: true }).click();
|
|
133
|
+
await frame.getByRole('heading', { name: '桌面工作区' }).waitFor({ state: 'detached' });
|
|
134
|
+
const oldCount = await oldCanvas.evaluate(element => element.dataset.frames);
|
|
135
|
+
await page.getByRole('link', { name: '桌面工作区', exact: true }).click();
|
|
136
|
+
await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
|
|
137
|
+
await page.waitForTimeout(4200);
|
|
138
|
+
check(await oldCanvas.evaluate(element => element.dataset.frames) === oldCount, 'unmounted canvas stops');
|
|
139
|
+
check(await frame.getByText('慢速初始化完成').count() === 0, 'late setup discarded');
|
|
140
|
+
await page.getByRole('link', { name: '渲染错误边界', exact: true }).click();
|
|
141
|
+
await frame.getByRole('alert').waitFor();
|
|
142
|
+
await page.getByRole('alert').filter({ hasText: '场景渲染失败' }).waitFor();
|
|
143
|
+
await page.getByRole('link', { name: '桌面工作区', exact: true }).click();
|
|
144
|
+
await frame.getByRole('heading', { name: '桌面工作区' }).waitFor();
|
|
145
|
+
await page.screenshot({ path: 'output/playwright/review-platform.png', fullPage: true });
|
|
146
|
+
const outbounds = requests.filter(url => !url.startsWith('http://127.0.0.1:1834') && !url.startsWith('data:'));
|
|
147
|
+
const businessRequests = requests.filter(url => /\/api\//.test(url));
|
|
148
|
+
check(!outbounds.length && !businessRequests.length, 'no real business/network egress');
|
|
149
|
+
check(!errors.length, `page errors: ${errors}`);
|
|
150
|
+
await Promise.all(consoleChecks);
|
|
151
|
+
check(!consoleErrors.length, `console errors: ${consoleErrors}`);
|
|
152
|
+
return { cases, failures, errors, consoleErrors, outbounds, businessRequests, requests: requests.length };
|
|
153
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rhine-design
|
|
3
3
|
description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.
|
|
5
|
+
version: "0.10.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Rhine Design · 莱茵设计
|
|
@@ -13,7 +13,7 @@ Use the installed version's components and contracts to build consistent interfa
|
|
|
13
13
|
|
|
14
14
|
## Locate the matching source
|
|
15
15
|
|
|
16
|
-
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.
|
|
16
|
+
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.10.0, including compact filter controls, Checkbox row layout, explicit component typography, ListFilterBar, Surface padding, narrow date layouts, OrbitBackdrop, application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; application APIs require 0.5.0 and OrbitBackdrop requires 0.6.0. Check installed exports before adoption.
|
|
17
17
|
|
|
18
18
|
Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
|
|
19
19
|
|
|
@@ -4,12 +4,14 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
4
4
|
|
|
5
5
|
| Exports | Source |
|
|
6
6
|
| --- | --- |
|
|
7
|
+
| Checkbox `layout="row"`, CheckboxProps(自 0.10.0) | `components/Forms.tsx`、`docs/CHECKBOX_ROWS.md`;整行双行多选,默认 form 保持兼容;npm 0.9.1 不含此选项 |
|
|
7
8
|
| TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
|
|
8
9
|
| Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
|
|
9
10
|
| AppShell.contentHeader(0.8.0) | `components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;正文列常驻导航插槽,与正文滚动区分离,组合 AppShellToggle / Inline / 标题与上下文操作 |
|
|
10
11
|
| MediaCard, MediaViewer(0.5.0) | `components/Media.tsx`;中性画布,不嵌套主题 |
|
|
11
12
|
| Media 主题重设计(0.8.0) | `components/media-parity.css`;`docs/APPLICATION_COMPONENTS.md`;卡片、标签与查看器操作/信息栏随宿主主题,仅媒体画布中性;选择与重试独立 |
|
|
12
13
|
| DateField, DateRangePicker, NumberField, TreeSelect, RemoteMultiSelect(0.5.0) | `components/AdvancedForms.tsx`;请求、时区和查询由宿主负责;只读/校验/多选键盘及AntD/MUI保留差异见 `docs/ANTD_MUI_AUDIT.md` |
|
|
14
|
+
| 紧凑筛选(自 0.10.0,npm 0.9.1 不含) | `docs/COMPACT_FILTER_CONTROLS.md`;SegmentedControl `fullWidth`、DateRangePicker `compact`、RemoteMultiSelect `maxVisibleTags`、ListFilterBar `drawerDescription` / `drawerResult`;保持默认兼容、完整选择值及加载/错误反馈 |
|
|
13
15
|
| Date presets + confirmed custom range(组合,无新增导出) | `docs/DATE_RANGE_FILTER.md`、`preview/DateRangeFilterGallery.tsx`;复用 SegmentedControl + Button + Popover + DateRangePicker,宿主负责时区、预设和查询 |
|
|
14
16
|
| ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
|
|
15
17
|
| OrbitBackdrop, OrbitBackdropProps (0.6.0) | `components/OrbitBackdrop.tsx`;契约 `docs/ORBIT_BACKDROP.md`;`components/orbitRenderer.ts` 为内部绘制实现 |
|
|
@@ -32,6 +34,8 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
32
34
|
| Toast | `components/Toast.tsx` |
|
|
33
35
|
| Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx`;Surface 的 `padding="none"` 仅取消表格容器外围内距,默认不变;此选项自 0.9.1 提供,见 `CHANGELOG.md` |
|
|
34
36
|
|
|
37
|
+
Additions in 0.11.0: `Tabs` / `TabItem` `description` and `status`, `Toast placement="floating"`, and `SegmentedControl size="compact"`. They are absent from npm 0.10.0. Pin 0.11.0 and read `CHANGELOG.md` before upgrading.
|
|
38
|
+
|
|
35
39
|
Primary contracts: `components/README.md` and `components/COMMON_COMPONENTS.md`. Find behavioral evidence in `tests/` and composition examples in `preview/`.
|
|
36
40
|
|
|
37
41
|
```sh
|
|
@@ -53,3 +57,5 @@ API decisions that commonly matter:
|
|
|
53
57
|
- Components in `preview/` are demonstrations. Public API must resolve through a `package.json` export; theme APIs use the separate `/theme` entry.
|
|
54
58
|
|
|
55
59
|
Available since 0.8.0 (check installed exports): `ThemePicker` / `ThemePreview` in `components/ThemePicker.tsx`, contract `docs/THEME_PICKER.md`; `Dialog.navigation` in `components/Overlays.tsx`, contract `docs/SETTINGS_DIALOG.md`; shared floating CSS zoom behavior in `docs/FLOATING_OVERLAYS.md`.
|
|
60
|
+
|
|
61
|
+
Product review tooling (since 0.11.0): `/review` exports `defineReview`, URL helpers and scene/session types from `review/index.ts`; `/review/vite` exports Node-only `reviewPlugin` and `reviewProductionGuard` from `review/vite.mjs`, types in `review/vite.d.mts`. Never import the Vite entry into production UI. Read `docs/REVIEW_TOOL.md`; npm 0.10.0 does not contain these entries.
|
|
@@ -6,6 +6,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
|
|
|
6
6
|
| --- | --- | --- |
|
|
7
7
|
| Application shell, backgrounds, media, advanced inputs, controlled tables | `docs/APPLICATION_COMPONENTS.md` (0.5.0) | `preview/ExtensionsGallery.tsx`; `/#app-shell`, `/#advanced-forms`, `/#advanced-data`, `/#media` |
|
|
8
8
|
| Workspace header, duplicate page titles, optional breadcrumbs | `docs/APPLICATION_COMPONENTS.md` → 平级工作区顶部操作区 (source guideline) | `preview/ExtensionsGallery.tsx`; `/#app-shell`; keep status/actions, navigation restore and accessible page identity |
|
|
9
|
+
| Product design review shell, synthetic HTTP/platform scenes, static deployment | `docs/REVIEW_TOOL.md` (since 0.11.0; not npm 0.10.0) | `review/`, `examples/review/http`, `examples/review/platform`; separate `/review` and `/review/vite` exports |
|
|
9
10
|
| Theme integration, switching, controlled preferences | `docs/THEMING.md` (0.4.0) | `components/theme.tsx`, `preview/GalleryShell.tsx` |
|
|
10
11
|
| Scenario selection, navigation, form recovery, monitoring | `docs/INTERACTION_PATTERNS.md` (0.4.0; check installed version) | `preview/PatternGallery.tsx`; research in `docs/REFERENCE_RESEARCH.md` |
|
|
11
12
|
| Date presets, visible date ranges, custom-range drafts and confirmation | `docs/DATE_RANGE_FILTER.md` (source pattern using existing 0.6.0 APIs) | `preview/DateRangeFilterGallery.tsx`; `/#date-range-filter`; no public DateRangeFilter component |
|
|
@@ -14,6 +15,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
|
|
|
14
15
|
| Text hierarchy, line height, paragraphs, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx`, `preview/LineHeightGallery.tsx`; `/#typography` |
|
|
15
16
|
| Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
|
|
16
17
|
| Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
|
|
18
|
+
| Tabs with identity summaries and per-object status | `docs/SUMMARY_TABS.md` (local candidate; verify installed API) | `Tabs.description/status`, `preview/SummaryTabsGallery.tsx`; `/#summary-tabs`; 12/8/4/16/24 spacing, stable labels and accessible descriptions |
|
|
17
19
|
| Task-centered content reduction, borderless account summaries, credential actions, compact legends | `docs/COMPACT_ACCOUNT_PATTERNS.md` (source candidates; not npm 0.6.0) | `preview/CompactAccountGallery.tsx`; `/#compact-account`; ButtonLink + StatusTimelineList + TimelineLegend |
|
|
18
20
|
| Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
|
|
19
21
|
| Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported baseline
|
|
4
4
|
|
|
5
|
-
Rhine Design 0.
|
|
5
|
+
Rhine Design 0.10.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
8
|
import { Button, Surface, Text } from '@linhey/rhine-design';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Checkbox, type CheckboxProps } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('Checkbox row layout', () => {
|
|
7
|
+
it('toggles once when the controlled checkbox description is clicked', () => {
|
|
8
|
+
const change = vi.fn();
|
|
9
|
+
function Example() {
|
|
10
|
+
const [checked, setChecked] = useState(false);
|
|
11
|
+
return <Checkbox layout="row" label="项目 A" hint="内存卡 · 14 项" checked={checked} onChange={event => { setChecked(event.target.checked); change(event.target.checked); }} />;
|
|
12
|
+
}
|
|
13
|
+
render(<Example />);
|
|
14
|
+
const input = screen.getByRole('checkbox', { name: '项目 A' });
|
|
15
|
+
expect(input).toHaveAccessibleDescription('内存卡 · 14 项');
|
|
16
|
+
fireEvent.click(screen.getByText('内存卡 · 14 项'));
|
|
17
|
+
expect(input).toBeChecked();
|
|
18
|
+
expect(change).toHaveBeenCalledExactlyOnceWith(true);
|
|
19
|
+
});
|
|
20
|
+
it('keeps mixed and disabled states on the native input', () => {
|
|
21
|
+
const change = vi.fn();
|
|
22
|
+
const { rerender } = render(<Checkbox layout="row" label="全部" hint="三个项目" indeterminate disabled onChange={change} />);
|
|
23
|
+
expect(screen.getByRole('checkbox')).toBePartiallyChecked();
|
|
24
|
+
fireEvent.click(screen.getByText('三个项目'));
|
|
25
|
+
expect(change).not.toHaveBeenCalled();
|
|
26
|
+
rerender(<Checkbox layout="row" label="全部" checked disabled onChange={change} />);
|
|
27
|
+
expect(screen.getByRole('checkbox')).toBeChecked();
|
|
28
|
+
expect(screen.getByRole('checkbox')).not.toBePartiallyChecked();
|
|
29
|
+
});
|
|
30
|
+
it('preserves native form submission and reset for uncontrolled selection', () => {
|
|
31
|
+
render(<form aria-label="项目"><Checkbox layout="row" label="项目 A" name="source" value="a" defaultChecked /><button type="reset">重置</button></form>);
|
|
32
|
+
const form = screen.getByRole('form') as HTMLFormElement;
|
|
33
|
+
expect(new FormData(form).getAll('source')).toEqual(['a']);
|
|
34
|
+
fireEvent.click(screen.getByRole('checkbox'));
|
|
35
|
+
expect(new FormData(form).getAll('source')).toEqual([]);
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
|
37
|
+
expect(screen.getByRole('checkbox')).toBeChecked();
|
|
38
|
+
});
|
|
39
|
+
it('preserves caller accessible names and combines descriptions', () => {
|
|
40
|
+
const props: CheckboxProps = { layout: 'row', label: '名称', hint: '说明', 'aria-label': '自定义名称', 'aria-describedby': 'external' };
|
|
41
|
+
render(<><span id="external">附加信息</span><Checkbox {...props} /></>);
|
|
42
|
+
expect(screen.getByRole('checkbox', { name: '自定义名称' })).toHaveAccessibleDescription('附加信息 说明');
|
|
43
|
+
});
|
|
44
|
+
it('keeps the default form hint outside its original label', () => {
|
|
45
|
+
const change = vi.fn();
|
|
46
|
+
render(<Checkbox label="表单字段" hint="原布局" onChange={change} />);
|
|
47
|
+
expect(screen.getByText('原布局').closest('label')).toBeNull();
|
|
48
|
+
fireEvent.click(screen.getByText('原布局'));
|
|
49
|
+
expect(change).not.toHaveBeenCalled();
|
|
50
|
+
fireEvent.click(screen.getByText('表单字段'));
|
|
51
|
+
expect(change).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -59,6 +59,14 @@ describe('common design components', () => {
|
|
|
59
59
|
act(()=>vi.advanceTimersByTime(2000));fireEvent.mouseEnter(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();fireEvent.mouseLeave(screen.getByRole('status'));act(()=>vi.advanceTimersByTime(2999));expect(close).not.toHaveBeenCalled();act(()=>vi.advanceTimersByTime(1));expect(close).toHaveBeenCalledWith(false);
|
|
60
60
|
close.mockClear();rerender(<Toast open onOpenChange={close} title="失败" tone="error" duration={1000}/>);act(()=>vi.advanceTimersByTime(10000));expect(close).not.toHaveBeenCalled();
|
|
61
61
|
});
|
|
62
|
+
it('portals floating feedback outside the content panel while preserving theme and focus', () => {
|
|
63
|
+
const {container,unmount}=render(<div className="rhine-theme"><section><button>继续</button><Toast placement="floating" open onOpenChange={()=>{}} title="完成"/></section></div>);
|
|
64
|
+
const trigger=screen.getByRole('button',{name:'继续'});trigger.focus();
|
|
65
|
+
const region=screen.getByRole('status').parentElement!;
|
|
66
|
+
expect(region.parentElement).toBe(container.querySelector('.rhine-theme'));
|
|
67
|
+
expect(container.querySelector('section')).not.toContainElement(region);
|
|
68
|
+
expect(trigger).toHaveFocus();unmount();expect(screen.queryByText('完成')).toBeNull();
|
|
69
|
+
});
|
|
62
70
|
it('resets toast countdown when reopening', () => {
|
|
63
71
|
vi.useFakeTimers();function Demo(){const [open,setOpen]=useState(true);return <><button onClick={()=>setOpen(true)}>显示</button><Toast open={open} onOpenChange={setOpen} title="通知" duration={1000}/></>;}
|
|
64
72
|
render(<Demo/>);act(()=>vi.advanceTimersByTime(1000));expect(screen.queryByText('通知')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'显示'}));act(()=>vi.advanceTimersByTime(999));expect(screen.getByText('通知')).toBeVisible();act(()=>vi.advanceTimersByTime(1));expect(screen.queryByText('通知')).not.toBeInTheDocument();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ListFilterBar, SegmentedControl, Select } from '../components';
|
|
5
|
+
|
|
6
|
+
const field = { id: 'state', label: '状态', control: <Select label="状态" defaultValue="all"><option value="all">全部</option></Select> };
|
|
7
|
+
|
|
8
|
+
describe('compact filter presentation', () => {
|
|
9
|
+
it('omits redundant drawer copy while keeping pending and error feedback', async () => {
|
|
10
|
+
const { rerender } = render(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={null} drawerResult={null} />);
|
|
11
|
+
fireEvent.click(screen.getByRole('button', { name: '更多筛选' }));
|
|
12
|
+
const dialog = await screen.findByRole('dialog', { name: '记录筛选' });
|
|
13
|
+
expect(within(dialog).queryByText('更改后立即更新结果。')).not.toBeInTheDocument();
|
|
14
|
+
expect(within(dialog).queryByText('120 条结果')).not.toBeInTheDocument();
|
|
15
|
+
expect(dialog).not.toHaveAttribute('aria-describedby');
|
|
16
|
+
expect(document.querySelectorAll('.rhine-list-filter-bar__feedback')).toHaveLength(1);
|
|
17
|
+
rerender(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={null} drawerResult={null} pending error="连接暂时不可用" />);
|
|
18
|
+
expect(within(dialog).getByText('正在更新结果…')).toBeInTheDocument();
|
|
19
|
+
expect(within(dialog).getByRole('alert')).toHaveTextContent('连接暂时不可用');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves legacy drawer feedback and supports distinct drawer content', async () => {
|
|
23
|
+
const { rerender } = render(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" />);
|
|
24
|
+
fireEvent.click(screen.getByRole('button', { name: '更多筛选' }));
|
|
25
|
+
const dialog = await screen.findByRole('dialog', { name: '记录筛选' });
|
|
26
|
+
expect(within(dialog).getByText('120 条结果')).toBeInTheDocument();
|
|
27
|
+
expect(dialog).toHaveAccessibleDescription('更改后立即更新结果。');
|
|
28
|
+
rerender(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={<span>按需选择范围</span>} drawerResult="已选 3 项" />);
|
|
29
|
+
expect(dialog).toHaveAccessibleDescription('按需选择范围');
|
|
30
|
+
expect(within(dialog).getByText('已选 3 项')).toBeInTheDocument();
|
|
31
|
+
expect(within(dialog).queryByText('120 条结果')).not.toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('full-width segments remain controlled native radios with disabled state', () => {
|
|
35
|
+
const onChange = vi.fn();
|
|
36
|
+
function Example() {
|
|
37
|
+
const [value, setValue] = useState('today');
|
|
38
|
+
return <SegmentedControl fullWidth label="时间快捷" value={value} onValueChange={next => { setValue(next); onChange(next); }} items={[
|
|
39
|
+
{ value: 'today', label: '今天' }, { value: 'yesterday', label: '昨天' }, { value: 'week', label: '本周', disabled: true },
|
|
40
|
+
]} />;
|
|
41
|
+
}
|
|
42
|
+
render(<Example />);
|
|
43
|
+
expect(screen.getByRole('group', { name: '时间快捷' })).toHaveClass('rhine-segmented--full-width');
|
|
44
|
+
fireEvent.click(screen.getByRole('radio', { name: '昨天' }));
|
|
45
|
+
expect(screen.getByRole('radio', { name: '昨天' })).toBeChecked();
|
|
46
|
+
expect(screen.getByRole('radio', { name: '今天' })).not.toBeChecked();
|
|
47
|
+
expect(screen.getByRole('radio', { name: '本周' })).toBeDisabled();
|
|
48
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith('yesterday');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { DateRangePicker, RemoteMultiSelect, type Choice } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('compact date range', () => {
|
|
7
|
+
it('keeps both native fields and range validation inside one named group', () => {
|
|
8
|
+
const validation = vi.fn();
|
|
9
|
+
function Example() {
|
|
10
|
+
const [value, setValue] = useState({ start: '2026-10-01', end: '2026-10-24' });
|
|
11
|
+
return <DateRangePicker compact label="日期范围" value={value} onValueChange={setValue} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={validation} />;
|
|
12
|
+
}
|
|
13
|
+
render(<Example />);
|
|
14
|
+
const group = screen.getByRole('group', { name: '日期范围' });
|
|
15
|
+
expect(group).toHaveClass('rhine-date-range--compact');
|
|
16
|
+
expect(within(group).getByLabelText('开始日期')).toBeRequired();
|
|
17
|
+
expect(within(group).getByLabelText('结束日期')).toHaveAttribute('type', 'date');
|
|
18
|
+
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-09-30' } });
|
|
19
|
+
expect(validation).toHaveBeenLastCalledWith('结束日期不能早于开始日期。');
|
|
20
|
+
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-10-24' } });
|
|
21
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
22
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2025-12-31' } });
|
|
23
|
+
expect(validation).toHaveBeenLastCalledWith('日期不能早于 2026-01-01。');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves readonly and external value replacement in compact mode', () => {
|
|
27
|
+
const change = vi.fn();
|
|
28
|
+
const { rerender } = render(<DateRangePicker compact label="范围" value={{ start: '2026-10-01', end: '2026-10-24' }} onValueChange={change} readOnly />);
|
|
29
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-10-02' } });
|
|
30
|
+
expect(change).not.toHaveBeenCalled();
|
|
31
|
+
expect(screen.getByRole('button', { name: '清空日期' })).toBeDisabled();
|
|
32
|
+
rerender(<DateRangePicker compact label="范围" value={{ start: '2026-11-01', end: '2026-11-24' }} onValueChange={change} readOnly />);
|
|
33
|
+
expect(screen.getByLabelText('开始日期')).toHaveValue('2026-11-01');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('bounded multi-select tags', () => {
|
|
38
|
+
const choices: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: String(i), label: `合成部门 ${i + 1}`, disabled: i === 0 }));
|
|
39
|
+
function Example() {
|
|
40
|
+
const [value, setValue] = useState(choices.slice(0, 90));
|
|
41
|
+
const [query, setQuery] = useState('');
|
|
42
|
+
return <RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} options={choices} value={value} onValueChange={setValue} maxVisibleTags={2} name="department" />;
|
|
43
|
+
}
|
|
44
|
+
it('shows a bounded summary without dropping hidden selections and expands for removal', () => {
|
|
45
|
+
render(<Example />);
|
|
46
|
+
const tags = screen.getByRole('list', { name: '部门已选项' });
|
|
47
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(2);
|
|
48
|
+
expect(screen.getByText('已选 90 项')).toBeInTheDocument();
|
|
49
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(90);
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: '展开已选项' }));
|
|
51
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(90);
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: '移除合成部门 90' }));
|
|
53
|
+
expect(screen.getByText('已选 89 项')).toBeInTheDocument();
|
|
54
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
55
|
+
fireEvent.click(screen.getByRole('button', { name: '收起已选项' }));
|
|
56
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(2);
|
|
57
|
+
expect(screen.getByRole('button', { name: '移除合成部门 1' })).toBeDisabled();
|
|
58
|
+
});
|
|
59
|
+
it('retains hidden-item keyboard removal and locked selections when clearing', () => {
|
|
60
|
+
render(<Example />);
|
|
61
|
+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
|
|
62
|
+
expect(screen.getByText('已选 89 项')).toBeInTheDocument();
|
|
63
|
+
expect(document.querySelector('input[type=hidden][value="89"]')).toBeNull();
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: '清空选择' }));
|
|
65
|
+
expect(screen.getByText('已选 1 项')).toBeInTheDocument();
|
|
66
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(1);
|
|
67
|
+
expect(screen.queryByRole('button', { name: '展开已选项' })).not.toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
it('collapses a new selection after the host clears an expanded selection', () => {
|
|
70
|
+
const props = { label: '部门', query: '', onQueryChange: vi.fn(), options: choices, onValueChange: vi.fn(), maxVisibleTags: 2, name: 'department' };
|
|
71
|
+
const { rerender } = render(<RemoteMultiSelect {...props} value={choices.slice(0, 90)} />);
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: '展开已选项' }));
|
|
73
|
+
rerender(<RemoteMultiSelect {...props} value={[]} />);
|
|
74
|
+
rerender(<RemoteMultiSelect {...props} value={choices} />);
|
|
75
|
+
expect(within(screen.getByRole('list', { name: '部门已选项' })).getAllByRole('listitem')).toHaveLength(2);
|
|
76
|
+
expect(screen.getByRole('button', { name: '展开已选项' })).toHaveAttribute('aria-expanded', 'false');
|
|
77
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(100);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -82,6 +82,21 @@ describe('reusable design components', () => {
|
|
|
82
82
|
const tabs=screen.getAllByRole('tab'); expect(tabs[0].id).not.toBe(tabs[1].id);
|
|
83
83
|
for(const tab of tabs) expect(document.getElementById(tab.getAttribute('aria-controls')!)).toHaveAttribute('aria-labelledby',tab.id);
|
|
84
84
|
});
|
|
85
|
+
it('keeps tab names stable while associating independent descriptions and status', () => {
|
|
86
|
+
const { rerender } = render(<Tabs label="连接" value="a" onValueChange={()=>{}} items={[
|
|
87
|
+
{value:'a',label:'甲',description:'账号一',status:<span>同步中</span>,content:'甲内容'},
|
|
88
|
+
{value:'b',label:'乙',description:'账号二',status:<span>已停止</span>,content:'乙内容'},
|
|
89
|
+
]}/>);
|
|
90
|
+
const first=screen.getByRole('tab',{name:'甲'}); first.focus();
|
|
91
|
+
expect(first).toHaveAccessibleDescription('账号一 同步中');
|
|
92
|
+
expect(screen.getByRole('tab',{name:'乙'})).toHaveAccessibleDescription('账号二 已停止');
|
|
93
|
+
rerender(<Tabs label="连接" value="a" onValueChange={()=>{}} items={[
|
|
94
|
+
{value:'a',label:'甲',description:'账号三',status:<span>已停止</span>,content:'甲内容'},
|
|
95
|
+
{value:'b',label:'乙',content:'乙内容'},
|
|
96
|
+
]}/>);
|
|
97
|
+
expect(first).toHaveFocus(); expect(first).toHaveAccessibleDescription('账号三 已停止');
|
|
98
|
+
expect(screen.getByRole('tab',{name:'乙'})).not.toHaveAttribute('aria-describedby');
|
|
99
|
+
});
|
|
85
100
|
it('keeps separate segmented controls in independent native radio groups', () => {
|
|
86
101
|
render(<>{['a','b'].map(label=><SegmentedControl key={label} label={label} value="one" onValueChange={()=>{}} items={[{value:'one',label:'一'},{value:'two',label:'二'}]}/>)}</>);
|
|
87
102
|
const radios=screen.getAllByRole('radio'); expect(radios[0].getAttribute('name')).not.toBe(radios[2].getAttribute('name'));
|
|
@@ -108,4 +123,21 @@ describe('reusable design components', () => {
|
|
|
108
123
|
expect(details.open).toBe(false);fireEvent.click(summary);expect(details.open).toBe(true);
|
|
109
124
|
fireEvent.click(summary);expect(details.open).toBe(false);expect(screen.getByText('内部按钮')).not.toBeVisible();
|
|
110
125
|
});
|
|
126
|
+
it.each([['200ms', 200], ['.2s', 200], ['0ms', 0], ['0s', 0]])('disclosure respects CSS duration %s after production normalization', (cssDuration, milliseconds) => {
|
|
127
|
+
render(<Disclosure title="动画时长">内容</Disclosure>);
|
|
128
|
+
const summary = screen.getByText('动画时长');
|
|
129
|
+
const details = summary.closest('details')!;
|
|
130
|
+
details.style.setProperty('--rhine-duration-notice', cssDuration);
|
|
131
|
+
const animate = vi.fn(() => ({ cancel: vi.fn(), onfinish: null } as unknown as Animation));
|
|
132
|
+
details.animate = animate;
|
|
133
|
+
fireEvent.click(summary);
|
|
134
|
+
if (milliseconds === 0) {
|
|
135
|
+
expect(animate).not.toHaveBeenCalled();
|
|
136
|
+
expect(details.open).toBe(true);
|
|
137
|
+
fireEvent.click(summary);
|
|
138
|
+
expect(details.open).toBe(false);
|
|
139
|
+
} else {
|
|
140
|
+
expect(animate).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ duration: milliseconds }));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
111
143
|
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { defineReview, parseReviewState, serializeReviewState } from '../review/state';
|
|
3
|
+
import { ReviewLifecycle } from '../review/lifecycle';
|
|
4
|
+
import { createMockFetch, MemoryStorage } from '../review/isolation';
|
|
5
|
+
import type { ReviewContext, ReviewScene, ReviewSession } from '../review/types';
|
|
6
|
+
|
|
7
|
+
const session = (): ReviewSession => ({ render: () => null, dispose: vi.fn() });
|
|
8
|
+
const scene: ReviewScene = { id: 'projects', title: '项目', setup: session, motionControls: ['full', 'paused'], dimensions: [
|
|
9
|
+
{ id: 'status', label: '状态', options: [{ id: 'normal', label: '正常' }, { id: 'error', label: '错误' }] },
|
|
10
|
+
{ id: 'role', label: '角色', options: [{ id: 'editor', label: '编辑' }, { id: 'reader', label: '只读' }] },
|
|
11
|
+
] };
|
|
12
|
+
const catalog = defineReview({ title: 'Review', scenes: [scene, { id: 'other', title: '其他', setup: session }] });
|
|
13
|
+
describe('review URL contract', () => {
|
|
14
|
+
it('restores independent dimensions and all controls', () => {
|
|
15
|
+
const hash = '#scene=projects&d.status=error&d.role=reader&theme=arknights&width=520&zoom=2&motion=paused';
|
|
16
|
+
const state = parseReviewState(hash, catalog);
|
|
17
|
+
expect(state).toEqual({ scene: 'projects', dimensions: { status: 'error', role: 'reader' }, theme: 'arknights', width: 520, zoom: 2, motion: 'paused' });
|
|
18
|
+
expect(parseReviewState(serializeReviewState(state), catalog)).toEqual(state);
|
|
19
|
+
});
|
|
20
|
+
it('drops unknown values and arbitrary form drafts with deterministic fallback', () => {
|
|
21
|
+
const state = parseReviewState('#scene=gone&theme=unknown&width=999&zoom=5&motion=bad&d.role=admin&draft=secret', catalog);
|
|
22
|
+
expect(state).toEqual({ scene: 'projects', dimensions: { status: 'normal', role: 'editor' }, theme: 'rhine', width: 'fit', zoom: 1, motion: 'system' });
|
|
23
|
+
expect(serializeReviewState(state)).not.toContain('secret');
|
|
24
|
+
});
|
|
25
|
+
it('does not leak dimensions to a scene which does not support them', () => {
|
|
26
|
+
expect(parseReviewState('#scene=other&d.role=reader', catalog).dimensions).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
it('rejects duplicate IDs, empty dimensions and invalid widths', () => {
|
|
29
|
+
expect(() => defineReview({ title: '', scenes: [] })).toThrow();
|
|
30
|
+
expect(() => defineReview({ title: '', scenes: [scene, scene] })).toThrow();
|
|
31
|
+
expect(() => defineReview({ title: '', scenes: [{ ...scene, dimensions: [{ id: 'x', label: '', options: [] }] }] })).toThrow();
|
|
32
|
+
expect(() => defineReview({ title: '', scenes: [scene], widths: [0] })).toThrow();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
const deferred = <T,>() => { let resolve!: (value: T) => void; const promise = new Promise<T>(yes => { resolve = yes; }); return { promise, resolve }; };
|
|
36
|
+
function harness() {
|
|
37
|
+
const hooks = { clear: vi.fn(), ready: vi.fn(), error: vi.fn(), context: () => ({ mockHttp: vi.fn(), report: vi.fn() }) };
|
|
38
|
+
return { hooks, lifecycle: new ReviewLifecycle(hooks) };
|
|
39
|
+
}
|
|
40
|
+
describe('review lifecycle', () => {
|
|
41
|
+
it('aborts pending setup and cleans resources registered before it returns', async () => {
|
|
42
|
+
const { lifecycle, hooks } = harness();
|
|
43
|
+
const pending = deferred<ReviewSession>();
|
|
44
|
+
const earlyCleanup = vi.fn();
|
|
45
|
+
let context!: ReviewContext;
|
|
46
|
+
const first = lifecycle.select({ ...scene, setup: ctx => { context = ctx; ctx.onDispose(earlyCleanup); return pending.promise; } }, {});
|
|
47
|
+
await lifecycle.select(catalog.scenes[1], {});
|
|
48
|
+
expect(context.signal.aborted).toBe(true);
|
|
49
|
+
expect(earlyCleanup).toHaveBeenCalledOnce();
|
|
50
|
+
const stale = session(); pending.resolve(stale); await first;
|
|
51
|
+
expect(stale.dispose).toHaveBeenCalledOnce();
|
|
52
|
+
expect(hooks.ready).toHaveBeenCalledTimes(1);
|
|
53
|
+
await lifecycle.dispose();
|
|
54
|
+
});
|
|
55
|
+
it('waits for async disposal across rapid consecutive selections', async () => {
|
|
56
|
+
const { lifecycle, hooks } = harness(); const disposal = deferred<void>();
|
|
57
|
+
await lifecycle.select({ ...scene, setup: () => ({ ...session(), dispose: () => disposal.promise }) }, {});
|
|
58
|
+
const discarded = vi.fn(session), accepted = vi.fn(session);
|
|
59
|
+
const second = lifecycle.select({ ...scene, setup: discarded }, {});
|
|
60
|
+
const third = lifecycle.select({ ...scene, setup: accepted }, {});
|
|
61
|
+
await Promise.resolve(); expect(accepted).not.toHaveBeenCalled();
|
|
62
|
+
disposal.resolve(); await Promise.all([second, third]);
|
|
63
|
+
expect(discarded).not.toHaveBeenCalled(); expect(accepted).toHaveBeenCalledOnce();
|
|
64
|
+
expect(hooks.error).not.toHaveBeenCalled(); await lifecycle.dispose();
|
|
65
|
+
});
|
|
66
|
+
it('resets then disposes and creates a fresh session', async () => {
|
|
67
|
+
const { lifecycle } = harness(); const log: string[] = [];
|
|
68
|
+
const definition = { ...scene, setup: (context: ReviewContext) => { log.push(`setup:${context.reason}`); return { render: () => null, reset: () => { log.push('reset'); }, dispose: () => { log.push('dispose'); } }; } };
|
|
69
|
+
await lifecycle.select(definition, {}); await lifecycle.select(definition, {}, 'reset');
|
|
70
|
+
expect(log).toEqual(['setup:select', 'reset', 'dispose', 'setup:reset']); await lifecycle.dispose();
|
|
71
|
+
});
|
|
72
|
+
it('runs cleanup on setup rejection and surfaces failure', async () => {
|
|
73
|
+
const { lifecycle, hooks } = harness(); const cleanup = vi.fn();
|
|
74
|
+
await lifecycle.select({ ...scene, setup: async context => { context.onDispose(cleanup); throw new Error('setup failed'); } }, {});
|
|
75
|
+
expect(cleanup).toHaveBeenCalledOnce(); expect(hooks.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'setup failed' }), 'projects');
|
|
76
|
+
});
|
|
77
|
+
it('attributes a disposal failure to the old scene while accepting the next scene', async () => {
|
|
78
|
+
const { lifecycle, hooks } = harness();
|
|
79
|
+
await lifecycle.select({ ...scene, setup: () => ({ ...session(), dispose() { throw new Error('cleanup failed'); } }) }, {});
|
|
80
|
+
await lifecycle.select(catalog.scenes[1], {});
|
|
81
|
+
expect(hooks.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'cleanup failed' }), 'projects');
|
|
82
|
+
expect(hooks.ready).toHaveBeenLastCalledWith(expect.anything(), catalog.scenes[1]);
|
|
83
|
+
await lifecycle.dispose();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('synthetic request boundary', () => {
|
|
87
|
+
it('preserves Request method/body and omits credentials', async () => {
|
|
88
|
+
const handler = vi.fn(async (request: Request) => {
|
|
89
|
+
expect(request.method).toBe('POST'); expect(request.credentials).toBe('omit');
|
|
90
|
+
return Response.json({ body: await request.text() });
|
|
91
|
+
});
|
|
92
|
+
const fetch = createMockFetch(() => handler, () => new AbortController().signal, vi.fn());
|
|
93
|
+
const response = await fetch(new Request('https://synthetic.invalid/items', { method: 'POST', body: 'hello' }));
|
|
94
|
+
expect(await response.json()).toEqual({ body: 'hello' });
|
|
95
|
+
});
|
|
96
|
+
it('rejects unmatched requests with visible failure instead of fallback network', async () => {
|
|
97
|
+
const report = vi.fn(); const fetch = createMockFetch(() => undefined, () => new AbortController().signal, report);
|
|
98
|
+
await expect(fetch(new URL('https://synthetic.invalid/missing'))).rejects.toThrow('未实现模拟请求');
|
|
99
|
+
expect(report).toHaveBeenCalledWith('未实现模拟请求:GET /missing');
|
|
100
|
+
});
|
|
101
|
+
it('cancels a noncooperative handler when its scene is disposed', async () => {
|
|
102
|
+
const abort = new AbortController(); const fetch = createMockFetch(() => () => new Promise(() => {}), () => abort.signal, vi.fn());
|
|
103
|
+
const pending = fetch('https://synthetic.invalid/items'); abort.abort();
|
|
104
|
+
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
|
105
|
+
});
|
|
106
|
+
it('honors a Request abort signal and a pre-aborted request', async () => {
|
|
107
|
+
const abort = new AbortController(); const handler = vi.fn(() => new Promise<Response>(() => {}));
|
|
108
|
+
const fetch = createMockFetch(() => handler, () => new AbortController().signal, vi.fn());
|
|
109
|
+
abort.abort(); await expect(fetch(new Request('https://synthetic.invalid', { signal: abort.signal }))).rejects.toMatchObject({ name: 'AbortError' });
|
|
110
|
+
expect(handler).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
it('keeps storage in memory and resets all keys', () => {
|
|
113
|
+
const storage = new MemoryStorage(); storage.setItem('name', 'synthetic');
|
|
114
|
+
expect(storage.getItem('name')).toBe('synthetic'); expect(storage.length).toBe(1);
|
|
115
|
+
storage.clear(); expect(storage.getItem('name')).toBeNull(); expect(storage.key(0)).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|