@linhey/rhine-design 0.9.0 → 0.10.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 +16 -0
- package/README.md +3 -1
- package/components/AdvancedForms.tsx +24 -10
- package/components/COMMON_COMPONENTS.md +1 -0
- package/components/Forms.tsx +14 -1
- package/components/ListFilterBar.tsx +5 -3
- package/components/Overlays.tsx +3 -2
- package/components/README.md +3 -3
- package/components/SPACING.md +6 -0
- package/components/TYPOGRAPHY.md +2 -0
- package/components/checkbox-row.css +12 -0
- package/components/common.css +1 -1
- package/components/compact-forms.css +20 -0
- package/components/components.tsx +7 -4
- package/components/extensions.css +10 -0
- package/components/index.ts +2 -1
- package/components/media-parity.css +4 -4
- package/components/patterns.css +3 -3
- package/components/styles.css +6 -2
- package/components/theme-picker.css +2 -2
- package/components/timeline.css +2 -1
- package/docs/APPLICATION_COMPONENTS.md +6 -2
- package/docs/CHECKBOX_ROWS.md +36 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +3 -1
- package/docs/COMPACT_FILTER_CONTROLS.md +38 -0
- package/docs/COMPACT_LAYOUT_VERIFICATION.md +53 -0
- package/docs/COMPONENT_TYPOGRAPHY_AUDIT.md +44 -0
- package/docs/DESIGN_SYSTEM.md +2 -0
- package/docs/INTERACTION_PATTERNS.md +6 -0
- package/docs/LIST_FILTER_BAR.md +8 -1
- package/docs/assets/compact-layouts/account.png +0 -0
- package/docs/assets/compact-layouts/date-arknights.png +0 -0
- package/docs/assets/compact-layouts/date-neo-brutalism.png +0 -0
- package/docs/assets/compact-layouts/date-rhine.png +0 -0
- package/docs/assets/compact-layouts/table.png +0 -0
- package/package.json +1 -1
- package/preview/CheckboxRowGallery.tsx +26 -0
- package/preview/CompactAccountGallery.tsx +18 -18
- package/preview/CompactFilterBarGallery.tsx +19 -0
- package/preview/ComponentTypographyGallery.tsx +23 -0
- package/preview/DateRangeFilterGallery.tsx +18 -1
- package/preview/DenseFilterControlsGallery.tsx +21 -0
- package/preview/GalleryShell.tsx +1 -1
- package/preview/SpacingGallery.tsx +15 -1
- package/preview/main.tsx +7 -0
- package/preview/preview.css +9 -2
- package/scripts/check-checkbox-rows.js +65 -0
- package/scripts/check-compact-layouts.js +136 -0
- package/scripts/check-component-typography.js +76 -0
- package/scripts/check-dense-filters.js +60 -0
- package/scripts/check-filter-native-date.js +1 -1
- package/skills/rhine-design/SKILL.md +3 -3
- package/skills/rhine-design/references/API_MAP.md +3 -1
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/CheckboxRow.test.tsx +53 -0
- package/tests/CompactAccountGallery.test.tsx +2 -1
- package/tests/CompactFilterBar.test.tsx +50 -0
- package/tests/DenseFilterControls.test.tsx +79 -0
- package/tests/DesignSystem.test.tsx +12 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Open a fixed production preview, then run with playwright-cli run-code --filename.
|
|
2
|
+
// CSS zoom evidence is separate from native browser-menu zoom.
|
|
3
|
+
async page => {
|
|
4
|
+
page.setDefaultTimeout(10000);
|
|
5
|
+
const origin = new URL(page.url()).origin;
|
|
6
|
+
const failures = [], errors = [], counts = { drawer: 0, date: 0, table: 0, account: 0 };
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const check = (pass, context) => { if (!pass) failures.push(context); };
|
|
9
|
+
const open = async (section, theme, width, zoom) => {
|
|
10
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
11
|
+
await page.goto(`${origin}/?theme=${theme}#${section}`);
|
|
12
|
+
await page.locator(`#${section}`).waitFor();
|
|
13
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
14
|
+
};
|
|
15
|
+
const editLastSegment = async (input, context) => {
|
|
16
|
+
await input.fill('2026-10-24');
|
|
17
|
+
await input.focus();
|
|
18
|
+
// The preview's zh-CN native date order is year/month/day.
|
|
19
|
+
await page.keyboard.press('ArrowRight');
|
|
20
|
+
await page.keyboard.press('ArrowRight');
|
|
21
|
+
await page.keyboard.press('ArrowUp');
|
|
22
|
+
check(await input.inputValue() === '2026-10-25', { ...context, kind: 'edit-last-segment' });
|
|
23
|
+
await page.keyboard.press('ArrowDown');
|
|
24
|
+
check(await input.inputValue() === '2026-10-24', { ...context, kind: 'restore-last-segment' });
|
|
25
|
+
};
|
|
26
|
+
const measureDate = async (input, context) => {
|
|
27
|
+
const dimensions = await input.evaluate(e => {
|
|
28
|
+
const style = getComputedStyle(e);
|
|
29
|
+
return { width: parseFloat(style.width), height: parseFloat(style.height), font: parseFloat(style.fontSize), padding: parseFloat(style.paddingLeft) };
|
|
30
|
+
});
|
|
31
|
+
check(dimensions.width >= 148 && dimensions.height >= 44 && dimensions.font >= 14, { ...context, kind: 'date-dimensions', dimensions });
|
|
32
|
+
};
|
|
33
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
34
|
+
for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
|
|
35
|
+
const context = { theme, width, zoom };
|
|
36
|
+
await open('list-filter-bar', theme, width, zoom);
|
|
37
|
+
const bar = page.getByRole('group', { name: '录制会话筛选', exact: true });
|
|
38
|
+
const trigger = bar.getByRole('button', { name: /^更多筛选/ });
|
|
39
|
+
await trigger.click();
|
|
40
|
+
const dialog = page.getByRole('dialog', { name: '录制会话筛选', exact: true });
|
|
41
|
+
await dialog.evaluate(e => Promise.all(e.getAnimations().map(a => a.finished)));
|
|
42
|
+
const start = dialog.getByLabel('开始日期', { exact: true });
|
|
43
|
+
await measureDate(start, context);
|
|
44
|
+
await editLastSegment(start, context);
|
|
45
|
+
await dialog.getByLabel('结束日期', { exact: true }).fill('2026-10-26');
|
|
46
|
+
await dialog.getByRole('button', { name: '清空日期', exact: true }).click();
|
|
47
|
+
check(await start.inputValue() === '', { ...context, kind: 'clear-range' });
|
|
48
|
+
await dialog.getByRole('checkbox', { name: '只看已审查' }).check();
|
|
49
|
+
await start.focus(); await page.keyboard.type('2');
|
|
50
|
+
check(await start.getAttribute('aria-invalid') === 'true', { ...context, kind: 'incomplete-date' });
|
|
51
|
+
await dialog.getByRole('button', { name: '重置筛选', exact: true }).click();
|
|
52
|
+
check(await start.evaluate(e => !e.value && !e.validity.badInput && e.getAttribute('aria-invalid') !== 'true'), { ...context, kind: 'reset-draft' });
|
|
53
|
+
if (width === 400 && zoom === 2) {
|
|
54
|
+
await start.fill('2026-10-24');
|
|
55
|
+
await start.screenshot({ path: `output/playwright/issues-23-25/drawer-${theme}.png` });
|
|
56
|
+
}
|
|
57
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
|
58
|
+
await page.waitForFunction(e => e === document.activeElement, await trigger.elementHandle());
|
|
59
|
+
counts.drawer++;
|
|
60
|
+
|
|
61
|
+
await open('spacing', theme, width, zoom);
|
|
62
|
+
const surface = page.locator('#spacing .rhine-surface--padding-none');
|
|
63
|
+
check(await surface.evaluate(e => getComputedStyle(e).padding === '0px' && getComputedStyle(e).overflow !== 'hidden'), { ...context, kind: 'surface-padding' });
|
|
64
|
+
check(await surface.locator('td').first().evaluate(e => parseFloat(getComputedStyle(e).paddingLeft) > 0), { ...context, kind: 'table-cell-spacing' });
|
|
65
|
+
const selection = surface.getByRole('checkbox').nth(1);
|
|
66
|
+
await selection.uncheck(); await selection.check();
|
|
67
|
+
await selection.focus(); await page.keyboard.press('Space');
|
|
68
|
+
check(!await selection.isChecked(), { ...context, kind: 'table-keyboard-selection' });
|
|
69
|
+
check(await surface.evaluate(e => e.getBoundingClientRect().right <= innerWidth + 1), { ...context, kind: 'table-bounds' });
|
|
70
|
+
const scroll = surface.getByRole('region', { name: /可横向滚动/ });
|
|
71
|
+
await scroll.focus();
|
|
72
|
+
await scroll.evaluate(e => { e.scrollLeft = e.scrollWidth; });
|
|
73
|
+
check(await scroll.evaluate(e => e === document.activeElement && e.querySelector('tbody tr td:last-child').getBoundingClientRect().right <= e.getBoundingClientRect().right + 1), { ...context, kind: 'table-last-column-reachable' });
|
|
74
|
+
await scroll.evaluate(e => { e.scrollLeft = 0; });
|
|
75
|
+
if (width === 400 && zoom === 2) await surface.screenshot({ path: `output/playwright/issues-23-25/table-${theme}.png` });
|
|
76
|
+
counts.table++;
|
|
77
|
+
|
|
78
|
+
await open('compact-account', theme, width, 1);
|
|
79
|
+
await page.getByRole('combobox', { name: '样机宽度' }).selectOption(String(width));
|
|
80
|
+
await page.getByRole('combobox', { name: '内容缩放' }).selectOption(String(zoom));
|
|
81
|
+
for (const state of ['normal', 'zero', 'unknown', 'long']) {
|
|
82
|
+
await page.getByRole('combobox', { name: '账号读数' }).selectOption(state);
|
|
83
|
+
const card = page.getByTestId('compact-account-card');
|
|
84
|
+
const layout = await card.evaluate(e => {
|
|
85
|
+
const bars = e.querySelector('.rhine-timeline__bars').getBoundingClientRect();
|
|
86
|
+
const legend = e.querySelector('.rhine-timeline__legend').getBoundingClientRect();
|
|
87
|
+
const slider = e.querySelector('input[type=range]');
|
|
88
|
+
const pairs = [...e.querySelectorAll('.compact-gallery-fact-pair')];
|
|
89
|
+
return {
|
|
90
|
+
gap: legend.top - bars.bottom,
|
|
91
|
+
sliderHeight: parseFloat(getComputedStyle(slider).height),
|
|
92
|
+
triggerHeight: parseFloat(getComputedStyle(e.querySelector('.rhine-timeline__legend-trigger')).height),
|
|
93
|
+
aligned: [...pairs[0].children].every((child, i) => Math.abs(child.getBoundingClientRect().left - pairs[1].children[i].getBoundingClientRect().left) < 1),
|
|
94
|
+
overflow: [...e.querySelectorAll('.compact-gallery-facts, .compact-gallery-facts span')].some(node => node.scrollWidth > node.clientWidth + 1),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
check(Math.abs(layout.gap / zoom - 12) < 1 && layout.sliderHeight >= 44 && layout.triggerHeight >= 44 && layout.aligned && !layout.overflow, { ...context, state, kind: 'account-layout', layout });
|
|
98
|
+
if (state === 'normal') {
|
|
99
|
+
const slider = card.getByRole('slider');
|
|
100
|
+
await slider.focus(); await page.keyboard.press('ArrowRight');
|
|
101
|
+
const detail = page.getByRole('dialog', { name: /时段详情$/ });
|
|
102
|
+
await detail.waitFor(); await page.keyboard.press('Escape'); await detail.waitFor({ state: 'detached' });
|
|
103
|
+
await page.getByRole('combobox', { name: '账号读数' }).focus();
|
|
104
|
+
await slider.hover(); await detail.waitFor();
|
|
105
|
+
await page.mouse.move(0, 0); await detail.waitFor({ state: 'detached' });
|
|
106
|
+
const legend = card.getByRole('button', { name: '有输出', exact: true });
|
|
107
|
+
await legend.click();
|
|
108
|
+
const rule = page.getByRole('dialog', { name: '有输出规则', exact: true });
|
|
109
|
+
await rule.waitFor(); await page.keyboard.press('Escape'); await rule.waitFor({ state: 'detached' });
|
|
110
|
+
}
|
|
111
|
+
if (width === 400 && zoom === 2 && state === 'long') await card.screenshot({ path: `output/playwright/issues-23-25/account-${theme}.png` });
|
|
112
|
+
counts.account++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Leave room for the gallery sidebar: max-width must not clamp the 280px fixture.
|
|
116
|
+
await open('date-range-filter', theme, 1280, 2);
|
|
117
|
+
for (const containerWidth of [150, 200, 280]) {
|
|
118
|
+
await page.getByRole('combobox', { name: '日期容器宽度' }).selectOption(String(containerWidth));
|
|
119
|
+
const context = { theme, containerWidth, zoom: 2 };
|
|
120
|
+
const range = page.getByRole('group', { name: '局部日期', exact: true });
|
|
121
|
+
check(await range.evaluate((e, expected) => Math.abs(parseFloat(getComputedStyle(e.parentElement).width) - expected) < 1, containerWidth), { ...context, kind: 'actual-container-width' });
|
|
122
|
+
const start = range.getByLabel('开始日期', { exact: true });
|
|
123
|
+
const single = page.getByLabel('独立日期', { exact: true });
|
|
124
|
+
await measureDate(start, context); await measureDate(single, context);
|
|
125
|
+
await editLastSegment(start, context); await editLastSegment(single, context);
|
|
126
|
+
await single.fill(''); check(await single.inputValue() === '', { ...context, kind: 'clear-single' });
|
|
127
|
+
await range.getByRole('button', { name: '清空日期', exact: true }).click();
|
|
128
|
+
await page.getByRole('button', { name: '重置示例日期' }).click();
|
|
129
|
+
check(await start.inputValue() === '2026-10-24' && await single.inputValue() === '2026-10-24', { ...context, kind: 'reset-example' });
|
|
130
|
+
check(await page.getByRole('button', { name: '重置示例日期' }).evaluate(e => e === document.activeElement), { ...context, kind: 'reset-example-focus' });
|
|
131
|
+
await single.screenshot({ path: `output/playwright/issues-23-25/single-${theme}-${containerWidth}.png` });
|
|
132
|
+
counts.date++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { pass: !failures.length && !errors.length, counts, failures, errors, scope: 'Chromium; real gallery, CSS zoom, native keyboard editing. Inspect date screenshots for full fragments/calendar visibility.' };
|
|
136
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Run against the production preview with playwright-cli run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(10000);
|
|
4
|
+
const origin = new URL(page.url()).origin;
|
|
5
|
+
const failures = [], errors = [];
|
|
6
|
+
let scenarios = 0;
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const check = (pass, context) => { if (!pass) failures.push(context); };
|
|
9
|
+
const typography = async (selector, size, leading, weight, context) => {
|
|
10
|
+
const actual = await page.locator(selector).first().evaluate(e => {
|
|
11
|
+
const s = getComputedStyle(e);
|
|
12
|
+
return { size:s.fontSize, leading:s.lineHeight, weight:s.fontWeight };
|
|
13
|
+
});
|
|
14
|
+
check(actual.size === `${size}px` && actual.leading === `${leading}px` && actual.weight === String(weight), { ...context, selector, actual, expected:{size,leading,weight} });
|
|
15
|
+
};
|
|
16
|
+
const wrappedText = async (selector, context) => {
|
|
17
|
+
const actual = await page.locator(selector).evaluate(e => {
|
|
18
|
+
const range = document.createRange();
|
|
19
|
+
range.selectNodeContents(e);
|
|
20
|
+
const lines = Array.from(range.getClientRects());
|
|
21
|
+
const box = e.getBoundingClientRect();
|
|
22
|
+
return {
|
|
23
|
+
lineCount:new Set(lines.map(r => Math.round(r.top))).size,
|
|
24
|
+
unclipped:e.scrollWidth <= e.clientWidth+1 && e.scrollHeight <= e.clientHeight+1 &&
|
|
25
|
+
lines.every(r => r.left >= box.left-1 && r.right <= box.right+1 && r.top >= box.top-1 && r.bottom <= box.bottom+1)
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
check(actual.lineCount > 1 && actual.unclipped, {...context,selector,kind:'text-wrap',actual});
|
|
29
|
+
};
|
|
30
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
31
|
+
for (const width of [400,520,880]) for (const zoom of [1,2]) {
|
|
32
|
+
const context = {theme,width,zoom}, heading = theme === 'neo-brutalism' ? 750 : 600;
|
|
33
|
+
await page.setViewportSize({width,height:1000});
|
|
34
|
+
await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}#component-typography`);
|
|
35
|
+
await page.getByRole('heading',{name:'公共组件文字配对',exact:true}).waitFor();
|
|
36
|
+
await page.evaluate(value => { document.documentElement.style.zoom=String(value); },zoom);
|
|
37
|
+
await page.getByRole('button',{name:'模拟复制',exact:true}).click();
|
|
38
|
+
await page.locator('.rhine-copy>[role]').filter({hasText:'已复制'}).waitFor();
|
|
39
|
+
await page.locator('.rhine-theme-preview[data-state=error]').waitFor();
|
|
40
|
+
for (const inherited of [false,true]) {
|
|
41
|
+
if (inherited) await page.locator('.rhine-theme').evaluate(e => { e.style.lineHeight='38px'; e.style.fontWeight='700'; });
|
|
42
|
+
const state = {...context,inherited};
|
|
43
|
+
await typography('.rhine-empty strong',16,24,heading,state);
|
|
44
|
+
await typography('.rhine-error-summary h3',16,24,heading,state);
|
|
45
|
+
for (const selector of ['.rhine-copy>[role]','.rhine-steps small','.rhine-fieldset>p','.rhine-theme-option__status','.rhine-theme-preview']) await typography(selector,12,18,400,state);
|
|
46
|
+
await typography('.rhine-media-card__status',14,22,400,state);
|
|
47
|
+
}
|
|
48
|
+
await page.locator('.rhine-theme').evaluate(e => { e.style.lineHeight=''; e.style.fontWeight=''; });
|
|
49
|
+
check(await page.locator('.rhine-empty').evaluate(e => e.scrollWidth <= e.clientWidth+1), {...context,kind:'empty-wrap'});
|
|
50
|
+
if (width === 400 && zoom === 2) await wrappedText('.rhine-empty strong',context);
|
|
51
|
+
if (width === 400 && zoom === 2) await page.locator('.rhine-empty').screenshot({path:`output/playwright/issue-31/empty-${theme}.png`});
|
|
52
|
+
const opener=page.getByRole('button',{name:'打开媒体示例',exact:true});
|
|
53
|
+
await opener.click();
|
|
54
|
+
const dialog=page.getByRole('dialog');
|
|
55
|
+
await dialog.waitFor();
|
|
56
|
+
await typography('.rhine-media-viewer__header p',12,18,400,context);
|
|
57
|
+
await typography('.rhine-media-viewer__header h2',18,26,heading,context);
|
|
58
|
+
await typography('.rhine-media-viewer__actions label',12,18,400,context);
|
|
59
|
+
if (width === 400 && zoom === 2) await wrappedText('.rhine-media-viewer__header p',context);
|
|
60
|
+
check(await dialog.evaluate(e => e.scrollWidth <= e.clientWidth+1), {...context,kind:'viewer-wrap'});
|
|
61
|
+
check(await dialog.locator('button,select').evaluateAll((elements, zoom) => elements.every(e => e.getBoundingClientRect().height/zoom >= 44),zoom), {...context,kind:'hit-targets'});
|
|
62
|
+
await dialog.getByRole('button',{name:'下一项',exact:true}).click();
|
|
63
|
+
check(await dialog.getByRole('heading',{name:'下一项合成媒体',exact:true}).isVisible(), {...context,kind:'next'});
|
|
64
|
+
if (width === 400 && zoom === 2) await page.locator('.rhine-media-viewer__header').screenshot({path:`output/playwright/issue-31/media-${theme}.png`});
|
|
65
|
+
const close=dialog.getByRole('button',{name:'关闭媒体查看器',exact:true});
|
|
66
|
+
await close.scrollIntoViewIfNeeded();
|
|
67
|
+
check(await close.evaluate(e => { const r=e.getBoundingClientRect(); return r.top >= 0 && r.bottom <= innerHeight && r.left >= 0 && r.right <= innerWidth; }), {...context,kind:'close-visible'});
|
|
68
|
+
await close.click(); await dialog.waitFor({state:'detached'});
|
|
69
|
+
await page.waitForFunction(() => document.activeElement?.textContent === '打开媒体示例');
|
|
70
|
+
await opener.click(); await page.keyboard.press('Escape'); await dialog.waitFor({state:'detached'});
|
|
71
|
+
await page.waitForFunction(() => document.activeElement?.textContent === '打开媒体示例');
|
|
72
|
+
scenarios++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {pass:!failures.length&&!errors.length,scenarios,failures,errors,scope:'Chromium, three themes, 400/520/880px, CSS zoom 100/200%; synthetic content and inherited-style stress. Native menu zoom is not covered.'};
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Run against a production preview: playwright-cli run-code --filename=scripts/check-dense-filters.js
|
|
2
|
+
async page => {
|
|
3
|
+
page.setDefaultTimeout(10000);
|
|
4
|
+
const origin = new URL(page.url()).origin;
|
|
5
|
+
const failures = [], errors = [];
|
|
6
|
+
let scenarios = 0;
|
|
7
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
8
|
+
const check = (pass, context) => { if (!pass) failures.push(context); };
|
|
9
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
10
|
+
for (const width of [400, 520, 880]) for (const zoom of [1, 2]) {
|
|
11
|
+
const context = { theme, width, zoom };
|
|
12
|
+
await page.setViewportSize({ width, height: 1000 });
|
|
13
|
+
await page.goto(`${origin}/?theme=${theme}&proof=${Date.now()}-${scenarios}#compact-filters`);
|
|
14
|
+
await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
|
|
15
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
16
|
+
const tags = page.getByRole('list', { name: '部门已选项' });
|
|
17
|
+
check(await tags.getByRole('listitem').count() === 2, { ...context, kind: 'bounded-tags' });
|
|
18
|
+
check(await tags.evaluate(e => e.scrollWidth <= e.clientWidth + 1 && [...e.children].every(li => li.scrollWidth <= li.clientWidth + 1)), { ...context, kind: 'tags-fit' });
|
|
19
|
+
await page.getByRole('button', { name: '展开已选项' }).click();
|
|
20
|
+
check(await tags.getByRole('listitem').count() === 90 && await tags.evaluate(e => e.clientHeight <= 224 && e.scrollHeight > e.clientHeight), { ...context, kind: 'bounded-expansion' });
|
|
21
|
+
await page.getByRole('button', { name: '移除合成部门 90', exact: true }).click();
|
|
22
|
+
check(await page.getByRole('combobox', { name: '部门', exact: true }).evaluate(e => document.activeElement === e), { ...context, kind: 'remove-focus' });
|
|
23
|
+
await page.getByRole('button', { name: '清空部门', exact: true }).click();
|
|
24
|
+
await page.getByRole('button', { name: '全选部门', exact: true }).click();
|
|
25
|
+
check(await tags.getByRole('listitem').count() === 2, { ...context, kind: 'reset-expansion' });
|
|
26
|
+
const range = page.getByRole('group', { name: '日期区间', exact: true });
|
|
27
|
+
const start = range.getByLabel('开始日期', { exact: true });
|
|
28
|
+
await start.fill('2026-10-01'); await start.focus();
|
|
29
|
+
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowUp');
|
|
30
|
+
check(await start.inputValue() === '2026-10-02', { ...context, kind: 'native-last-segment' });
|
|
31
|
+
check(await range.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind: 'date-fit' });
|
|
32
|
+
if (width === 400 && zoom === 2) await range.screenshot({ path: `output/playwright/issue-27/date-${theme}.png` });
|
|
33
|
+
const trigger = page.getByRole('button', { name: '更多筛选', exact: true });
|
|
34
|
+
await trigger.click();
|
|
35
|
+
const dialog = page.getByRole('dialog', { name: '紧凑筛选示例', exact: true });
|
|
36
|
+
await dialog.evaluate(e => Promise.all(e.getAnimations().map(a => a.finished)));
|
|
37
|
+
check(!await dialog.getAttribute('aria-describedby') && await dialog.locator('.rhine-list-filter-bar__feedback').count() === 0, { ...context, kind: 'omitted-feedback' });
|
|
38
|
+
const segments = dialog.locator('fieldset.rhine-segmented');
|
|
39
|
+
check(await segments.evaluate(e => {
|
|
40
|
+
const items = [...e.querySelectorAll('label')].map(label => ({ label: label.getBoundingClientRect(), span: label.querySelector('span').getBoundingClientRect() }));
|
|
41
|
+
return items.every(item => Math.abs(item.label.width - items[0].label.width) < 1 && Math.abs(item.label.width - item.span.width) < 1 && Math.abs(item.label.height - item.span.height) < 1);
|
|
42
|
+
}), { ...context, kind: 'equal-segment-backgrounds' });
|
|
43
|
+
await segments.getByRole('radio', { name: '今天', exact: true }).focus();
|
|
44
|
+
await page.keyboard.press('ArrowRight');
|
|
45
|
+
check(await segments.getByRole('radio', { name: '昨天', exact: true }).isChecked(), { ...context, kind: 'native-radio-keyboard' });
|
|
46
|
+
check(await dialog.evaluate(e => e.scrollWidth <= e.clientWidth + 1), { ...context, kind: 'drawer-fit' });
|
|
47
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
|
48
|
+
await trigger.waitFor();
|
|
49
|
+
await page.waitForFunction(() => document.activeElement?.textContent?.includes('更多筛选'));
|
|
50
|
+
for (const [state, text] of [['加载', '正在更新结果…'], ['失败', '暂时无法更新合成结果,请重试。']]) {
|
|
51
|
+
await page.getByRole('button', { name: state, exact: true }).click();
|
|
52
|
+
await trigger.click();
|
|
53
|
+
check(await dialog.getByText(text, { exact: true }).isVisible(), { ...context, kind: `feedback-${state}` });
|
|
54
|
+
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' });
|
|
55
|
+
}
|
|
56
|
+
scenarios++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { pass: failures.length === 0 && errors.length === 0, scenarios, failures, errors, scope: 'Chromium, three themes, 400/520/880px, 100/200% CSS zoom; synthetic data only. Native menu zoom is not covered.' };
|
|
60
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
async page => {
|
|
2
|
-
await page.goto(
|
|
2
|
+
await page.goto(`${new URL(page.url()).origin}/?fixture=list-filters&theme=neo-brutalism&proof=${Date.now()}#list-filter-bar`);
|
|
3
3
|
const checks = [];
|
|
4
4
|
const check = (name, pass, evidence = {}) => checks.push({ name, pass: Boolean(pass), ...evidence });
|
|
5
5
|
await page.getByRole('group', { name: '录制会话筛选', exact: true }).getByRole('button', { name: /^更多筛选/ }).click();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rhine-design
|
|
3
3
|
description: Use Rhine Design components and specifications to design, implement, or review React UI, typography, themes, and motion. Apply when a project uses Rhine Design or the user requests its visual language or component system.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.
|
|
5
|
+
version: "0.10.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Rhine Design · 莱茵设计
|
|
@@ -13,7 +13,7 @@ Use the installed version's components and contracts to build consistent interfa
|
|
|
13
13
|
|
|
14
14
|
## Locate the matching source
|
|
15
15
|
|
|
16
|
-
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.
|
|
16
|
+
Package: `@linhey/rhine-design`. GitHub: `linhay/rhine-design` (different account spelling). This skill targets 0.10.0, including compact filter controls, Checkbox row layout, explicit component typography, ListFilterBar, Surface padding, narrow date layouts, OrbitBackdrop, application shells, advanced forms, media, controlled tables and monitoring charts. Theme and navigation APIs require at least 0.4.0; application APIs require 0.5.0 and OrbitBackdrop requires 0.6.0. Check installed exports before adoption.
|
|
17
17
|
|
|
18
18
|
Find the package in the host's dependencies, normally `node_modules/@linhey/rhine-design/`, or its registered source checkout. Verify `package.json` name/version before using the paths below. Paths in references are relative to that package root, not the copied skill directory. Read installed types and source when versions differ; do not infer a new API from this skill. If the package is unavailable, use the task's supplied design sources and identify the missing implementation evidence.
|
|
19
19
|
|
|
@@ -56,6 +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
|
|
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 base APIs require 0.8.0, and the spacing fixes and Surface padding option require 0.9.1.
|
|
60
60
|
- Motion explains a change. Respect reduced motion, cancellation and replay; startup decoration never blocks real readiness. Preview-only experiments are not exported components.
|
|
61
61
|
- Keep independent release boundaries: do not change unrelated products or publish packages merely because this skill was invoked.
|
|
@@ -4,12 +4,14 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
4
4
|
|
|
5
5
|
| Exports | Source |
|
|
6
6
|
| --- | --- |
|
|
7
|
+
| Checkbox `layout="row"`, CheckboxProps(自 0.10.0) | `components/Forms.tsx`、`docs/CHECKBOX_ROWS.md`;整行双行多选,默认 form 保持兼容;npm 0.9.1 不含此选项 |
|
|
7
8
|
| TrendChart, StackedBar;StatusTimeline.detail(0.5.0) | `components/Charts.tsx`、`components/StatusTimeline.tsx`;`docs/APPLICATION_COMPONENTS.md`,未知不补零;键盘逐点查看、按ID受控高亮/详情、valueFormatter |
|
|
8
9
|
| Background, AppShell(0.5.0);AppShellToggle(0.8.0) | `components/Background.tsx`、`components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;桌面open与临时drawerOpen独立,断点为容器CSS布局宽度 |
|
|
9
10
|
| AppShell.contentHeader(0.8.0) | `components/AppShell.tsx`;`docs/APPLICATION_COMPONENTS.md`;正文列常驻导航插槽,与正文滚动区分离,组合 AppShellToggle / Inline / 标题与上下文操作 |
|
|
10
11
|
| MediaCard, MediaViewer(0.5.0) | `components/Media.tsx`;中性画布,不嵌套主题 |
|
|
11
12
|
| Media 主题重设计(0.8.0) | `components/media-parity.css`;`docs/APPLICATION_COMPONENTS.md`;卡片、标签与查看器操作/信息栏随宿主主题,仅媒体画布中性;选择与重试独立 |
|
|
12
13
|
| DateField, DateRangePicker, NumberField, TreeSelect, RemoteMultiSelect(0.5.0) | `components/AdvancedForms.tsx`;请求、时区和查询由宿主负责;只读/校验/多选键盘及AntD/MUI保留差异见 `docs/ANTD_MUI_AUDIT.md` |
|
|
14
|
+
| 紧凑筛选(自 0.10.0,npm 0.9.1 不含) | `docs/COMPACT_FILTER_CONTROLS.md`;SegmentedControl `fullWidth`、DateRangePicker `compact`、RemoteMultiSelect `maxVisibleTags`、ListFilterBar `drawerDescription` / `drawerResult`;保持默认兼容、完整选择值及加载/错误反馈 |
|
|
13
15
|
| Date presets + confirmed custom range(组合,无新增导出) | `docs/DATE_RANGE_FILTER.md`、`preview/DateRangeFilterGallery.tsx`;复用 SegmentedControl + Button + Popover + DateRangePicker,宿主负责时区、预设和查询 |
|
|
14
16
|
| ThemeProvider, useTheme, themes, ThemeName, ThemeProviderProps (0.4.0;`@linhey/rhine-design/theme`) | `components/theme.tsx`;契约 `docs/THEMING.md` |
|
|
15
17
|
| OrbitBackdrop, OrbitBackdropProps (0.6.0) | `components/OrbitBackdrop.tsx`;契约 `docs/ORBIT_BACKDROP.md`;`components/orbitRenderer.ts` 为内部绘制实现 |
|
|
@@ -30,7 +32,7 @@ All paths are relative to the matching package root. `package.json` exports, `co
|
|
|
30
32
|
| SideNav, Breadcrumbs, Steps (0.4.0) | `components/Navigation.tsx` |
|
|
31
33
|
| Fieldset, ErrorSummary (0.4.0) | `components/FormWorkflow.tsx` |
|
|
32
34
|
| Toast | `components/Toast.tsx` |
|
|
33
|
-
| Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx` |
|
|
35
|
+
| Button, Badge, Surface, SelectionCard, TextField, Tabs, SegmentedControl, Notice, Progress, Skeleton, Disclosure, EmptyState | `components/components.tsx`;Surface 的 `padding="none"` 仅取消表格容器外围内距,默认不变;此选项自 0.9.1 提供,见 `CHANGELOG.md` |
|
|
34
36
|
|
|
35
37
|
Primary contracts: `components/README.md` and `components/COMMON_COMPONENTS.md`. Find behavioral evidence in `tests/` and composition examples in `preview/`.
|
|
36
38
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported baseline
|
|
4
4
|
|
|
5
|
-
Rhine Design 0.
|
|
5
|
+
Rhine Design 0.10.0 ships TypeScript/TSX, CSS and SVG source for React 19 + Vite. Install `@linhey/rhine-design`; GitHub remains `linhay/rhine-design`. Check the installed package's engine and peer dependencies. Node 24 is the development baseline. Do not assume precompiled JavaScript, arbitrary SSR support or a complete dark theme.
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
8
|
import { Button, Surface, Text } from '@linhey/rhine-design';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Checkbox, type CheckboxProps } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('Checkbox row layout', () => {
|
|
7
|
+
it('toggles once when the controlled checkbox description is clicked', () => {
|
|
8
|
+
const change = vi.fn();
|
|
9
|
+
function Example() {
|
|
10
|
+
const [checked, setChecked] = useState(false);
|
|
11
|
+
return <Checkbox layout="row" label="项目 A" hint="内存卡 · 14 项" checked={checked} onChange={event => { setChecked(event.target.checked); change(event.target.checked); }} />;
|
|
12
|
+
}
|
|
13
|
+
render(<Example />);
|
|
14
|
+
const input = screen.getByRole('checkbox', { name: '项目 A' });
|
|
15
|
+
expect(input).toHaveAccessibleDescription('内存卡 · 14 项');
|
|
16
|
+
fireEvent.click(screen.getByText('内存卡 · 14 项'));
|
|
17
|
+
expect(input).toBeChecked();
|
|
18
|
+
expect(change).toHaveBeenCalledExactlyOnceWith(true);
|
|
19
|
+
});
|
|
20
|
+
it('keeps mixed and disabled states on the native input', () => {
|
|
21
|
+
const change = vi.fn();
|
|
22
|
+
const { rerender } = render(<Checkbox layout="row" label="全部" hint="三个项目" indeterminate disabled onChange={change} />);
|
|
23
|
+
expect(screen.getByRole('checkbox')).toBePartiallyChecked();
|
|
24
|
+
fireEvent.click(screen.getByText('三个项目'));
|
|
25
|
+
expect(change).not.toHaveBeenCalled();
|
|
26
|
+
rerender(<Checkbox layout="row" label="全部" checked disabled onChange={change} />);
|
|
27
|
+
expect(screen.getByRole('checkbox')).toBeChecked();
|
|
28
|
+
expect(screen.getByRole('checkbox')).not.toBePartiallyChecked();
|
|
29
|
+
});
|
|
30
|
+
it('preserves native form submission and reset for uncontrolled selection', () => {
|
|
31
|
+
render(<form aria-label="项目"><Checkbox layout="row" label="项目 A" name="source" value="a" defaultChecked /><button type="reset">重置</button></form>);
|
|
32
|
+
const form = screen.getByRole('form') as HTMLFormElement;
|
|
33
|
+
expect(new FormData(form).getAll('source')).toEqual(['a']);
|
|
34
|
+
fireEvent.click(screen.getByRole('checkbox'));
|
|
35
|
+
expect(new FormData(form).getAll('source')).toEqual([]);
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
|
37
|
+
expect(screen.getByRole('checkbox')).toBeChecked();
|
|
38
|
+
});
|
|
39
|
+
it('preserves caller accessible names and combines descriptions', () => {
|
|
40
|
+
const props: CheckboxProps = { layout: 'row', label: '名称', hint: '说明', 'aria-label': '自定义名称', 'aria-describedby': 'external' };
|
|
41
|
+
render(<><span id="external">附加信息</span><Checkbox {...props} /></>);
|
|
42
|
+
expect(screen.getByRole('checkbox', { name: '自定义名称' })).toHaveAccessibleDescription('附加信息 说明');
|
|
43
|
+
});
|
|
44
|
+
it('keeps the default form hint outside its original label', () => {
|
|
45
|
+
const change = vi.fn();
|
|
46
|
+
render(<Checkbox label="表单字段" hint="原布局" onChange={change} />);
|
|
47
|
+
expect(screen.getByText('原布局').closest('label')).toBeNull();
|
|
48
|
+
fireEvent.click(screen.getByText('原布局'));
|
|
49
|
+
expect(change).not.toHaveBeenCalled();
|
|
50
|
+
fireEvent.click(screen.getByText('表单字段'));
|
|
51
|
+
expect(change).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -19,7 +19,8 @@ describe('compact account composition', () => {
|
|
|
19
19
|
view.rerender(<div className="rhine-theme"><CompactAccountExample state="unknown" /></div>);
|
|
20
20
|
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
|
21
21
|
expect(screen.getByText('7D 剩余 — · 额度暂不可用')).toBeVisible();
|
|
22
|
-
expect(screen.getByText(
|
|
22
|
+
expect(screen.getByText(/10\/23 00:00 – 10\/24 00:00 · 上海/)).toBeVisible();
|
|
23
|
+
expect(screen.getByText(/历史记录不完整,仅展示已确认的时段。/)).toBeVisible();
|
|
23
24
|
expect(within(screen.getByTestId('compact-account-facts')).getAllByText('—')).toHaveLength(5);
|
|
24
25
|
view.rerender(<div className="rhine-theme"><CompactAccountExample state="long" /></div>);
|
|
25
26
|
expect(screen.getByText('到期')).toBeVisible();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ListFilterBar, SegmentedControl, Select } from '../components';
|
|
5
|
+
|
|
6
|
+
const field = { id: 'state', label: '状态', control: <Select label="状态" defaultValue="all"><option value="all">全部</option></Select> };
|
|
7
|
+
|
|
8
|
+
describe('compact filter presentation', () => {
|
|
9
|
+
it('omits redundant drawer copy while keeping pending and error feedback', async () => {
|
|
10
|
+
const { rerender } = render(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={null} drawerResult={null} />);
|
|
11
|
+
fireEvent.click(screen.getByRole('button', { name: '更多筛选' }));
|
|
12
|
+
const dialog = await screen.findByRole('dialog', { name: '记录筛选' });
|
|
13
|
+
expect(within(dialog).queryByText('更改后立即更新结果。')).not.toBeInTheDocument();
|
|
14
|
+
expect(within(dialog).queryByText('120 条结果')).not.toBeInTheDocument();
|
|
15
|
+
expect(dialog).not.toHaveAttribute('aria-describedby');
|
|
16
|
+
expect(document.querySelectorAll('.rhine-list-filter-bar__feedback')).toHaveLength(1);
|
|
17
|
+
rerender(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={null} drawerResult={null} pending error="连接暂时不可用" />);
|
|
18
|
+
expect(within(dialog).getByText('正在更新结果…')).toBeInTheDocument();
|
|
19
|
+
expect(within(dialog).getByRole('alert')).toHaveTextContent('连接暂时不可用');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves legacy drawer feedback and supports distinct drawer content', async () => {
|
|
23
|
+
const { rerender } = render(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" />);
|
|
24
|
+
fireEvent.click(screen.getByRole('button', { name: '更多筛选' }));
|
|
25
|
+
const dialog = await screen.findByRole('dialog', { name: '记录筛选' });
|
|
26
|
+
expect(within(dialog).getByText('120 条结果')).toBeInTheDocument();
|
|
27
|
+
expect(dialog).toHaveAccessibleDescription('更改后立即更新结果。');
|
|
28
|
+
rerender(<ListFilterBar label="记录筛选" filters={[field]} result="120 条结果" drawerDescription={<span>按需选择范围</span>} drawerResult="已选 3 项" />);
|
|
29
|
+
expect(dialog).toHaveAccessibleDescription('按需选择范围');
|
|
30
|
+
expect(within(dialog).getByText('已选 3 项')).toBeInTheDocument();
|
|
31
|
+
expect(within(dialog).queryByText('120 条结果')).not.toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('full-width segments remain controlled native radios with disabled state', () => {
|
|
35
|
+
const onChange = vi.fn();
|
|
36
|
+
function Example() {
|
|
37
|
+
const [value, setValue] = useState('today');
|
|
38
|
+
return <SegmentedControl fullWidth label="时间快捷" value={value} onValueChange={next => { setValue(next); onChange(next); }} items={[
|
|
39
|
+
{ value: 'today', label: '今天' }, { value: 'yesterday', label: '昨天' }, { value: 'week', label: '本周', disabled: true },
|
|
40
|
+
]} />;
|
|
41
|
+
}
|
|
42
|
+
render(<Example />);
|
|
43
|
+
expect(screen.getByRole('group', { name: '时间快捷' })).toHaveClass('rhine-segmented--full-width');
|
|
44
|
+
fireEvent.click(screen.getByRole('radio', { name: '昨天' }));
|
|
45
|
+
expect(screen.getByRole('radio', { name: '昨天' })).toBeChecked();
|
|
46
|
+
expect(screen.getByRole('radio', { name: '今天' })).not.toBeChecked();
|
|
47
|
+
expect(screen.getByRole('radio', { name: '本周' })).toBeDisabled();
|
|
48
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith('yesterday');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { DateRangePicker, RemoteMultiSelect, type Choice } from '../components';
|
|
5
|
+
|
|
6
|
+
describe('compact date range', () => {
|
|
7
|
+
it('keeps both native fields and range validation inside one named group', () => {
|
|
8
|
+
const validation = vi.fn();
|
|
9
|
+
function Example() {
|
|
10
|
+
const [value, setValue] = useState({ start: '2026-10-01', end: '2026-10-24' });
|
|
11
|
+
return <DateRangePicker compact label="日期范围" value={value} onValueChange={setValue} min="2026-01-01" max="2026-12-31" clearable={false} required onValidationChange={validation} />;
|
|
12
|
+
}
|
|
13
|
+
render(<Example />);
|
|
14
|
+
const group = screen.getByRole('group', { name: '日期范围' });
|
|
15
|
+
expect(group).toHaveClass('rhine-date-range--compact');
|
|
16
|
+
expect(within(group).getByLabelText('开始日期')).toBeRequired();
|
|
17
|
+
expect(within(group).getByLabelText('结束日期')).toHaveAttribute('type', 'date');
|
|
18
|
+
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-09-30' } });
|
|
19
|
+
expect(validation).toHaveBeenLastCalledWith('结束日期不能早于开始日期。');
|
|
20
|
+
fireEvent.change(screen.getByLabelText('结束日期'), { target: { value: '2026-10-24' } });
|
|
21
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
22
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2025-12-31' } });
|
|
23
|
+
expect(validation).toHaveBeenLastCalledWith('日期不能早于 2026-01-01。');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('preserves readonly and external value replacement in compact mode', () => {
|
|
27
|
+
const change = vi.fn();
|
|
28
|
+
const { rerender } = render(<DateRangePicker compact label="范围" value={{ start: '2026-10-01', end: '2026-10-24' }} onValueChange={change} readOnly />);
|
|
29
|
+
fireEvent.change(screen.getByLabelText('开始日期'), { target: { value: '2026-10-02' } });
|
|
30
|
+
expect(change).not.toHaveBeenCalled();
|
|
31
|
+
expect(screen.getByRole('button', { name: '清空日期' })).toBeDisabled();
|
|
32
|
+
rerender(<DateRangePicker compact label="范围" value={{ start: '2026-11-01', end: '2026-11-24' }} onValueChange={change} readOnly />);
|
|
33
|
+
expect(screen.getByLabelText('开始日期')).toHaveValue('2026-11-01');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('bounded multi-select tags', () => {
|
|
38
|
+
const choices: Choice[] = Array.from({ length: 100 }, (_, i) => ({ value: String(i), label: `合成部门 ${i + 1}`, disabled: i === 0 }));
|
|
39
|
+
function Example() {
|
|
40
|
+
const [value, setValue] = useState(choices.slice(0, 90));
|
|
41
|
+
const [query, setQuery] = useState('');
|
|
42
|
+
return <RemoteMultiSelect label="部门" query={query} onQueryChange={setQuery} options={choices} value={value} onValueChange={setValue} maxVisibleTags={2} name="department" />;
|
|
43
|
+
}
|
|
44
|
+
it('shows a bounded summary without dropping hidden selections and expands for removal', () => {
|
|
45
|
+
render(<Example />);
|
|
46
|
+
const tags = screen.getByRole('list', { name: '部门已选项' });
|
|
47
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(2);
|
|
48
|
+
expect(screen.getByText('已选 90 项')).toBeInTheDocument();
|
|
49
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(90);
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: '展开已选项' }));
|
|
51
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(90);
|
|
52
|
+
fireEvent.click(screen.getByRole('button', { name: '移除合成部门 90' }));
|
|
53
|
+
expect(screen.getByText('已选 89 项')).toBeInTheDocument();
|
|
54
|
+
expect(screen.getByRole('combobox')).toHaveFocus();
|
|
55
|
+
fireEvent.click(screen.getByRole('button', { name: '收起已选项' }));
|
|
56
|
+
expect(within(tags).getAllByRole('listitem')).toHaveLength(2);
|
|
57
|
+
expect(screen.getByRole('button', { name: '移除合成部门 1' })).toBeDisabled();
|
|
58
|
+
});
|
|
59
|
+
it('retains hidden-item keyboard removal and locked selections when clearing', () => {
|
|
60
|
+
render(<Example />);
|
|
61
|
+
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
|
|
62
|
+
expect(screen.getByText('已选 89 项')).toBeInTheDocument();
|
|
63
|
+
expect(document.querySelector('input[type=hidden][value="89"]')).toBeNull();
|
|
64
|
+
fireEvent.click(screen.getByRole('button', { name: '清空选择' }));
|
|
65
|
+
expect(screen.getByText('已选 1 项')).toBeInTheDocument();
|
|
66
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(1);
|
|
67
|
+
expect(screen.queryByRole('button', { name: '展开已选项' })).not.toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
it('collapses a new selection after the host clears an expanded selection', () => {
|
|
70
|
+
const props = { label: '部门', query: '', onQueryChange: vi.fn(), options: choices, onValueChange: vi.fn(), maxVisibleTags: 2, name: 'department' };
|
|
71
|
+
const { rerender } = render(<RemoteMultiSelect {...props} value={choices.slice(0, 90)} />);
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: '展开已选项' }));
|
|
73
|
+
rerender(<RemoteMultiSelect {...props} value={[]} />);
|
|
74
|
+
rerender(<RemoteMultiSelect {...props} value={choices} />);
|
|
75
|
+
expect(within(screen.getByRole('list', { name: '部门已选项' })).getAllByRole('listitem')).toHaveLength(2);
|
|
76
|
+
expect(screen.getByRole('button', { name: '展开已选项' })).toHaveAttribute('aria-expanded', 'false');
|
|
77
|
+
expect(document.querySelectorAll('input[type=hidden]')).toHaveLength(100);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { Button, Disclosure, Logo, Progress, SegmentedControl, Select, SelectionCard, Tabs, TextField } from '../components';
|
|
4
|
+
import { Button, Disclosure, Logo, Progress, SegmentedControl, Select, SelectionCard, Surface, Tabs, TextField } from '../components';
|
|
5
5
|
|
|
6
6
|
describe('reusable design components', () => {
|
|
7
|
+
it('keeps Surface native attributes and default spacing when opting out of table container padding', () => {
|
|
8
|
+
const { rerender } = render(<Surface role="region" aria-label="表格" className="host-layout">内容</Surface>);
|
|
9
|
+
const surface = screen.getByRole('region', { name: '表格' });
|
|
10
|
+
expect(surface).toHaveClass('rhine-surface', 'host-layout');
|
|
11
|
+
expect(surface).not.toHaveClass('rhine-surface--padding-none');
|
|
12
|
+
rerender(<Surface role="region" aria-label="表格" className="host-layout" padding="none">内容</Surface>);
|
|
13
|
+
expect(surface).toHaveClass('rhine-surface--padding-none', 'host-layout');
|
|
14
|
+
expect(surface).not.toHaveAttribute('padding');
|
|
15
|
+
rerender(<Surface role="region" aria-label="表格" padding="default">内容</Surface>);
|
|
16
|
+
expect(surface).not.toHaveClass('rhine-surface--padding-none');
|
|
17
|
+
});
|
|
7
18
|
it('uses the independent library identity and accepts a host wordmark', () => {
|
|
8
19
|
const { rerender } = render(<Logo variant="lockup" />);
|
|
9
20
|
expect(screen.getByRole('img', { name: 'Rhine Design' })).toHaveTextContent('Rhine Design');
|