@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CONTRIBUTING.md +2 -0
  3. package/README.md +9 -4
  4. package/components/Actions.tsx +1 -0
  5. package/components/AdvancedForms.tsx +13 -5
  6. package/components/AppShell.tsx +3 -2
  7. package/components/COMMON_COMPONENTS.md +5 -1
  8. package/components/Chat.tsx +160 -0
  9. package/components/Forms.tsx +66 -6
  10. package/components/Navigation.tsx +15 -4
  11. package/components/Overlays.tsx +37 -7
  12. package/components/README.md +11 -1
  13. package/components/chat.css +32 -0
  14. package/components/common.css +2 -0
  15. package/components/components.tsx +19 -9
  16. package/components/dialog-settings.css +54 -1
  17. package/components/extensions.css +4 -0
  18. package/components/index.ts +8 -3
  19. package/components/patterns.css +4 -0
  20. package/components/styles.css +11 -2
  21. package/components/theme-arknights.css +4 -2
  22. package/components/useFloatingScale.ts +7 -3
  23. package/docs/AGENT_CHAT.md +76 -0
  24. package/docs/AGENT_CHAT_VERIFICATION.md +42 -0
  25. package/docs/APPLICATION_COMPONENTS.md +4 -2
  26. package/docs/BUTTON_ACTIONS.md +32 -0
  27. package/docs/BUTTON_ACTIONS_VERIFICATION.md +14 -0
  28. package/docs/COMPACT_ACCOUNT_PATTERNS.md +16 -0
  29. package/docs/COMPACT_SELECTION_VERIFICATION.md +15 -0
  30. package/docs/FLOATING_OVERLAYS.md +20 -0
  31. package/docs/INTERACTION_PATTERNS.md +4 -0
  32. package/docs/SETTINGS_COMPOSITION_VERIFICATION.md +12 -0
  33. package/docs/SETTINGS_DIALOG.md +42 -4
  34. package/docs/SIDE_NAV_ACTIONS.md +9 -0
  35. package/docs/SINGLE_SEARCH.md +19 -0
  36. package/docs/SINGLE_SEARCH_VERIFICATION.md +15 -0
  37. package/docs/SUMMARY_TABS.md +7 -1
  38. package/docs/TRAY_USAGE.md +34 -0
  39. package/docs/TRAY_USAGE_VERIFICATION.md +13 -0
  40. package/docs/assets/chat/after.png +0 -0
  41. package/docs/assets/chat/arknights.png +0 -0
  42. package/docs/assets/chat/before.png +0 -0
  43. package/docs/assets/chat/neo-brutalism.png +0 -0
  44. package/package.json +1 -1
  45. package/preview/ButtonActionsGallery.tsx +25 -0
  46. package/preview/ChatGallery.tsx +98 -0
  47. package/preview/CompactSelectionGallery.tsx +76 -0
  48. package/preview/DenseFilterControlsGallery.tsx +5 -2
  49. package/preview/GalleryShell.tsx +2 -2
  50. package/preview/NavActionsGallery.tsx +24 -0
  51. package/preview/SettingsCompositionGallery.tsx +38 -0
  52. package/preview/SettingsDialogGallery.tsx +54 -14
  53. package/preview/SingleSearchGallery.tsx +50 -0
  54. package/preview/SummaryTabsGallery.tsx +7 -3
  55. package/preview/TrayUsageGallery.tsx +82 -0
  56. package/preview/chat-gallery.css +1 -0
  57. package/preview/compact-selection.css +9 -0
  58. package/preview/main.tsx +15 -1
  59. package/preview/packageInfo.ts +1 -1
  60. package/preview/settings-dialog-gallery.css +5 -0
  61. package/preview/tray-usage.css +4 -0
  62. package/scripts/check-button-actions.js +34 -0
  63. package/scripts/check-combobox-modal-focus.js +22 -0
  64. package/scripts/check-combobox-zoom.js +109 -0
  65. package/scripts/check-compact-selection.js +40 -0
  66. package/scripts/check-dialog-scroll-boundaries.js +41 -0
  67. package/scripts/check-multiselect-completion.js +46 -0
  68. package/scripts/check-nav-actions.js +41 -0
  69. package/scripts/check-settings-bounded.js +49 -0
  70. package/scripts/check-settings-composition.js +50 -0
  71. package/scripts/check-settings-interactions.js +93 -0
  72. package/scripts/check-settings-theme.js +1 -5
  73. package/scripts/check-single-search.js +43 -0
  74. package/scripts/check-tray-usage.js +68 -0
  75. package/scripts/prepare-settings-consumer.mjs +21 -0
  76. package/skills/rhine-design/SKILL.md +2 -0
  77. package/skills/rhine-design/references/API_MAP.md +11 -0
  78. package/skills/rhine-design/references/TASK_MAP.md +1 -0
  79. package/skills/rhine-design/references/integration.md +1 -1
  80. package/tests/Chat.test.tsx +84 -0
  81. package/tests/CompactSelection.test.tsx +47 -0
  82. package/tests/FormParity.test.tsx +104 -2
  83. package/tests/NavActions.test.tsx +29 -0
  84. package/tests/SettingsDialog.test.tsx +102 -9
  85. package/tests/SingleSearch.test.tsx +42 -0
  86. package/tests/SkillInstallation.test.tsx +77 -0
  87. package/tests/TrayUsage.test.tsx +70 -0
@@ -0,0 +1,43 @@
1
+ // Production preview, isolated playwright-cli session; synthetic data only.
2
+ async page => {
3
+ page.setDefaultTimeout(8000);
4
+ const origin = new URL(page.url()).origin;
5
+ const failures = [], errors = [], samples = [];
6
+ page.on('pageerror', error => errors.push(String(error)));
7
+ const check = (ok, context) => { if (!ok) failures.push(context); };
8
+ for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
9
+ await page.setViewportSize({ width: 1100, height: 1000 });
10
+ await page.goto(`${origin}/?theme=${theme}#single-search`);
11
+ await page.getByRole('searchbox', { name: '搜索合成条目' }).waitFor();
12
+ await page.getByLabel('搜索容器宽度').selectOption(String(width));
13
+ await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
14
+ const input = page.getByRole('searchbox', { name: '搜索合成条目', exact: true });
15
+ const context = { theme, width, zoom };
16
+ check(await page.locator('[data-search-example] button').count() === 0, { ...context, kind: 'input-only' });
17
+ const typography = await input.evaluate(el => { const s = getComputedStyle(el), p = getComputedStyle(el, '::placeholder'); return { size: s.fontSize, line: s.lineHeight, weight: s.fontWeight, placeholder: p.fontSize, height: el.offsetHeight, fits: el.parentElement.scrollWidth <= el.parentElement.clientWidth + 1 }; });
18
+ check(typography.size === '14px' && typography.line === '22px' && typography.weight === '400' && typography.placeholder === '14px' && typography.height >= 44 && typography.fits, { ...context, kind: 'body-type-and-bounds', typography });
19
+ await page.getByLabel('搜索场景').selectOption('long');
20
+ await input.fill('跨区域联合研究项目中的长中文合成查询'.repeat(5));
21
+ await page.getByRole('status').filter({ hasText: '找到' }).waitFor();
22
+ check(await input.evaluate(el => el.getBoundingClientRect().right <= el.parentElement.getBoundingClientRect().right + 1), { ...context, kind: 'long-query' });
23
+ await input.press('Escape'); check(await input.inputValue() === '' && await input.evaluate(el => document.activeElement === el), { ...context, kind: 'clear-focus' });
24
+ for (const [state, message] of [['loading', '正在搜索'], ['empty', '没有匹配结果'], ['error', '搜索失败']]) {
25
+ await page.getByLabel('搜索场景').selectOption(state); await input.fill('合成查询');
26
+ await page.getByRole('status').filter({ hasText: message }).waitFor();
27
+ check(!!await input.getAttribute('aria-describedby'), { ...context, kind: `description-${state}` });
28
+ }
29
+ await page.getByLabel('搜索场景').selectOption('disabled'); check(await input.isDisabled(), { ...context, kind: 'disabled' });
30
+ if (width === 400 && zoom === 2) await page.screenshot({ path: `output/playwright/search-${theme}.png` });
31
+ samples.push({ ...context, typography });
32
+ }
33
+ for (const reduced of [false, true]) {
34
+ await page.emulateMedia({ reducedMotion: reduced ? 'reduce' : 'no-preference' });
35
+ await page.goto(`${origin}/#single-search`);
36
+ await page.getByRole('searchbox').fill('动效偏好');
37
+ await page.getByRole('status').filter({ hasText: '找到' }).waitFor();
38
+ }
39
+ await page.getByRole('radio', { name: '减少动态', exact: true }).check();
40
+ await page.getByRole('searchbox').press('Escape');
41
+ check(await page.getByRole('searchbox').inputValue() === '', { kind: 'manual-reduced-clear' });
42
+ return { pass: !failures.length && !errors.length, failures, errors, samples, scope: 'Production Chromium, 3 themes × 400/520/880 container × CSS zoom 1/2. OS IME and native zoom recorded separately.' };
43
+ }
@@ -0,0 +1,68 @@
1
+ // Start on this revision's production preview. native=1 expects Chrome's real page zoom already set to 200%.
2
+ async page => {
3
+ const origin = page.url().split('/').slice(0,3).join('/'), native = page.url().includes('native=1'), results = [], errors = [];
4
+ page.on('pageerror', e => errors.push(String(e)));
5
+ const panel = () => page.getByRole('dialog',{name:'账号与用量概览'});
6
+ const account = () => panel().getByRole('combobox',{name:'Agent A 当前账号'});
7
+ const open = async () => { if (!await panel().isVisible()) await page.getByRole('button',{name:'切换用量面板'}).click(); await panel().waitFor(); await page.waitForTimeout(200); };
8
+ const close = async () => { if (await panel().isVisible()) { await page.keyboard.press('Escape'); await panel().waitFor({state:'detached',timeout:3000}); } };
9
+ const size = () => page.getByTestId('switch-feedback').evaluate(e=>({h:e.getBoundingClientRect().height,content:e.closest('[data-testid="tray-panel"]').scrollHeight}));
10
+ const bounds = async () => {
11
+ const b = await panel().evaluate(e=>{const r=e.getBoundingClientRect();return {x:r.x,right:r.right,y:r.y,bottom:r.bottom,width:innerWidth,height:innerHeight};});
12
+ if (b.x < -1 || b.y < -1 || b.right > b.width+1 || b.bottom > b.height+1) throw Error(`Popover bounds ${JSON.stringify(b)}`);
13
+ };
14
+ for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880]) for (const zoom of (native ? [1] : [1,2])) {
15
+ await page.setViewportSize({width,height:1000});
16
+ await page.goto(`${origin}/?fixture=tray-usage&theme=${theme}&zoom=${zoom}`);
17
+ await panel().waitFor();
18
+ if (await panel().locator('.rhine-dialog__head').count()) throw Error('Visible header retained');
19
+ await close(); await page.getByRole('checkbox',{name:'长账号名',exact:true}).check(); await open();
20
+ await close(); await page.getByRole('checkbox',{name:'首次切换失败',exact:true}).check(); await open();
21
+ const other = await page.getByTestId('other-agent').textContent(), summary = await page.getByTestId('usage-summary').textContent(), before = await size();
22
+ await account().selectOption('b');
23
+ if (await account().inputValue() !== 'a' || !await account().isDisabled()) throw Error('Unconfirmed account committed');
24
+ const busy = await size();
25
+ await panel().getByRole('alert').waitFor();
26
+ const failed = await size();
27
+ if (await account().inputValue() !== 'a') throw Error('Failure changed account');
28
+ if (!((await page.getByTestId('request-count').textContent()) || '').endsWith('1')) throw Error('Duplicate request');
29
+ await panel().getByRole('button',{name:'重试切换'}).click();
30
+ await page.waitForFunction(()=>document.querySelector('[data-testid="current-account"]')?.textContent?.startsWith('协作账号 B'));
31
+ const success = await size();
32
+ if ([busy,failed,success].some(s=>Math.abs(s.h-before.h)>1 || Math.abs(s.content-before.content)>1)) throw Error(`Unstable heights ${JSON.stringify({before,busy,failed,success})}`);
33
+ if (await page.getByTestId('other-agent').textContent() !== other || await page.getByTestId('usage-summary').textContent() !== summary) throw Error('Other data changed');
34
+ await bounds();
35
+ await account().focus(); await page.keyboard.press('Escape'); await panel().waitFor({state:'detached',timeout:3000});
36
+ await page.waitForFunction(()=>document.activeElement?.getAttribute('aria-label')==='切换用量面板');
37
+ await page.keyboard.press('Enter'); await panel().waitFor();
38
+ await page.getByRole('button',{name:'切换用量面板'}).click(); await panel().waitFor({state:'detached',timeout:3000});
39
+ await open();
40
+ await page.mouse.click(5,5); await panel().waitFor({state:'detached',timeout:3000});
41
+ await open();
42
+ for (const state of ['loading','low','unknown','stale','connecting','unmatched','signed-out','shared']) {
43
+ await close(); await page.getByRole('combobox',{name:'用量场景'}).selectOption(state); await open();
44
+ if (['loading','stale','connecting','unmatched'].includes(state) && !await account().isDisabled()) throw Error(`Unavailable ${state}`);
45
+ if (state==='unknown' && await panel().getByRole('progressbar').count()) throw Error('Unknown shown as progress');
46
+ if (state==='stale' && !(await panel().textContent()).includes('上次数据 · 目录已过期')) throw Error('Cache not marked');
47
+ if (state==='signed-out' && /研究账号|协作账号|1.28|128|61%/.test(await panel().textContent())) throw Error('Logged out data leak');
48
+ if (state==='shared') {
49
+ await panel().getByText('2026-10 周期剩余 未知',{exact:true}).scrollIntoViewIfNeeded();
50
+ await page.getByTestId('usage-summary').scrollIntoViewIfNeeded();
51
+ const scrolled = await panel().evaluate(e=>e.scrollTop>0 && e.scrollHeight>e.clientHeight);
52
+ if (!scrolled) throw Error('Shared members not scrollable');
53
+ }
54
+ await bounds();
55
+ }
56
+ await close(); await page.getByRole('button',{name:'重置托盘示例'}).click(); await open();
57
+ if (await account().inputValue()!=='a' || await page.getByRole('checkbox',{name:'长账号名',exact:true}).isChecked()) throw Error('Reset failed');
58
+ results.push({theme,width,zoom,native,viewport:await page.evaluate(()=>({width:innerWidth,zoom:getComputedStyle(document.documentElement).zoom,dpr:devicePixelRatio})),pass:true,heights:{before,busy,failed,success}});
59
+ }
60
+ await page.emulateMedia({reducedMotion:'reduce'});
61
+ await page.goto(`${origin}/?fixture=tray-usage&motion=system`); await panel().waitFor();
62
+ const system = await panel().evaluate(e=>getComputedStyle(e).animationDuration);
63
+ await page.emulateMedia({reducedMotion:'no-preference'});
64
+ await page.goto(`${origin}/?fixture=tray-usage&motion=reduced`); await panel().waitFor();
65
+ const manual = await panel().evaluate(e=>getComputedStyle(e).animationDuration);
66
+ if (parseFloat(system) > 0.01 || parseFloat(manual)>0.01) throw Error('Reduced motion ignored');
67
+ return {pass:!errors.length,results,errors,reducedMotion:{system,manual}};
68
+ }
@@ -0,0 +1,21 @@
1
+ // Node 24. Creates a NEW isolated Vite consumer; never modifies an installed package.
2
+ // node scripts/prepare-settings-consumer.mjs candidate.tgz /new/consumer [--late-css]
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { resolve, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { execFileSync } from 'node:child_process';
7
+ const [tarball, destination, order] = process.argv.slice(2);
8
+ if (!tarball || !destination) throw new Error('Provide a fixed tarball and a new consumer directory.');
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const target = resolve(destination);
11
+ await mkdir(target); // Existing candidates are immutable; use a fresh directory.
12
+ const sourcePackage = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
13
+ await writeFile(resolve(target, 'package.json'), JSON.stringify({name:'settings-dialog-synthetic-consumer',version:'0.0.0',private:true,type:'module',scripts:{build:'vite build',preview:'vite preview'},dependencies:{'@linhey/rhine-design':`file:${resolve(tarball)}`,react:sourcePackage.devDependencies.react,'react-dom':sourcePackage.devDependencies['react-dom']},devDependencies:{vite:sourcePackage.devDependencies.vite,'@vitejs/plugin-react':sourcePackage.devDependencies['@vitejs/plugin-react']}}, null, 2));
14
+ await writeFile(resolve(target, 'index.html'), '<html><head><link rel="icon" href="data:,"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module" src="/main.tsx"></script></body></html>');
15
+ await writeFile(resolve(target, 'main.tsx'), `import React from 'react';\nimport {createRoot} from 'react-dom/client';\nimport {SettingsDialogFixture} from './SettingsDialogGallery';\n${order === '--late-css' ? "import '@linhey/rhine-design/styles.css?late';" : ''}\ncreateRoot(document.getElementById('root')!).render(<SettingsDialogFixture/>);`);
16
+ await writeFile(resolve(target, 'vite.config.ts'), "import {defineConfig} from 'vite';import react from '@vitejs/plugin-react';export default defineConfig({plugins:[react()]});");
17
+ const fixture = (await readFile(resolve(root, 'preview/SettingsDialogGallery.tsx'), 'utf8')).replaceAll("'../components'", "'@linhey/rhine-design'").replaceAll("'../components/theme'", "'@linhey/rhine-design/theme'");
18
+ await writeFile(resolve(target, 'SettingsDialogGallery.tsx'), fixture);
19
+ await writeFile(resolve(target, 'settings-dialog-gallery.css'), await readFile(resolve(root, 'preview/settings-dialog-gallery.css')));
20
+ execFileSync('npm', ['install'], {cwd:target,stdio:'inherit'});
21
+ execFileSync('npm', ['run','build'], {cwd:target,stdio:'inherit'});
@@ -17,6 +17,8 @@ Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different accoun
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
 
20
+ Relative Markdown links stay inside this portable skill folder. Package-root paths are written as code, not as links relative to the installed skill. Any online contract fallback identifies a fixed source revision; it does not follow main or replace the installed version's contract.
21
+
20
22
  ## Route the task
21
23
 
22
24
  - Screen design, interaction, typography, motion: start with [TASK_MAP.md](references/TASK_MAP.md), then read only the relevant contract.
@@ -4,6 +4,7 @@ All paths are relative to the matching package root. `package.json` exports, `co
4
4
 
5
5
  | Exports | Source |
6
6
  | --- | --- |
7
+ | ChatThread, ChatCard; ChatEntry, ChatMessage, ChatEvent, ChatMember, ChatStatus, ChatThreadProps, ChatCardProps (source candidate after 0.11.0) | `components/Chat.tsx`, `docs/AGENT_CHAT.md`; stable IDs, history anchor, explicit resume, readOnly action slots |
7
8
  | Checkbox `layout="row"`, CheckboxProps(自 0.10.0) | `components/Forms.tsx`、`docs/CHECKBOX_ROWS.md`;整行双行多选,默认 form 保持兼容;npm 0.9.1 不含此选项 |
8
9
  | TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
9
10
  | Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
@@ -59,3 +60,13 @@ API decisions that commonly matter:
59
60
  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
 
61
62
  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.
63
+
64
+ SideNav 行尾命令:`NavItem.actions` / `NavAction`,见 `docs/SIDE_NAV_ACTIONS.md`,真实展台 `#nav-actions`;未发布。
65
+
66
+ 单输入即时搜索:TextField / TextFieldProps 的 `labelVisibility="hidden"` 保留非空名称;参见 `docs/SINGLE_SEARCH.md` 与 `#single-search`,未发布。搜索/异步与IME由宿主管理。
67
+
68
+ #41未发布:Button.contentAlign、Tabs.layout/TabsProps、Dialog.height/footer;先读 `docs/COMPACT_ACCOUNT_PATTERNS.md`、`docs/SETTINGS_DIALOG.md`、`docs/SUMMARY_TABS.md`,展台 `#compact-selection`。
69
+
70
+ #46未发布:Button.size="standard" | "large";动作语义先读 `docs/BUTTON_ACTIONS.md`,展台 `#button-actions`。
71
+
72
+ #18 未发布:Dialog `description?`、settings CSS次序独立、`height/footer/navigation` 固定头尾与正文/长导航独立滚动;短屏右对齐动作及真实消费验证见 `docs/SETTINGS_DIALOG.md`,展台 `#settings-dialog`。
@@ -4,6 +4,7 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
4
4
 
5
5
  | Task | First reference (relative to package root) | Implementation / example |
6
6
  | --- | --- | --- |
7
+ | Agent chat, member grouping, inline cards, observation and history scrolling | `docs/AGENT_CHAT.md` (source candidate after 0.11.0) | `components/Chat.tsx`, `preview/ChatGallery.tsx`; `/#agent-chat`; host owns transitions, requests and permissions |
7
8
  | 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
9
  | 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
10
  | 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 |
@@ -21,7 +21,7 @@ export function Example() {
21
21
 
22
22
  ## Integration invariants
23
23
 
24
- Version 0.4.0 also exports `ThemeProvider`, `useTheme`, `themes`, `ThemeName` and `ThemeProviderProps` from `@linhey/rhine-design/theme` (not in npm 0.3.0). See [the theme contract](../../../docs/THEMING.md). This entry loads base and built-in theme CSS and renders the `.rhine-theme` root. Use `defaultTheme` for local state, or `theme` with `onThemeChange` for host state; storage and URL preferences remain with the host. Sibling providers are supported; nested providers throw. Do not wrap it in another themed root or key the subtree by theme. Gallery decoration is excluded from the public theme CSS.
24
+ Version 0.4.0 also exports `ThemeProvider`, `useTheme`, `themes`, `ThemeName` and `ThemeProviderProps` from `@linhey/rhine-design/theme` (not in npm 0.3.0). Read `docs/THEMING.md` from the verified package root. For a standalone skill installation without the package, see [the theme contract snapshot](https://github.com/linhay/rhine-design/blob/cfcb400fa213d9905eae1cab5dcb8ab8f817e03f/docs/THEMING.md) at commit `cfcb400fa213d9905eae1cab5dcb8ab8f817e03f` (0.10.0 source baseline). This fixed snapshot does not track main or establish availability in another installed version; prefer that version's own contract and implementation. This entry loads base and built-in theme CSS and renders the `.rhine-theme` root. Use `defaultTheme` for local state, or `theme` with `onThemeChange` for host state; storage and URL preferences remain with the host. Sibling providers are supported; nested providers throw. Do not wrap it in another themed root or key the subtree by theme. Gallery decoration is excluded from the public theme CSS.
25
25
 
26
26
  Version 0.5.0 adds `Background` with canvas/content/sidebar/surface/overlay/media roles. `decoration="auto"` is the default: only canvas uses theme texture; content and AppShell default to plain backgrounds. Use `appearance` for local color/text/texture/size overrides and theme-root scene tokens for shared configuration. Background is paint only, not a new theme root or modal. Preview `#backgrounds` shows only the selected theme and follows the theme picker. Check installed exports before adopting this API; it is not in npm 0.4.0.
27
27
 
@@ -0,0 +1,84 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { Button, ChatCard, ChatThread, type ChatEntry, type ChatMessage, type ChatStatus } from '../components';
4
+ import { ChatGallery } from '../preview/ChatGallery';
5
+
6
+ const msg = (id: string, minute: number, member = 'A', day = '今天'): ChatMessage => ({ id, kind: 'message', day, dateTime: `2026-10-27T10:${minute}:00+08:00`, time: `10:${minute}`, member: { id: member, name: member }, content: <p>{id}</p> });
7
+ afterEach(() => vi.useRealTimers());
8
+ describe('Chat composition', () => {
9
+ it('groups only adjacent same-member messages within five minutes and a day; events break groups', () => {
10
+ const { container } = render(<ChatThread label="聊天" entries={[msg('a', 10), msg('b', 11), { id: 'e', kind: 'event', day: '今天', content: '事件' }, msg('c', 12), msg('d', 13, 'B'), msg('f', 20, 'B'), msg('g', 21, 'B', '明天')]} />);
11
+ expect(container.querySelectorAll('[data-continuation=true]')).toHaveLength(1);
12
+ expect(screen.getByRole('article', { name: 'A · 10:11' })).toHaveTextContent('b');
13
+ expect(container.querySelectorAll('.rhine-chat-day')).toHaveLength(2);
14
+ });
15
+ it('preserves errors outside collapsed details and suppresses action slots in observation mode', () => {
16
+ const content = <ChatCard title="请求" status="failed" summary="来源不可用,请稍后核对" actions={<Button>接受</Button>}><p>完整依据</p></ChatCard>;
17
+ const entries: ChatEntry[] = [{ ...msg('a', 10), content, actions: <Button>重试</Button> }];
18
+ const { rerender } = render(<ChatThread label="聊天" readOnly entries={entries} />);
19
+ expect(screen.queryByRole('button', { name: '接受' })).not.toBeInTheDocument();
20
+ expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument();
21
+ expect(screen.getByText('来源不可用,请稍后核对')).toBeVisible();
22
+ fireEvent.click(screen.getByText('请求 · 详情'));
23
+ expect(screen.getByText('完整依据')).toBeVisible();
24
+ rerender(<ChatThread label="聊天" entries={entries} />);
25
+ expect(screen.getByRole('button', { name: '接受' })).toBeInTheDocument();
26
+ });
27
+ it('does not repeat a loading history request or remove the focused control', () => {
28
+ const load = vi.fn();
29
+ const { rerender } = render(<ChatThread label="聊天" entries={[]} hasEarlier onLoadEarlier={load} />);
30
+ const button = screen.getByRole('button', { name: '加载更早消息' }); button.focus(); fireEvent.click(button);
31
+ rerender(<ChatThread label="聊天" entries={[]} hasEarlier loadingEarlier onLoadEarlier={load} />);
32
+ fireEvent.click(button); expect(load).toHaveBeenCalledTimes(1); expect(button).toHaveFocus();
33
+ rerender(<ChatThread label="聊天" entries={[]} onLoadEarlier={load} historyError="合成失败" />);
34
+ fireEvent.click(button); expect(load).toHaveBeenCalledTimes(1); expect(button).toHaveFocus();
35
+ expect(screen.getByRole('alert')).toHaveTextContent('合成失败');
36
+ });
37
+ it('pauses on a disclosure and resumes only through the persistent follow control', () => {
38
+ render(<ChatThread label="聊天" entries={[{ ...msg('a', 10), content: <ChatCard title="详情" status="unknown" summary="尚未确认"><p>证据</p></ChatCard> }]} />);
39
+ fireEvent.click(screen.getByText('详情 · 详情'));
40
+ const follow = screen.getByRole('button', { name: '回到最新并跟随' });
41
+ expect(follow).toHaveAttribute('aria-pressed', 'false'); follow.focus(); fireEvent.click(follow);
42
+ expect(follow).toHaveAttribute('aria-pressed', 'true'); expect(follow).toHaveFocus();
43
+ });
44
+ it('announces new messages inserted before an existing stream without announcing prepended history', () => {
45
+ const entries = [msg('a', 10), msg('stream', 15)];
46
+ const { rerender } = render(<ChatThread label="聊天" entries={entries} />);
47
+ fireEvent.keyDown(screen.getByRole('region', { name: '聊天' }), { key: 'PageUp' });
48
+ rerender(<ChatThread label="聊天" entries={[msg('history', 9), ...entries]} />);
49
+ expect(screen.getByRole('status')).not.toHaveTextContent('有新消息');
50
+ rerender(<ChatThread label="聊天" entries={[msg('history', 9), entries[0], msg('new', 12), entries[1]]} />);
51
+ expect(screen.getByRole('status')).toHaveTextContent('有新消息');
52
+ fireEvent.click(screen.getByRole('button', { name: '回到最新并跟随' }));
53
+ expect(screen.getByRole('status')).not.toHaveTextContent('有新消息');
54
+ });
55
+ it('keeps focus after a host action and treats unexpected runtime states as unknown', () => {
56
+ const { unmount } = render(<ChatGallery />);
57
+ const accept = screen.getByRole('button', { name: '接受请求' }); accept.focus(); fireEvent.click(accept);
58
+ expect(accept).toHaveFocus(); expect(accept).toHaveAttribute('aria-disabled', 'true');
59
+ expect(screen.getByText('已接受')).toBeVisible();
60
+ fireEvent.click(screen.getByRole('button', { name: '拒绝请求' }));
61
+ expect(screen.getByText('已接受')).toBeVisible(); unmount();
62
+ render(<ChatCard title="外部状态" status={'constructor' as ChatStatus} summary="未知来源" />);
63
+ expect(screen.getByText('状态未知')).toBeVisible();
64
+ });
65
+ it('disconnects ResizeObserver on unmount', () => {
66
+ const disconnect = vi.fn();
67
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect; });
68
+ const { unmount } = render(<ChatThread label="聊天" entries={[]} />); unmount();
69
+ expect(disconnect).toHaveBeenCalledOnce(); vi.unstubAllGlobals();
70
+ });
71
+ it('cancels synthetic streaming without late text or success and clears timers on unmount', () => {
72
+ vi.useFakeTimers();
73
+ const { unmount } = render(<ChatGallery />);
74
+ fireEvent.click(screen.getByRole('button', { name: '播放合成流式记录' }));
75
+ act(() => vi.advanceTimersByTime(440));
76
+ fireEvent.click(screen.getByRole('button', { name: '取消演示生成' }));
77
+ const before = screen.getByRole('region', { name: '合成聊天消息' }).textContent;
78
+ act(() => vi.advanceTimersByTime(6000));
79
+ expect(screen.getByRole('region', { name: '合成聊天消息' }).textContent).toBe(before);
80
+ expect(screen.getByText('生成已取消;保留部分内容,未确认成功。')).toBeVisible();
81
+ fireEvent.click(screen.getByRole('button', { name: '加载更早消息' }));
82
+ unmount(); expect(vi.getTimerCount()).toBe(0);
83
+ });
84
+ });
@@ -0,0 +1,47 @@
1
+ import { act, fireEvent, render, screen, within } from '@testing-library/react';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { Button, Dialog, Tabs } from '../components';
4
+ import { CompactSelectionGallery } from '../preview/CompactSelectionGallery';
5
+ afterEach(()=>{vi.useRealTimers();vi.restoreAllMocks();});
6
+ describe('compact selection contracts',()=>{
7
+ it('keeps button native semantics and makes tabs layout independent of status',()=>{
8
+ const {rerender}=render(<><Button contentAlign="between">名称<span>箭头</span></Button><Tabs label="页签" value="a" onValueChange={()=>{}} items={[{value:'a',label:'甲',status:'运行',content:'内容'}]}/></>);
9
+ expect(screen.getByRole('button')).toHaveClass('rhine-button--between');expect(screen.getByRole('button')).not.toHaveAttribute('contentAlign');
10
+ expect(screen.getByRole('tablist')).toHaveClass('rhine-tabs__list--content');
11
+ rerender(<Tabs label="页签" layout="fill" value="a" onValueChange={()=>{}} items={[{value:'a',label:'甲',content:'内容'}]}/>);
12
+ expect(screen.getByRole('tablist')).toHaveClass('rhine-tabs__list--fill');
13
+ });
14
+ it('renders footer outside content scroll with opt-in height',()=>{
15
+ render(<Dialog trigger={<Button>打开</Button>} title="选择" description="草稿" height={560} footer={<Button>保存</Button>}><p>内容</p></Dialog>);
16
+ fireEvent.click(screen.getByText('打开'));const dialog=screen.getByRole('dialog');
17
+ expect(dialog).toHaveClass('rhine-dialog--bounded');expect(dialog.style.getPropertyValue('--rhine-dialog-height')).toBe('560px');
18
+ expect(screen.getByText('保存').closest('.rhine-dialog__scroll')).toBeNull();
19
+ });
20
+ it('discards draft on close, prevents duplicate save/close while saving and retains failures',()=>{
21
+ vi.useFakeTimers();render(<CompactSelectionGallery/>);
22
+ const trigger=screen.getByRole('button',{name:'选择 合成对象 A 的授权分组'});
23
+ fireEvent.click(trigger);let dialog=screen.getByRole('dialog',{name:'分组选择'});
24
+ fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'beta'}});
25
+ expect(within(dialog).getByText('已确认:示例组')).toBeInTheDocument();
26
+ fireEvent.click(within(dialog).getByRole('button',{name:'关闭对话框'}));
27
+ fireEvent.click(trigger);dialog=screen.getByRole('dialog',{name:'分组选择'});
28
+ expect(within(dialog).getByLabelText('候选分组')).toHaveValue('alpha');
29
+ fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'beta'}});
30
+ const save=within(dialog).getByRole('button',{name:'保存'});fireEvent.click(save);fireEvent.click(save);
31
+ fireEvent.click(within(dialog).getByRole('button',{name:'关闭对话框'}));expect(dialog).toBeInTheDocument();
32
+ act(()=>vi.advanceTimersByTime(700));expect(within(dialog).getByText('已确认:联合研究组')).toBeInTheDocument();expect(save).toBeDisabled();
33
+ fireEvent.click(within(dialog).getByRole('button',{name:'关闭对话框'}));
34
+ fireEvent.change(screen.getByLabelText('组合场景'),{target:{value:'error'}});fireEvent.click(trigger);
35
+ dialog=screen.getByRole('dialog',{name:'分组选择'});fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'none'}});
36
+ fireEvent.click(within(dialog).getByRole('button',{name:'保存'}));act(()=>vi.advanceTimersByTime(700));
37
+ expect(within(dialog).getByText('已确认:联合研究组')).toBeInTheDocument();expect(within(dialog).getByLabelText('候选分组')).toHaveValue('none');
38
+ expect(within(dialog).getByRole('status')).toHaveTextContent('保存失败');
39
+ });
40
+ it('cleans host timers on unmount',()=>{
41
+ vi.useFakeTimers();const timer=vi.spyOn(globalThis,'setTimeout'),clear=vi.spyOn(globalThis,'clearTimeout');const {unmount}=render(<CompactSelectionGallery/>);
42
+ fireEvent.click(screen.getByRole('button',{name:'选择 合成对象 A 的授权分组'}));const dialog=screen.getByRole('dialog');
43
+ fireEvent.change(within(dialog).getByLabelText('候选分组'),{target:{value:'beta'}});fireEvent.click(within(dialog).getByRole('button',{name:'保存'}));
44
+ const index=timer.mock.calls.findIndex(call=>call[1]===700);expect(index).toBeGreaterThanOrEqual(0);
45
+ const pendingTimer=timer.mock.results[index].value;unmount();expect(clear).toHaveBeenCalledWith(pendingTimer);
46
+ });
47
+ });
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { act, fireEvent, render, screen } from '@testing-library/react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
- import { Combobox, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
4
+ import { Combobox, Dialog, DateField, DateRangePicker, NumberField, RemoteMultiSelect, TreeSelect } from '../components';
5
5
 
6
6
  describe('date field parity', () => {
7
7
  it('reports controlled invalid dates and changed bounds without rewriting the value', () => {
@@ -106,6 +106,64 @@ describe('number field parity', () => {
106
106
  });
107
107
 
108
108
  describe('remote multi-select parity', () => {
109
+ it.each([false, true])('removes a tag without reopening candidates (initially open: %s)', initiallyOpen => {
110
+ const query = vi.fn(), submit = vi.fn(event => event.preventDefault());
111
+ function Demo() {
112
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]);
113
+ return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="保留查询" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'b', label: 'Beta' }]} /></form>;
114
+ }
115
+ render(<Demo />); const input = screen.getByRole('combobox');
116
+ if (initiallyOpen) act(() => input.focus());
117
+ fireEvent.click(screen.getByRole('button', { name: '移除Beta' }));
118
+ expect(screen.queryByRole('button', { name: '移除Beta' })).not.toBeInTheDocument();
119
+ expect(screen.getByRole('button', { name: '移除Alpha' })).toBeVisible();
120
+ expect(input).toHaveFocus(); expect(input).toHaveAttribute('aria-expanded', 'false');
121
+ expect(input).toHaveValue('保留查询'); expect(query).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
122
+ fireEvent.click(screen.getByRole('button', { name: '移除Alpha' }));
123
+ expect(input).toHaveAttribute('aria-expanded', 'false');
124
+ expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
125
+ });
126
+
127
+ it.each([false, true])('Backspace retains the current candidate state (open: %s)', initiallyOpen => {
128
+ function Demo() {
129
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }]);
130
+ return <RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }]} />;
131
+ }
132
+ render(<Demo />); const input = screen.getByRole('combobox'); act(() => input.focus());
133
+ if (!initiallyOpen) fireEvent.keyDown(input, { key: 'Escape' });
134
+ fireEvent.keyDown(input, { key: 'Backspace' });
135
+ expect(screen.queryByRole('button', { name: '移除Alpha' })).not.toBeInTheDocument();
136
+ expect(input).toHaveAttribute('aria-expanded', String(initiallyOpen)); expect(input).toHaveFocus();
137
+ });
138
+
139
+ it.each([false, true])('clears without opening candidates (initially open: %s) and hides empty actions', initiallyOpen => {
140
+ const query = vi.fn(), submit = vi.fn(event => event.preventDefault());
141
+ function Demo() {
142
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }]);
143
+ return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="保留查询" onQueryChange={query} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }]} maxVisibleTags={2} /></form>;
144
+ }
145
+ render(<Demo />); const input = screen.getByRole('combobox');
146
+ if (initiallyOpen) act(() => input.focus());
147
+ const clear = screen.getByRole('button', { name: '清空选择' });
148
+ fireEvent.pointerDown(clear); act(() => clear.focus());
149
+ if (initiallyOpen) expect(screen.getByRole('listbox', { name: '成员' })).toBeVisible();
150
+ else expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
151
+ fireEvent.pointerUp(clear); fireEvent.click(clear);
152
+ expect(screen.getByText('已选 0 项')).toBeVisible();
153
+ expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
154
+ expect(input).toHaveFocus(); expect(input).toHaveAttribute('aria-expanded', 'false');
155
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
156
+ expect(input).toHaveValue('保留查询'); expect(query).not.toHaveBeenCalled(); expect(submit).not.toHaveBeenCalled();
157
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
158
+ expect(screen.getByRole('button', { name: '清空选择' })).toBeVisible();
159
+ });
160
+
161
+ it('hides clear when only locked choices remain', () => {
162
+ render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'locked', label: '固定', disabled: true }]} onValueChange={vi.fn()} options={[]} />);
163
+ expect(screen.queryByRole('button', { name: '清空选择' })).not.toBeInTheDocument();
164
+ expect(screen.getByRole('button', { name: '移除固定' })).toBeDisabled();
165
+ });
166
+
109
167
  it('keeps the list open for consecutive selection and exposes the selected list semantics', () => {
110
168
  const query = vi.fn();
111
169
  function Demo() {
@@ -117,12 +175,41 @@ describe('remote multi-select parity', () => {
117
175
  expect(input).toHaveAttribute('aria-expanded', 'true');
118
176
  expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true');
119
177
  expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
120
- fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
178
+ fireEvent.keyDown(input, { key: 'End' }); fireEvent.keyDown(input, { key: 'Enter' });
121
179
  expect(screen.getByRole('button', { name: '移除Beta' })).toBeVisible();
122
180
  expect(container.querySelectorAll('input[type="hidden"][name="member"]')).toHaveLength(3);
123
181
  expect(query).not.toHaveBeenCalled();
124
182
  });
125
183
 
184
+ it('toggles selected candidates with pointer and keyboard, then completes without submitting', () => {
185
+ const submit = vi.fn(event => event.preventDefault());
186
+ function Demo() {
187
+ const [value, setValue] = useState([{ value: 'a', label: 'Alpha' }, { value: 'locked', label: 'Locked', disabled: true }]);
188
+ return <form onSubmit={submit}><RemoteMultiSelect label="成员" query="查询" onQueryChange={() => {}} value={value} onValueChange={setValue} options={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: 'Locked' }, { value: 'off', label: 'Disabled', disabled: true }]} /></form>;
189
+ }
190
+ render(<Demo />);
191
+ const input = screen.getByRole('combobox'); act(() => input.focus());
192
+ fireEvent.click(screen.getByRole('option', { name: 'Alpha' }));
193
+ expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false');
194
+ expect(screen.queryByRole('button', { name: '移除Alpha' })).not.toBeInTheDocument();
195
+ fireEvent.click(screen.getByRole('option', { name: 'Locked' }));
196
+ fireEvent.click(screen.getByRole('option', { name: 'Disabled' }));
197
+ expect(screen.getByRole('button', { name: '移除Locked' })).toBeDisabled();
198
+ expect(screen.queryByRole('button', { name: '移除Disabled' })).not.toBeInTheDocument();
199
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
200
+ expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true');
201
+ fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' });
202
+ expect(screen.getByRole('option', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false');
203
+ const done = screen.getByRole('button', { name: '完成选择' }); act(() => done.focus());
204
+ expect(input).toHaveAttribute('aria-expanded', 'true');
205
+ fireEvent.click(done);
206
+ expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveFocus();
207
+ expect(input).toHaveValue('查询'); expect(submit).not.toHaveBeenCalled();
208
+ fireEvent.click(input); act(() => screen.getByRole('button', { name: '完成选择' }).focus());
209
+ fireEvent.keyDown(screen.getByRole('button', { name: '完成选择' }), { key: 'Escape' });
210
+ expect(input).toHaveAttribute('aria-expanded', 'false'); expect(input).toHaveFocus();
211
+ });
212
+
126
213
  it('removes the last removable tag with Backspace while respecting IME and locked values', () => {
127
214
  const change = vi.fn(); render(<RemoteMultiSelect label="成员" query="" onQueryChange={() => {}} value={[{ value: 'a', label: 'Alpha' }, { value: 'locked', label: '固定', disabled: true }]} onValueChange={change} options={[]} />);
128
215
  const input = screen.getByRole('combobox'); fireEvent.focus(input); fireEvent.compositionStart(input);
@@ -183,3 +270,18 @@ describe('tree keyboard parity', () => {
183
270
  fireEvent.keyDown(group, { key: 'ArrowRight' }); expect(expand).toHaveBeenLastCalledWith(['root']);
184
271
  });
185
272
  });
273
+
274
+
275
+ describe('combobox modal keyboard exit', () => {
276
+ it('keeps focus inside while the nested scope is resuming its parent', () => {
277
+ render(<><button data-testid="background-action">背景动作</button><Dialog trigger={<button>打开选择</button>} title="成员选择" description="合成测试" open><RemoteMultiSelect label="弹窗成员" query="" onQueryChange={() => {}} value={[]} onValueChange={() => {}} options={[{ value: 'a', label: 'Alpha' }]} /></Dialog></>);
278
+ const input = screen.getByRole('combobox', { name: '弹窗成员' });
279
+ act(() => input.focus());
280
+ fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
281
+ const close = screen.getByRole('button', { name: '关闭对话框' });
282
+ act(() => close.focus());
283
+ act(() => screen.getByTestId('background-action').focus());
284
+ expect(close).toHaveFocus();
285
+ expect(input).toHaveAttribute('aria-expanded', 'false');
286
+ });
287
+ });
@@ -0,0 +1,29 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { SideNav } from '../components';
4
+ import { NavActionsGallery } from '../preview/NavActionsGallery';
5
+ describe('SideNav trailing actions', () => {
6
+ it('omits empty actions and keeps action triggers outside links', async () => {
7
+ const navigate = vi.fn(), action = vi.fn();
8
+ render(<SideNav currentId="a" onNavigate={navigate} groups={[{label:'项目', items:[
9
+ {id:'a', label:'甲', href:'#a', actions:[{id:'details',label:'详情',onSelect:action}]},
10
+ {id:'b', label:'乙', href:'#b', actions:[]}, {id:'c',label:'丙',href:'#c'},
11
+ ]}]} />);
12
+ const trigger = screen.getByRole('button', {name:'甲:更多操作'});
13
+ expect(trigger.closest('a')).toBeNull(); expect(screen.getAllByRole('button')).toHaveLength(1);
14
+ expect(screen.getByRole('link', {name:'乙'}).parentElement).toHaveTextContent('乙');
15
+ fireEvent.keyDown(trigger, {key:'Enter'});
16
+ fireEvent.click(await screen.findByRole('menuitem', {name:'详情'}));
17
+ expect(navigate).not.toHaveBeenCalled(); expect(action).toHaveBeenCalledWith(trigger);
18
+ });
19
+ it('opens a controlled dialog and returns focus to the originating more button', async () => {
20
+ render(<NavActionsGallery />);
21
+ const trigger = screen.getByRole('button', {name:'合成工作区:更多操作'});
22
+ trigger.focus(); fireEvent.keyDown(trigger, {key:'Enter'});
23
+ fireEvent.click(await screen.findByRole('menuitem', {name:'查看详情'}));
24
+ expect(await screen.findByRole('dialog', {name:'工作区详情'})).toBeInTheDocument();
25
+ fireEvent.click(screen.getByRole('button', {name:'关闭对话框'}));
26
+ await waitFor(() => expect(trigger).toHaveFocus());
27
+ expect(screen.getByRole('link', {name:'合成工作区'})).toHaveAttribute('aria-current','page');
28
+ });
29
+ });