@linhey/rhine-design 0.6.0 → 0.8.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 +32 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AppShell.tsx +46 -17
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +4 -2
- package/components/Overlays.tsx +37 -8
- package/components/README.md +27 -3
- package/components/StatusTimeline.tsx +72 -6
- package/components/StatusTimelineList.tsx +38 -0
- package/components/ThemePicker.tsx +49 -0
- package/components/assets/orbit-bird.svg +578 -0
- package/components/assets/orbit-crystal.svg +11 -0
- package/components/assets/orbit-donut.svg +16 -0
- package/components/assets/theme-previews/arknights.png +0 -0
- package/components/assets/theme-previews/manifest.json +24 -0
- package/components/assets/theme-previews/neo-brutalism.png +0 -0
- package/components/assets/theme-previews/rhine.png +0 -0
- package/components/common.css +3 -3
- package/components/components.tsx +3 -3
- package/components/dialog-settings.css +9 -0
- package/components/extensions.css +8 -32
- package/components/index.ts +11 -5
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +79 -1
- package/components/orbitBirdFlight.ts +34 -0
- package/components/orbitBirdGeometry.ts +158 -0
- package/components/orbitDonut.ts +134 -0
- package/components/orbitDonutThree.ts +208 -0
- package/components/orbitObjectMaterials.ts +76 -0
- package/components/orbitObjects.ts +71 -0
- package/components/orbitObjectsThree.ts +215 -0
- package/components/orbitProjection.ts +21 -0
- package/components/orbitRenderer.ts +163 -46
- package/components/styles.css +3 -1
- package/components/theme-neo-brutalism.css +3 -2
- package/components/theme-picker.css +17 -0
- package/components/theme.tsx +2 -8
- package/components/themeMetadata.ts +7 -0
- package/components/timeline.css +17 -6
- package/components/useFloatingScale.ts +96 -0
- package/docs/APPLICATION_COMPONENTS.md +52 -3
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +7 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +38 -46
- package/docs/SETTINGS_DIALOG.md +21 -0
- package/docs/THEME_PICKER.md +26 -0
- package/docs/THEMING.md +2 -0
- package/docs/assets/compact-account-neo.png +0 -0
- package/docs/assets/neutron-star-original.png +0 -0
- package/docs/assets/neutron-star-theme.png +0 -0
- package/docs/assets/orbit-bird.png +0 -0
- package/docs/assets/orbit-crystal.png +0 -0
- package/docs/assets/orbit-strawberry.png +0 -0
- package/package.json +8 -3
- package/preview/CompactAccountGallery.tsx +178 -0
- package/preview/DateRangeFilterGallery.tsx +109 -0
- package/preview/ExtensionsGallery.tsx +27 -18
- package/preview/GalleryShell.tsx +3 -3
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +35 -32
- package/preview/OverlayBoundsGallery.tsx +49 -0
- package/preview/SettingsDialogGallery.tsx +33 -0
- package/preview/ThemePickerGallery.tsx +24 -0
- package/preview/ThemeSample.tsx +14 -0
- package/preview/TimelineListGallery.tsx +36 -0
- package/preview/extensions.css +9 -0
- package/preview/main.tsx +16 -3
- package/preview/preview.css +8 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-overlay-bounds.js +76 -0
- package/scripts/check-settings-theme.js +88 -0
- package/skills/rhine-design/SKILL.md +3 -2
- package/skills/rhine-design/references/API_MAP.md +13 -2
- package/skills/rhine-design/references/TASK_MAP.md +8 -2
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/AppShellControls.test.tsx +82 -0
- package/tests/ButtonLink.test.tsx +94 -0
- package/tests/CompactAccountGallery.test.tsx +116 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/NeutronStarBackdrop.test.tsx +288 -0
- package/tests/NeutronStarGallery.test.tsx +62 -0
- package/tests/NeutronStarImport.test.tsx +15 -0
- package/tests/OrbitBackdrop.test.tsx +246 -9
- package/tests/OrbitDonutThree.test.tsx +91 -0
- package/tests/OrbitObjectsThree.test.tsx +164 -0
- package/tests/SettingsDialog.test.tsx +66 -0
- package/tests/StatusTimelineList.test.tsx +63 -0
- package/tests/ThemePicker.test.tsx +30 -0
- package/tests/TimelineLegend.test.tsx +60 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// First run: npm run build && npx vite preview --host 127.0.0.1 --port 18480 --strictPort
|
|
2
|
+
// Fixed regression port 18480 differs from npm run dev (18309).
|
|
3
|
+
// Run with playwright-cli run-code --filename scripts/check-overlay-bounds.js against port 18480.
|
|
4
|
+
async page => {
|
|
5
|
+
const errors = [], failures = [], cases = [];
|
|
6
|
+
let tooltipCases = 0;
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const bounds = async locator => locator.evaluate(element => {
|
|
9
|
+
const rect = element.getBoundingClientRect();
|
|
10
|
+
return { x: rect.x, y: rect.y, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height, viewportWidth: innerWidth, viewportHeight: innerHeight };
|
|
11
|
+
});
|
|
12
|
+
const contained = rect => rect.x >= -1 && rect.y >= -1 && rect.right <= rect.viewportWidth + 1 && rect.bottom <= rect.viewportHeight + 1 && rect.width > 0 && rect.height > 0;
|
|
13
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 520, 880]) for (const zoom of [1, 2]) for (const scope of ['document', 'theme', 'content']) for (const [edge, side] of [['top-left', 'left'], ['top-right', 'top'], ['bottom-left', 'bottom'], ['bottom-right', 'right']]) {
|
|
14
|
+
const spec = { theme, width, zoom, scope, edge, side };
|
|
15
|
+
await page.setViewportSize({ width, height: 758 });
|
|
16
|
+
await page.goto(`http://127.0.0.1:18480/?${new URLSearchParams({ fixture: 'overlay-bounds', theme, zoom: String(zoom), scope, edge, side })}`);
|
|
17
|
+
const trigger = page.getByRole('button', { name: '账号与日期', exact: true });
|
|
18
|
+
await trigger.click();
|
|
19
|
+
const popup = page.getByRole('dialog', { name: '工作区与日期详情' });
|
|
20
|
+
await popup.waitFor();
|
|
21
|
+
const popupRect = await bounds(popup);
|
|
22
|
+
const draft = popup.getByRole('textbox', { name: '草稿名称' });
|
|
23
|
+
await draft.fill('保留中的草稿');
|
|
24
|
+
const draftRect = await bounds(draft);
|
|
25
|
+
const apply = popup.getByRole('button', { name: '应用日期', exact: true });
|
|
26
|
+
await apply.scrollIntoViewIfNeeded();
|
|
27
|
+
const applyRect = await bounds(apply);
|
|
28
|
+
await apply.click();
|
|
29
|
+
const applied = await page.getByRole('status').innerText();
|
|
30
|
+
const close = popup.getByRole('button', { name: '关闭详情', exact: true });
|
|
31
|
+
await close.scrollIntoViewIfNeeded();
|
|
32
|
+
const closeRect = await bounds(close);
|
|
33
|
+
await close.click();
|
|
34
|
+
await page.waitForFunction(() => document.activeElement?.textContent === '账号与日期');
|
|
35
|
+
const focusReturned = await trigger.evaluate(element => element === document.activeElement);
|
|
36
|
+
const menuTrigger = page.getByRole('button', { name: '对象操作', exact: true });
|
|
37
|
+
await menuTrigger.click();
|
|
38
|
+
const menu = page.getByRole('menu', { name: '对象操作' });
|
|
39
|
+
await menu.waitFor();
|
|
40
|
+
const menuRect = await bounds(menu);
|
|
41
|
+
await menu.press('End');
|
|
42
|
+
const last = menu.getByRole('menuitem', { name: '最后一项操作' });
|
|
43
|
+
await last.scrollIntoViewIfNeeded();
|
|
44
|
+
const lastRect = await bounds(last);
|
|
45
|
+
await last.click();
|
|
46
|
+
const selected = await page.getByRole('status').innerText();
|
|
47
|
+
const pass = [popupRect, draftRect, applyRect, closeRect, menuRect, lastRect].every(contained) && applied === '已应用' && selected === '最后一项操作' && focusReturned;
|
|
48
|
+
cases.push({ ...spec, pass });
|
|
49
|
+
if (!pass) failures.push({ ...spec, popupRect, draftRect, applyRect, closeRect, menuRect, lastRect, focusReturned, applied, selected });
|
|
50
|
+
}
|
|
51
|
+
for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880]) for (const zoom of [1,2]) for (const scope of ['document','theme','content']) {
|
|
52
|
+
await page.setViewportSize({width,height:758});
|
|
53
|
+
await page.goto(`http://127.0.0.1:18480/?fixture=overlay-bounds&theme=${theme}&zoom=${zoom}&scope=${scope}&edge=bottom-right&side=right`);
|
|
54
|
+
await page.getByRole('button',{name:'辅助说明',exact:true}).focus();
|
|
55
|
+
const tooltip=page.getByRole('tooltip'); await tooltip.waitFor();
|
|
56
|
+
const tooltipRect=await bounds(tooltip);
|
|
57
|
+
if(!contained(tooltipRect)) failures.push({kind:'tooltip',theme,width,zoom,scope,tooltipRect});
|
|
58
|
+
tooltipCases++;
|
|
59
|
+
await page.keyboard.press('Escape');
|
|
60
|
+
await tooltip.waitFor({state:'detached'});
|
|
61
|
+
}
|
|
62
|
+
// Re-scaling an already open portal must preserve the input node and value.
|
|
63
|
+
await page.goto('http://127.0.0.1:18480/?fixture=overlay-bounds&theme=neo-brutalism&zoom=1');
|
|
64
|
+
await page.setViewportSize({ width: 400, height: 758 });
|
|
65
|
+
await page.getByRole('button', { name: '账号与日期', exact: true }).click();
|
|
66
|
+
const draft = page.getByRole('textbox', { name: '草稿名称' });
|
|
67
|
+
await draft.fill('动态缩放保留');
|
|
68
|
+
const input = await draft.elementHandle();
|
|
69
|
+
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
|
70
|
+
await page.waitForTimeout(150);
|
|
71
|
+
const stable = await input.evaluate(element => element.isConnected && element.value === '动态缩放保留');
|
|
72
|
+
const dynamicBounds = await bounds(page.getByRole('dialog', { name: '工作区与日期详情' }));
|
|
73
|
+
await page.keyboard.press('Escape');
|
|
74
|
+
const escaped = await page.getByRole('dialog').waitFor({state:'detached'}).then(() => true, () => false);
|
|
75
|
+
return { pass: failures.length === 0 && errors.length === 0 && stable && contained(dynamicBounds) && escaped, cases: cases.length, tooltipCases, failures, errors, dynamic: { stable, bounds: dynamicBounds, escaped } };
|
|
76
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// First run: npm run build && npx vite preview --host 127.0.0.1 --port 18480 --strictPort
|
|
2
|
+
// Fixed regression port 18480 differs from npm run dev (18309).
|
|
3
|
+
// playwright-cli run-code --filename scripts/check-settings-theme.js (preview on port 18480).
|
|
4
|
+
async page => {
|
|
5
|
+
const errors = [], failures = [];
|
|
6
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
7
|
+
const box = async locator => locator.evaluate(e => { const r = e.getBoundingClientRect(); return { x:r.x, y:r.y, right:r.right, bottom:r.bottom, w:innerWidth, h:innerHeight }; });
|
|
8
|
+
const inside = r => r.x >= -1 && r.y >= -1 && r.right <= r.w + 1 && r.bottom <= r.h + 1;
|
|
9
|
+
let settingsCases = 0, themeCases = 0;
|
|
10
|
+
for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [400,520,880,1440]) for (const zoom of [1,2]) {
|
|
11
|
+
await page.setViewportSize({width,height:900});
|
|
12
|
+
await page.goto(`http://127.0.0.1:18480/?fixture=settings-dialog&theme=${theme}&zoom=${zoom}`);
|
|
13
|
+
const url = page.url();
|
|
14
|
+
const host = page.getByRole('textbox',{name:'宿主查询'});
|
|
15
|
+
await host.fill('查询保留');
|
|
16
|
+
await page.getByRole('button',{name:'打开设置',exact:true}).click();
|
|
17
|
+
const outer = page.getByRole('dialog',{name:'设置',exact:true});
|
|
18
|
+
const outerBounds = await box(outer);
|
|
19
|
+
const help = outer.getByRole('button',{name:'设置帮助',exact:true});
|
|
20
|
+
await help.focus(); await page.getByRole('tooltip').waitFor();
|
|
21
|
+
await page.keyboard.press('Escape'); await page.getByRole('tooltip').waitFor({state:'detached'});
|
|
22
|
+
if (!await outer.isVisible()) throw new Error('Tooltip Escape closed the settings dialog');
|
|
23
|
+
await outer.getByRole('textbox',{name:'工作区名称'}).fill('已保存草稿');
|
|
24
|
+
const save = outer.getByRole('button',{name:'保存设置',exact:true});
|
|
25
|
+
await save.scrollIntoViewIfNeeded(); const saveBounds=await box(save); await save.click();
|
|
26
|
+
await outer.getByRole('status').getByText('已保存演示草稿').waitFor();
|
|
27
|
+
await outer.getByRole('link',{name:'用户管理与权限',exact:true}).click();
|
|
28
|
+
const edit = outer.getByRole('button',{name:'编辑成员 12',exact:true});
|
|
29
|
+
await edit.click();
|
|
30
|
+
const inner=page.getByRole('dialog',{name:'编辑成员 12',exact:true});
|
|
31
|
+
await inner.waitFor(); const innerBounds=await box(inner);
|
|
32
|
+
await inner.getByRole('textbox',{name:'成员备注'}).fill('临时备注');
|
|
33
|
+
await page.keyboard.press('Escape');
|
|
34
|
+
await inner.waitFor({state:'detached'});
|
|
35
|
+
await page.waitForFunction(()=>document.activeElement?.textContent==='编辑成员 12');
|
|
36
|
+
const outerOpen=await outer.isVisible();
|
|
37
|
+
await page.keyboard.press('Tab');
|
|
38
|
+
const trapped=await outer.evaluate(e=>e.contains(document.activeElement));
|
|
39
|
+
await outer.getByRole('link',{name:'公开项目',exact:true}).click();
|
|
40
|
+
for (const state of ['loading','error','empty','normal']) await outer.getByRole('combobox',{name:'项目状态'}).selectOption(state);
|
|
41
|
+
const close=outer.getByRole('button',{name:'关闭对话框',exact:true});
|
|
42
|
+
await close.scrollIntoViewIfNeeded(); const closeBounds=await box(close); await close.click();
|
|
43
|
+
await page.waitForFunction(()=>document.activeElement?.textContent==='打开设置');
|
|
44
|
+
const pass=[outerBounds,saveBounds,innerBounds,closeBounds].every(inside)&&outerOpen&&trapped&&page.url()===url&&await host.inputValue()==='查询保留';
|
|
45
|
+
if(!pass) failures.push({kind:'settings',theme,width,zoom,outerBounds,saveBounds,innerBounds,closeBounds,outerOpen,trapped});
|
|
46
|
+
settingsCases++;
|
|
47
|
+
}
|
|
48
|
+
for (const theme of ['rhine','arknights','neo-brutalism']) for (const width of [390,400,520,880,889,1440]) for (const zoom of [1,2]) {
|
|
49
|
+
await page.setViewportSize({width,height:900});
|
|
50
|
+
await page.goto(`http://127.0.0.1:18480/?fixture=theme-picker&theme=${theme}&zoom=${zoom}`);
|
|
51
|
+
const draft=page.getByRole('textbox',{name:'未保存草稿',exact:true}); await draft.fill('保留原始输入节点');
|
|
52
|
+
const handle=await draft.elementHandle();
|
|
53
|
+
await page.waitForFunction(()=>[...document.querySelectorAll('.rhine-theme-preview img')].every(e=>e.complete&&e.naturalWidth===960));
|
|
54
|
+
const srcs=await page.locator('.rhine-theme-preview img').evaluateAll(es=>es.map(e=>e.currentSrc));
|
|
55
|
+
for (const name of ['莱茵 · 纸面','明日方舟 · 战术终端','橙趣 · 粗描边']) {
|
|
56
|
+
const radio=page.getByRole('radio',{name,exact:true});
|
|
57
|
+
await page.locator('label.rhine-theme-option').filter({has:radio}).click();
|
|
58
|
+
if(!await radio.isChecked()) failures.push({kind:'theme-selection',theme,width,zoom,name});
|
|
59
|
+
}
|
|
60
|
+
await page.getByRole('checkbox',{name:'长主题名称',exact:true}).check();
|
|
61
|
+
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1);
|
|
62
|
+
const cards=await page.locator('.rhine-theme-option').evaluateAll(es=>es.map(e=>{const r=e.getBoundingClientRect();return {x:r.x,right:r.right,width:innerWidth,height:r.height};}));
|
|
63
|
+
const stable=await handle.evaluate(e=>e.isConnected&&e.value==='保留原始输入节点');
|
|
64
|
+
const srcsAfter=await page.locator('.rhine-theme-preview img').evaluateAll(es=>es.map(e=>e.currentSrc));
|
|
65
|
+
await page.getByRole('button',{name:'弹窗内切换主题',exact:true}).click();
|
|
66
|
+
const dialog=page.getByRole('dialog',{name:'外观设置',exact:true});
|
|
67
|
+
const innerDraft=dialog.getByRole('textbox',{name:'弹窗草稿',exact:true}); await innerDraft.fill('继续编辑,不关闭');
|
|
68
|
+
const innerHandle=await innerDraft.elementHandle();
|
|
69
|
+
const radio=dialog.getByRole('radio',{name:'莱茵 · 纸面',exact:true});
|
|
70
|
+
await dialog.locator('label.rhine-theme-option').filter({has:page.getByRole('radio',{name:'莱茵 · 纸面',exact:true})}).click();
|
|
71
|
+
await radio.focus(); await page.keyboard.press('ArrowRight');
|
|
72
|
+
const keyboard=await dialog.getByRole('radio',{name:'明日方舟 · 战术终端',exact:true}).isChecked();
|
|
73
|
+
const dialogStable=await innerHandle.evaluate(e=>e.isConnected&&e.value==='继续编辑,不关闭');
|
|
74
|
+
await page.keyboard.press('Escape');
|
|
75
|
+
await dialog.waitFor({state:'detached'});
|
|
76
|
+
await page.getByRole('checkbox',{name:'模拟预览失败',exact:true}).check();
|
|
77
|
+
await page.waitForFunction(()=>document.querySelectorAll('.rhine-theme-preview[data-state=error]').length===3);
|
|
78
|
+
const fallback=page.getByRole('radio',{name:/橙趣/});
|
|
79
|
+
await page.locator('label.rhine-theme-option').filter({has:fallback}).click();
|
|
80
|
+
const fallbackSelectable=await fallback.isChecked();
|
|
81
|
+
await page.getByRole('checkbox',{name:'禁用主题选择',exact:true}).check();
|
|
82
|
+
const disabled=await page.getByRole('radio').evaluateAll(es=>es.every(e=>e.matches(':disabled')));
|
|
83
|
+
const pass=!overflow&&cards.every(r=>r.x>=-1&&r.right<=r.width+1&&r.height>=44)&&stable&&JSON.stringify(srcs)===JSON.stringify(srcsAfter)&&keyboard&&dialogStable&&fallbackSelectable&&disabled;
|
|
84
|
+
if(!pass) failures.push({kind:'theme',theme,width,zoom,overflow,cards,stable,keyboard,dialogStable,fallbackSelectable,disabled});
|
|
85
|
+
themeCases++;
|
|
86
|
+
}
|
|
87
|
+
return {pass:failures.length===0&&errors.length===0,settingsCases,themeCases,failures,errors};
|
|
88
|
+
}
|
|
@@ -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.8.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.8.0, including 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
|
|
|
@@ -56,5 +56,6 @@ Required issue content:
|
|
|
56
56
|
- Host state determines data, selection and operation outcomes. Search and pagination remain host/server responsibilities; busy indicators never imply success.
|
|
57
57
|
- For multi-field failures, preserve input and focus a summary only after failed submission; links return to fields without changing routing. Use SideNav/Breadcrumbs/Steps only when exported by the installed source.
|
|
58
58
|
- Monitoring shows units, freshness and stale/unknown states; errors must not rely on color, flashing or transient toasts.
|
|
59
|
+
- Design from the user's current task: keep identity, available actions, results and decision-relevant consequences; remove duplicate state labels and implementation prose. Reveal history on demand. Inside an existing Surface prefer borderless read-only summaries and paired facts; never compress by shrinking business text, cropping values, hiding unknown/error states or merging distinct costs/expiry meanings. See `docs/COMPACT_ACCOUNT_PATTERNS.md` when present; its new APIs are source candidates, not npm 0.6.0 features.
|
|
59
60
|
- Motion explains a change. Respect reduced motion, cancellation and replay; startup decoration never blocks real readiness. Preview-only experiments are not exported components.
|
|
60
61
|
- Keep independent release boundaries: do not change unrelated products or publish packages merely because this skill was invoked.
|
|
@@ -5,21 +5,28 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
5
5
|
| Exports | Source |
|
|
6
6
|
| --- | --- |
|
|
7
7
|
| TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
|
|
8
|
-
| Background, AppShell(0.5.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
|
|
8
|
+
| Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
|
|
9
|
+
| AppShell.contentHeader(0.8.0) | `components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;正文列常驻导航插槽,与正文滚动区分离,组合 AppShellToggle / Inline / 标题与上下文操作 |
|
|
9
10
|
| MediaCard, MediaViewer(0.5.0) | `components/Media.tsx`;中性画布,不嵌套主题 |
|
|
11
|
+
| Media 主题重设计(0.8.0) | `components/media-parity.css`;`docs/APPLICATION_COMPONENTS.md`;卡片、标签与查看器操作/信息栏随宿主主题,仅媒体画布中性;选择与重试独立 |
|
|
10
12
|
| DateField, DateRangePicker, NumberField, TreeSelect, RemoteMultiSelect(0.5.0) | `components/AdvancedForms.tsx`;请求、时区和查询由宿主负责;只读/校验/多选键盘及AntD/MUI保留差异见 `docs/ANTD_MUI_AUDIT.md` |
|
|
13
|
+
| Date presets + confirmed custom range(组合,无新增导出) | `docs/DATE_RANGE_FILTER.md`、`preview/DateRangeFilterGallery.tsx`;复用 SegmentedControl + Button + Popover + DateRangePicker,宿主负责时区、预设和查询 |
|
|
11
14
|
| ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
|
|
12
15
|
| OrbitBackdrop, OrbitBackdropProps (0.6.0) | `components/OrbitBackdrop.tsx`;契约 `docs/ORBIT_BACKDROP.md`;`components/orbitRenderer.ts` 为内部绘制实现 |
|
|
16
|
+
| NeutronStarBackdrop, NeutronStarBackdropProps (0.8.0) | `components/NeutronStarBackdrop.tsx`;契约 `docs/NEUTRON_STAR_BACKDROP.md`;Three.js drawing modules are internal |
|
|
13
17
|
| Logo | `components/Logo.tsx` |
|
|
14
18
|
| Text | `components/Typography.tsx` |
|
|
15
19
|
| Select | `components/Select.tsx` |
|
|
16
20
|
| Toolbar | `components/Toolbar.tsx` |
|
|
17
21
|
| Icon, IconButton, Link, CopyButton | `components/Actions.tsx` |
|
|
22
|
+
| ButtonLink(0.8.0) | `components/Actions.tsx`;`docs/COMPACT_ACCOUNT_PATTERNS.md`;原生 anchor/下载语义,同 Button 视觉,busy 保焦点并移除 href |
|
|
18
23
|
| Divider, Stack, Inline, Grid | `components/Layout.tsx` |
|
|
19
24
|
| Metric, KeyValue, DataTable, Pagination | `components/Data.tsx` |
|
|
20
25
|
| Checkbox, RadioGroup, Switch, Textarea, SearchField, Combobox | `components/Forms.tsx` |
|
|
21
26
|
| Dialog, Drawer (0.4.0), ConfirmDialog, Popover, Menu, Tooltip | `components/Overlays.tsx` |
|
|
22
27
|
| StatusTimeline, TimelineLegend (0.4.0) | `components/StatusTimeline.tsx`;历史证据,不计算健康评分 |
|
|
28
|
+
| TimelineLegend compact/items(0.8.0) | `components/StatusTimeline.tsx`;`docs/COMPACT_ACCOUNT_PATTERNS.md`;宿主短标签/完整规则,一行局部滚动,hover/focus/touch 详情 |
|
|
29
|
+
| StatusTimelineList(0.8.0) | `components/StatusTimelineList.tsx`、`docs/APPLICATION_COMPONENTS.md`;稳定行 ID、标题/摘要/紧凑时间条组合、单一详情,宿主保留请求与分页;展台 `/#monitoring` |
|
|
23
30
|
| SideNav, Breadcrumbs, Steps (0.4.0) | `components/Navigation.tsx` |
|
|
24
31
|
| Fieldset, ErrorSummary (0.4.0) | `components/FormWorkflow.tsx` |
|
|
25
32
|
| Toast | `components/Toast.tsx` |
|
|
@@ -36,9 +43,13 @@ rg -n 'ConfirmDialog|DataTable|Combobox' tests preview
|
|
|
36
43
|
API decisions that commonly matter:
|
|
37
44
|
|
|
38
45
|
- `Select` uses a native single-select; searchable candidates use `Combobox`.
|
|
46
|
+
- `SelectionCard.showIndicator=false` visually hides the native input while preserving keyboard focus, accessible state and card selection styling (0.8.0).
|
|
39
47
|
- `SelectionCard` uses checkbox semantics; the host determines single-selection policy. Do not nest other interactive controls in it.
|
|
40
48
|
- `Text`'s `as` chooses semantics; `variant` chooses visual hierarchy.
|
|
41
49
|
- `Logo` accepts `wordmark` and `label`; its `theme` affects the logo, not the full component library.
|
|
42
|
-
- `OrbitBackdrop` is decorative artwork with built-in pause / resume. Its `reducedMotion` prop can strengthen but never override system reduction; see `docs/ORBIT_BACKDROP.md` for visibility, narrow-window and fallback behavior. It does not express loading or business progress.
|
|
50
|
+
- `OrbitBackdrop` is decorative artwork with built-in pause / resume. Its `reducedMotion` prop can strengthen but never override system reduction; see `docs/ORBIT_BACKDROP.md` for visibility, narrow-window and fallback behavior. In 0.8.0, theme artwork uses an origami paper bird, originium crystals or a strawberry donut; `appearance="original"` preserves the initial orbit. It does not express loading or business progress.
|
|
51
|
+
- `NeutronStarBackdrop` preserves the separate neutron-star artwork. `appearance="theme"` follows existing theme tokens; `original` restores its deep-space palette without replacing the host control theme. It lazy-loads WebGL, freezes under reduction or visibility constraints, and retains static art on failure. It requires 0.8.0 or later; see `docs/NEUTRON_STAR_BACKDROP.md`.
|
|
43
52
|
- `Toast` presents one notification, not a global queue. `DataTable` is not a query or table-state engine.
|
|
44
53
|
- Components in `preview/` are demonstrations. Public API must resolve through a `package.json` export; theme APIs use the separate `/theme` entry.
|
|
54
|
+
|
|
55
|
+
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`.
|
|
@@ -5,21 +5,27 @@ Resolve the matching package root as described in SKILL.md. Open the first relev
|
|
|
5
5
|
| Task | First reference (relative to package root) | Implementation / example |
|
|
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
|
+
| 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 |
|
|
8
9
|
| Theme integration, switching, controlled preferences | `docs/THEMING.md` (0.4.0) | `components/theme.tsx`, `preview/GalleryShell.tsx` |
|
|
9
10
|
| 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
|
+
| 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 |
|
|
10
12
|
| Visual direction, colors, spacing, layout | `docs/DESIGN_SYSTEM.md` | `components/tokens.css`, `components/Layout.tsx` |
|
|
11
13
|
| Spacing, grouping, card composition | `components/SPACING.md` (0.4.0 docs; existing gap API works in 0.3.0) | `components/Layout.tsx`, `preview/SpacingGallery.tsx`; `/#spacing` |
|
|
12
14
|
| Text hierarchy, line height, paragraphs, Chinese/Latin text, metrics | `components/TYPOGRAPHY.md` | `components/Typography.tsx`, `preview/LineHeightGallery.tsx`; `/#typography` |
|
|
13
15
|
| Logo, light/dark variants, wordmark | `components/README.md` → 品牌标志 | `components/Logo.tsx`, `components/assets/logo.svg` |
|
|
14
16
|
| Buttons, navigation, basic states | `components/README.md` → 组件契约 | `components/components.tsx` |
|
|
17
|
+
| 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 |
|
|
15
18
|
| Forms, selection, combobox, data tables | `components/COMMON_COMPONENTS.md` | Use API_MAP for the source file |
|
|
16
19
|
| Modal, menu, popover, tooltip, feedback | `components/COMMON_COMPONENTS.md` | `components/Overlays.tsx`, `components/Toast.tsx` |
|
|
17
20
|
| Transition, replay, cancellation, reduced motion | `components/MOTION.md` | `preview/`, `components/styles.css` |
|
|
18
21
|
| Startup sequence, logo assembly, ready handoff | `components/STARTUP.md` | Search `startup` in `preview/`; not a public Startup component |
|
|
19
|
-
| Orbit artwork, decorative Canvas, pause / resume, reduced motion | `docs/ORBIT_BACKDROP.md` (0.6.0) | `components/OrbitBackdrop.tsx`, internal `components/orbitRenderer.ts`; `/#orbit` |
|
|
22
|
+
| Orbit artwork, decorative Canvas, pause / resume, reduced motion | `docs/ORBIT_BACKDROP.md` (base 0.6.0, themed objects 0.8.0) | `components/OrbitBackdrop.tsx`, internal `components/orbitRenderer.ts`; `/#orbit` |
|
|
23
|
+
| Neutron-star artwork, themed WebGL, async loading, static fallback | `docs/NEUTRON_STAR_BACKDROP.md` (0.8.0) | `components/NeutronStarBackdrop.tsx`; `preview/NeutronStarGallery.tsx`; `/#neutron-star` |
|
|
20
24
|
| Missing capability or system coverage | `docs/DESIGN_SYSTEM_AUDIT.md` | Verify status against `components/index.ts` |
|
|
21
25
|
| Version upgrade | `CHANGELOG.md` | Installed `package.json`, current types |
|
|
22
26
|
|
|
23
|
-
Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion` and `/#
|
|
27
|
+
Run the library showcase with `npm ci && npm run dev` in a source checkout. Default port is 18309; sections include `/#foundation`, `/#typography`, `/#brand`, `/#motion`, `/#startup` and `/#orbit`, plus `/#neutron-star` and `/#compact-account` in 0.8.0. Do not assume another project's server or local filesystem layout.
|
|
24
28
|
|
|
25
29
|
For host pages, keep product-specific drafts in the host project. The library's `preview/` demonstrates reusable behavior, not business APIs. Labels such as “candidate” or “preview” in motion docs identify experiments; check whether the behavior is actually exported before integrating it.
|
|
30
|
+
|
|
31
|
+
For 0.8.0 theme-comparison cards, settings navigation, or floating CSS zoom boundaries, read `docs/THEME_PICKER.md`, `docs/SETTINGS_DIALOG.md`, and `docs/FLOATING_OVERLAYS.md` respectively. Preview routes: `#theme-picker`, `#settings-dialog`, `#overlay-bounds`. Do not infer package availability from the main gallery.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported baseline
|
|
4
4
|
|
|
5
|
-
Rhine Design 0.
|
|
5
|
+
Rhine Design 0.8.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,82 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
4
|
+
import { AppShell, AppShellToggle } from '../components';
|
|
5
|
+
import { ThemeProvider } from '../components/theme';
|
|
6
|
+
|
|
7
|
+
function Demo({ navigationBar = false }: { navigationBar?: boolean }) {
|
|
8
|
+
const [open, setOpen] = useState(true);
|
|
9
|
+
return <ThemeProvider><AppShell controls="inline" layout="single" navigation={{ label: '导航', open, onOpenChange: setOpen,
|
|
10
|
+
content: <div aria-label="品牌区"><strong>Workspace</strong><AppShellToggle placement="panel" iconOnly /><button>导航项</button></div> }}
|
|
11
|
+
contentHeader={navigationBar ? <nav aria-label="工作区导航"><AppShellToggle placement="content" iconOnly /><h1>收件箱</h1></nav> : undefined}>
|
|
12
|
+
{!navigationBar && <AppShellToggle placement="content" iconOnly />}<input aria-label="草稿" defaultValue="保留" />
|
|
13
|
+
</AppShell></ThemeProvider>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
|
17
|
+
describe('inline shell controls', () => {
|
|
18
|
+
it('keeps navigation outside the body and preserves drafts when navigation changes', () => {
|
|
19
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
20
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1040);
|
|
21
|
+
const { rerender } = render(<Demo navigationBar />);
|
|
22
|
+
const input = screen.getByLabelText('草稿');
|
|
23
|
+
fireEvent.change(input, { target: { value: '未保存的编辑' } });
|
|
24
|
+
expect(screen.getByRole('region', { name: '正文' })).not.toContainElement(screen.getByRole('navigation', { name: '工作区导航' }));
|
|
25
|
+
const collapse = screen.getByRole('button', { name: '收起导航' });
|
|
26
|
+
collapse.focus(); fireEvent.click(collapse);
|
|
27
|
+
const expand = within(screen.getByRole('navigation', { name: '工作区导航' })).getByRole('button', { name: '展开导航' });
|
|
28
|
+
expect(expand).toHaveFocus();
|
|
29
|
+
fireEvent.click(expand);
|
|
30
|
+
rerender(<Demo />);
|
|
31
|
+
expect(screen.getByLabelText('草稿')).toBe(input);
|
|
32
|
+
expect(input).toHaveValue('未保存的编辑');
|
|
33
|
+
});
|
|
34
|
+
it('collapses from the brand and restores focus without a standalone header', () => {
|
|
35
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
36
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1040);
|
|
37
|
+
const { container } = render(<Demo />);
|
|
38
|
+
expect(container.querySelector('.rhine-shell__header')).toBeNull();
|
|
39
|
+
const input = screen.getByLabelText('草稿');
|
|
40
|
+
const collapse = within(screen.getByLabelText('品牌区')).getByRole('button', { name: '收起导航' });
|
|
41
|
+
collapse.focus(); fireEvent.click(collapse);
|
|
42
|
+
const expand = screen.getByRole('button', { name: '展开导航' });
|
|
43
|
+
expect(expand).toHaveFocus();
|
|
44
|
+
fireEvent.click(expand);
|
|
45
|
+
expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus();
|
|
46
|
+
expect(screen.getByLabelText('草稿')).toBe(input);
|
|
47
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it('opens the narrow drawer and returns focus after Escape', async () => {
|
|
50
|
+
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
|
51
|
+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400);
|
|
52
|
+
render(<Demo />);
|
|
53
|
+
const trigger = screen.getByRole('button', { name: '导航' });
|
|
54
|
+
trigger.focus(); fireEvent.click(trigger);
|
|
55
|
+
const dialog = await screen.findByRole('dialog', { name: '导航' });
|
|
56
|
+
expect(within(dialog).getByText('Workspace')).toBeVisible();
|
|
57
|
+
expect(screen.queryByRole('button', { name: '收起导航' })).toBeNull();
|
|
58
|
+
fireEvent.keyDown(dialog, { key: 'Escape' });
|
|
59
|
+
await waitFor(() => expect(trigger).toHaveFocus());
|
|
60
|
+
});
|
|
61
|
+
it('moves focused navigation to the content trigger when the container becomes narrow', () => {
|
|
62
|
+
let resize = () => {};
|
|
63
|
+
vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} });
|
|
64
|
+
const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1040);
|
|
65
|
+
render(<Demo />);
|
|
66
|
+
screen.getByRole('button', { name: '导航项' }).focus();
|
|
67
|
+
width.mockReturnValue(400); act(resize);
|
|
68
|
+
expect(screen.getByRole('button', { name: '导航' })).toHaveFocus();
|
|
69
|
+
});
|
|
70
|
+
it('returns to the brand after closing a drawer that was resized to desktop', async () => {
|
|
71
|
+
let resize = () => {};
|
|
72
|
+
vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback; } observe() {} disconnect() {} });
|
|
73
|
+
const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(400);
|
|
74
|
+
render(<Demo />);
|
|
75
|
+
fireEvent.click(screen.getByRole('button', { name: '导航' }));
|
|
76
|
+
const dialog = await screen.findByRole('dialog', { name: '导航' });
|
|
77
|
+
width.mockReturnValue(1040); act(resize);
|
|
78
|
+
expect(dialog).toBeVisible();
|
|
79
|
+
fireEvent.keyDown(dialog, { key: 'Escape' });
|
|
80
|
+
await waitFor(() => expect(screen.getByRole('button', { name: '收起导航' })).toHaveFocus());
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createRef } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ButtonLink } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('ButtonLink', () => {
|
|
7
|
+
it('keeps native link and download attributes and forwards the anchor ref', () => {
|
|
8
|
+
const ref = createRef<HTMLAnchorElement>();
|
|
9
|
+
render(<ButtonLink ref={ref} href="#download" download="example.json" target="_blank" rel="noopener" variant="secondary" className="host-action">下载文件</ButtonLink>);
|
|
10
|
+
const link = screen.getByRole('link', { name: '下载文件' });
|
|
11
|
+
expect(link.tagName).toBe('A');
|
|
12
|
+
expect(ref.current).toBe(link);
|
|
13
|
+
expect(link).toHaveAttribute('href', '#download');
|
|
14
|
+
expect(link).toHaveAttribute('download', 'example.json');
|
|
15
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
16
|
+
expect(link).toHaveAttribute('rel', 'noopener');
|
|
17
|
+
expect(link).toHaveClass('rhine-button', 'rhine-button--secondary', 'host-action');
|
|
18
|
+
expect(link).not.toHaveAttribute('aria-disabled');
|
|
19
|
+
expect(link).not.toHaveAttribute('aria-busy');
|
|
20
|
+
expect(link.tabIndex).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves primary, modified and auxiliary clicks to the browser and caller', () => {
|
|
24
|
+
const click = vi.fn();
|
|
25
|
+
const auxiliary = vi.fn();
|
|
26
|
+
render(<ButtonLink href="#download" onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink>);
|
|
27
|
+
const link = screen.getByRole('link');
|
|
28
|
+
for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { shiftKey: true }]) {
|
|
29
|
+
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...modifiers });
|
|
30
|
+
expect(fireEvent(link, event)).toBe(true);
|
|
31
|
+
expect(event.defaultPrevented).toBe(false);
|
|
32
|
+
expect(click.mock.lastCall?.[0].ctrlKey).toBe(Boolean('ctrlKey' in modifiers && modifiers.ctrlKey));
|
|
33
|
+
expect(click.mock.lastCall?.[0].metaKey).toBe(Boolean('metaKey' in modifiers && modifiers.metaKey));
|
|
34
|
+
}
|
|
35
|
+
const event = new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true });
|
|
36
|
+
expect(fireEvent(link, event)).toBe(true);
|
|
37
|
+
expect(event.defaultPrevented).toBe(false);
|
|
38
|
+
expect(click).toHaveBeenCalledTimes(4);
|
|
39
|
+
expect(auxiliary).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('lets the caller cancel native navigation', () => {
|
|
43
|
+
render(<ButtonLink href="#download" onClick={event => event.preventDefault()}>下载文件</ButtonLink>);
|
|
44
|
+
expect(fireEvent.click(screen.getByRole('link'))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('removes disabled destinations and blocks primary and auxiliary activation', () => {
|
|
48
|
+
const click = vi.fn();
|
|
49
|
+
const auxiliary = vi.fn();
|
|
50
|
+
const delegated = vi.fn();
|
|
51
|
+
render(<div onClick={delegated} onAuxClick={delegated}><ButtonLink href="#download" disabled tabIndex={0} onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink></div>);
|
|
52
|
+
const link = screen.getByRole('link', { name: '下载文件' });
|
|
53
|
+
expect(link).not.toHaveAttribute('href');
|
|
54
|
+
expect(link).toHaveAttribute('aria-disabled', 'true');
|
|
55
|
+
expect(link.tabIndex).toBe(-1);
|
|
56
|
+
expect(fireEvent.click(link, { ctrlKey: true })).toBe(false);
|
|
57
|
+
expect(fireEvent(link, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))).toBe(false);
|
|
58
|
+
expect(click).not.toHaveBeenCalled();
|
|
59
|
+
expect(auxiliary).not.toHaveBeenCalled();
|
|
60
|
+
expect(delegated).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('retains focus while busy and restores the destination when ready', () => {
|
|
64
|
+
const click = vi.fn();
|
|
65
|
+
const auxiliary = vi.fn();
|
|
66
|
+
const { rerender } = render(<ButtonLink href="#download" onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink>);
|
|
67
|
+
const link = screen.getByRole('link');
|
|
68
|
+
link.focus();
|
|
69
|
+
rerender(<ButtonLink href="#download" busy busyLabel="准备文件" onClick={click} onAuxClick={auxiliary}>下载文件</ButtonLink>);
|
|
70
|
+
expect(link).toHaveFocus();
|
|
71
|
+
expect(link).toHaveAttribute('aria-busy', 'true');
|
|
72
|
+
expect(link).toHaveAttribute('aria-disabled', 'true');
|
|
73
|
+
expect(link).toHaveAccessibleName('准备文件');
|
|
74
|
+
expect(link).not.toHaveAttribute('href');
|
|
75
|
+
expect(link.tabIndex).toBe(0);
|
|
76
|
+
expect(fireEvent.click(link)).toBe(false);
|
|
77
|
+
expect(fireEvent(link, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true }))).toBe(false);
|
|
78
|
+
expect(click).not.toHaveBeenCalled();
|
|
79
|
+
expect(auxiliary).not.toHaveBeenCalled();
|
|
80
|
+
rerender(<ButtonLink href="#ready" onClick={click}>下载文件</ButtonLink>);
|
|
81
|
+
expect(link).toHaveFocus();
|
|
82
|
+
expect(link).toHaveAttribute('href', '#ready');
|
|
83
|
+
expect(link).not.toHaveAttribute('aria-disabled');
|
|
84
|
+
expect(link).not.toHaveAttribute('aria-busy');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keeps caller focus policy while busy and gives disabled precedence', () => {
|
|
88
|
+
const { rerender } = render(<ButtonLink href="#download" busy tabIndex={-1}>下载文件</ButtonLink>);
|
|
89
|
+
expect(screen.getByRole('link').tabIndex).toBe(-1);
|
|
90
|
+
rerender(<ButtonLink href="#download" busy disabled tabIndex={0}>下载文件</ButtonLink>);
|
|
91
|
+
expect(screen.getByRole('link').tabIndex).toBe(-1);
|
|
92
|
+
expect(screen.getByRole('link')).toHaveAttribute('data-disabled', 'true');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { CompactAccountExample, CompactAccountGallery, CompactSubscriptionExample } from '../preview/CompactAccountGallery';
|
|
4
|
+
|
|
5
|
+
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
|
9
|
+
else Reflect.deleteProperty(navigator, 'clipboard');
|
|
10
|
+
});
|
|
11
|
+
function demo(content: React.ReactNode) { return render(<div className="rhine-theme">{content}</div>); }
|
|
12
|
+
|
|
13
|
+
describe('compact account composition', () => {
|
|
14
|
+
it('preserves valid zero, unknown values and distinct expiry meanings', () => {
|
|
15
|
+
const view = demo(<CompactAccountExample state="zero" />);
|
|
16
|
+
expect(screen.getByRole('progressbar', { name: '7D 剩余' })).toHaveAttribute('aria-valuenow', '0');
|
|
17
|
+
expect(within(screen.getByTestId('compact-account-facts')).getAllByText('0')).toHaveLength(4);
|
|
18
|
+
expect(within(screen.getByTestId('compact-account-facts')).getAllByText('$0.00')).toHaveLength(2);
|
|
19
|
+
view.rerender(<div className="rhine-theme"><CompactAccountExample state="unknown" /></div>);
|
|
20
|
+
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText('7D 剩余 — · 额度暂不可用')).toBeVisible();
|
|
22
|
+
expect(screen.getByText('历史记录不完整,仅展示已确认的时段。')).toBeVisible();
|
|
23
|
+
expect(within(screen.getByTestId('compact-account-facts')).getAllByText('—')).toHaveLength(5);
|
|
24
|
+
view.rerender(<div className="rhine-theme"><CompactAccountExample state="long" /></div>);
|
|
25
|
+
expect(screen.getByText('到期')).toBeVisible();
|
|
26
|
+
expect(screen.getByText('订阅到期')).toBeVisible();
|
|
27
|
+
expect(screen.getByText('$12,345.67 / $24,691.34')).toBeVisible();
|
|
28
|
+
expect(screen.getByText('$123,456.78 / $246,913.56')).toBeVisible();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses the shared timeline with inspectable evidence and compact custom legend', async () => {
|
|
32
|
+
demo(<CompactAccountExample />);
|
|
33
|
+
expect(screen.getByRole('list', { name: '资源用量与异常记录' })).toBeInTheDocument();
|
|
34
|
+
const slider = screen.getByRole('slider', { name: '用量与异常记录 历史时段' });
|
|
35
|
+
act(() => slider.focus());
|
|
36
|
+
const details = await screen.findByRole('dialog', { name: '用量与异常记录 历史时段时段详情' });
|
|
37
|
+
expect(within(details).getByText(/按 15 分钟聚合的合成记录/)).toBeVisible();
|
|
38
|
+
const legend = screen.getByRole('list', { name: '历史状态图例' });
|
|
39
|
+
for (const label of ['有输出', '已恢复', '待确认', '失败', '未知']) expect(within(legend).getByText(label)).toBeVisible();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('exposes long, narrow and scaled review settings without changing business values', () => {
|
|
43
|
+
demo(<CompactAccountGallery />);
|
|
44
|
+
fireEvent.change(screen.getByLabelText('账号读数'), { target: { value: 'long' } });
|
|
45
|
+
fireEvent.change(screen.getByLabelText('样机宽度'), { target: { value: '400' } });
|
|
46
|
+
fireEvent.change(screen.getByLabelText('内容缩放'), { target: { value: '2' } });
|
|
47
|
+
expect(screen.getByTestId('compact-stage')).toHaveStyle({ width: '400px' });
|
|
48
|
+
expect(screen.getByText('$123,456.78 / $246,913.56')).toBeVisible();
|
|
49
|
+
fireEvent.click(screen.getByLabelText('包含历史链接'));
|
|
50
|
+
expect(screen.getByText('历史链接(1)')).toBeVisible();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('compact credential actions composition', () => {
|
|
55
|
+
it('shows a native download action and no redundant empty history or repeated state', () => {
|
|
56
|
+
demo(<CompactSubscriptionExample />);
|
|
57
|
+
const download = screen.getByRole('link', { name: '下载文件' });
|
|
58
|
+
expect(download.tagName).toBe('A');
|
|
59
|
+
expect(download).toHaveAttribute('download', 'rhine-demo.json');
|
|
60
|
+
expect(download).toHaveClass('rhine-button');
|
|
61
|
+
expect(download.getAttribute('href')).toMatch(/^data:application\/json/);
|
|
62
|
+
expect(screen.queryByText(/历史链接/)).not.toBeInTheDocument();
|
|
63
|
+
expect(screen.queryByText('当前链接')).not.toBeInTheDocument();
|
|
64
|
+
expect(screen.queryByText('已生成')).not.toBeInTheDocument();
|
|
65
|
+
expect(screen.getByText('链接可下载凭证,请勿分享。')).toBeVisible();
|
|
66
|
+
expect(screen.getByText('重置或撤销后,旧链接失效。')).toBeVisible();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('copies only the synthetic address and leaves it available after clipboard failure', async () => {
|
|
70
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
71
|
+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
|
|
72
|
+
demo(<CompactSubscriptionExample />);
|
|
73
|
+
fireEvent.click(screen.getByRole('button', { name: '复制链接' }));
|
|
74
|
+
await screen.findByText('已复制演示链接。');
|
|
75
|
+
expect(writeText).toHaveBeenCalledWith('https://example.invalid/subscriptions/demo/1');
|
|
76
|
+
writeText.mockRejectedValueOnce(new Error('clipboard unavailable'));
|
|
77
|
+
fireEvent.click(screen.getByRole('button', { name: '复制链接' }));
|
|
78
|
+
await screen.findByRole('alert');
|
|
79
|
+
expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/1');
|
|
80
|
+
expect(screen.getByRole('button', { name: '复制链接' })).toBeEnabled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps history collapsed, then resets, revokes and explicitly regenerates a link', async () => {
|
|
84
|
+
demo(<CompactSubscriptionExample showHistory />);
|
|
85
|
+
const previous = screen.getByText('https://example.invalid/subscriptions/demo/retired-1');
|
|
86
|
+
expect(previous).not.toBeVisible();
|
|
87
|
+
fireEvent.click(screen.getByText('历史链接(1)'));
|
|
88
|
+
await waitFor(() => expect(previous).toBeVisible());
|
|
89
|
+
fireEvent.click(screen.getByRole('button', { name: '重置链接' }));
|
|
90
|
+
expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/2');
|
|
91
|
+
expect(screen.getByText('历史链接(2)')).toBeVisible();
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: '撤销链接' }));
|
|
93
|
+
expect(screen.getByText('尚未生成订阅链接')).toBeVisible();
|
|
94
|
+
expect(screen.queryByRole('link', { name: '下载文件' })).not.toBeInTheDocument();
|
|
95
|
+
expect(screen.queryByRole('button', { name: '撤销链接' })).not.toBeInTheDocument();
|
|
96
|
+
fireEvent.click(screen.getByRole('button', { name: '获取订阅链接' }));
|
|
97
|
+
expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/3');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('blocks loading actions and does not generate from an error or no-link state', () => {
|
|
101
|
+
const view = demo(<CompactSubscriptionExample initialState="loading" />);
|
|
102
|
+
expect(screen.getByRole('status')).toHaveTextContent('正在读取订阅链接');
|
|
103
|
+
expect(screen.getByRole('button', { name: '复制链接' })).toBeDisabled();
|
|
104
|
+
expect(screen.getByText('下载文件').closest('a')).toHaveAttribute('aria-disabled', 'true');
|
|
105
|
+
expect(screen.queryByLabelText('订阅链接')).not.toBeInTheDocument();
|
|
106
|
+
view.rerender(<div className="rhine-theme"><CompactSubscriptionExample key="error" initialState="error" /></div>);
|
|
107
|
+
expect(screen.getByRole('alert')).toHaveTextContent('订阅链接读取失败');
|
|
108
|
+
expect(screen.queryByLabelText('订阅链接')).not.toBeInTheDocument();
|
|
109
|
+
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
|
110
|
+
expect(screen.getByLabelText('订阅链接')).toHaveValue('https://example.invalid/subscriptions/demo/1');
|
|
111
|
+
view.rerender(<div className="rhine-theme"><CompactSubscriptionExample key="empty" initialState="no-link" /></div>);
|
|
112
|
+
expect(screen.getByRole('button', { name: '获取订阅链接' })).toBeEnabled();
|
|
113
|
+
expect(screen.queryByLabelText('订阅链接')).not.toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByText(/历史链接/)).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|