@linhey/rhine-design 0.6.0 → 0.9.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 +39 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AdvancedForms.tsx +38 -8
- package/components/AppShell.tsx +46 -17
- package/components/ListFilterBar.tsx +104 -0
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +11 -3
- package/components/Overlays.tsx +37 -8
- package/components/README.md +28 -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 +12 -5
- package/components/list-filter-bar.css +33 -0
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +100 -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/BACKGROUND_FILTER_VERIFICATION.md +12 -0
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +11 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +10 -2
- package/docs/LIST_FILTER_BAR.md +73 -0
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +61 -44
- 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/BackgroundFiltersFixture.tsx +28 -0
- 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/ListFilterBarGallery.tsx +170 -0
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +49 -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 +19 -3
- package/preview/preview.css +23 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-background-filters.js +58 -0
- package/scripts/check-background-motion.js +169 -0
- package/scripts/check-filter-native-date.js +47 -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/DateNativeInput.test.tsx +147 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/ListFilterBar.test.tsx +198 -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 +273 -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,147 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Button, DateField, DateRangePicker } from '../components';
|
|
5
|
+
|
|
6
|
+
// JSDOM cannot edit native calendar fragments; browser coverage verifies the real keystrokes.
|
|
7
|
+
function nativeInput(input: HTMLElement, value: string, badInput: boolean) {
|
|
8
|
+
Object.defineProperty(input, 'validity', { configurable: true, value: { badInput } });
|
|
9
|
+
fireEvent.input(input, { target: { value } });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('native date editing', () => {
|
|
13
|
+
it('does not display a valid proposal that the controlled host rejected', () => {
|
|
14
|
+
const change = vi.fn();
|
|
15
|
+
render(<DateField label="日期" value="2026-10-20" onValueChange={change} />);
|
|
16
|
+
const input = screen.getByLabelText('日期');
|
|
17
|
+
nativeInput(input, '2026-10-21', false);
|
|
18
|
+
expect(change).toHaveBeenCalledExactlyOnceWith('2026-10-21');
|
|
19
|
+
expect(input).toHaveValue('2026-10-20');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('does not report an incomplete native date as an intentionally empty boundary', () => {
|
|
23
|
+
const change = vi.fn(), validation = vi.fn();
|
|
24
|
+
const props = { label: '日期', value: '2026-10-20', onValueChange: change, onValidationChange: validation };
|
|
25
|
+
const { rerender } = render(<DateField {...props} />);
|
|
26
|
+
const input = screen.getByLabelText('日期');
|
|
27
|
+
nativeInput(input, '', true);
|
|
28
|
+
expect(change).not.toHaveBeenCalled();
|
|
29
|
+
expect(input).toHaveValue('');
|
|
30
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
31
|
+
expect(validation).toHaveBeenLastCalledWith('请填写完整有效的日期。');
|
|
32
|
+
rerender(<DateField {...props} hint="父级刷新了其他状态" />);
|
|
33
|
+
expect(input).toHaveValue('');
|
|
34
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
35
|
+
nativeInput(input, '2026-10-21', false);
|
|
36
|
+
expect(change.mock.calls).toEqual([['2026-10-21']]);
|
|
37
|
+
expect(input).toHaveValue('2026-10-20');
|
|
38
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('detects fragments from an initially empty field even when its string value does not change', () => {
|
|
42
|
+
const change = vi.fn(), validation = vi.fn();
|
|
43
|
+
render(<DateField label="日期" value="" onValueChange={change} onValidationChange={validation} />);
|
|
44
|
+
const input = screen.getByLabelText('日期');
|
|
45
|
+
nativeInput(input, '', true);
|
|
46
|
+
expect(change).not.toHaveBeenCalled();
|
|
47
|
+
expect(validation).toHaveBeenLastCalledWith('请填写完整有效的日期。');
|
|
48
|
+
nativeInput(input, '', false);
|
|
49
|
+
expect(change.mock.calls).toEqual([['']]);
|
|
50
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('observes calendar fragments that only emit keyup without submitting valid values twice', () => {
|
|
54
|
+
const change = vi.fn(), validation = vi.fn();
|
|
55
|
+
render(<DateField label="日期" value="" onValueChange={change} onValidationChange={validation} />);
|
|
56
|
+
const input = screen.getByLabelText('日期');
|
|
57
|
+
Object.defineProperty(input, 'validity', { configurable: true, value: { badInput: true } });
|
|
58
|
+
fireEvent.keyUp(input, { key: '2' });
|
|
59
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
60
|
+
expect(validation).toHaveBeenLastCalledWith('请填写完整有效的日期。');
|
|
61
|
+
expect(change).not.toHaveBeenCalled();
|
|
62
|
+
nativeInput(input, '2026-10-21', false);
|
|
63
|
+
fireEvent.keyUp(input, { key: '1' });
|
|
64
|
+
fireEvent.blur(input);
|
|
65
|
+
expect(change.mock.calls).toEqual([['2026-10-21']]);
|
|
66
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('recognizes intentionally erasing the last native fragment during blur', () => {
|
|
70
|
+
const change = vi.fn();
|
|
71
|
+
render(<DateField label="日期" value="2026-10-20" onValueChange={change} />);
|
|
72
|
+
const input = screen.getByLabelText('日期');
|
|
73
|
+
nativeInput(input, '', true);
|
|
74
|
+
Object.defineProperty(input, 'validity', { configurable: true, value: { badInput: false } });
|
|
75
|
+
fireEvent.blur(input);
|
|
76
|
+
expect(change.mock.calls).toEqual([['']]);
|
|
77
|
+
expect(input).not.toHaveAttribute('aria-invalid', 'true');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('synchronizes a genuinely changed external date and removes the native draft error', () => {
|
|
81
|
+
const validation = vi.fn(), change = vi.fn();
|
|
82
|
+
const props = { label: '日期', value: '2026-10-20', onValueChange: change, onValidationChange: validation };
|
|
83
|
+
const { rerender } = render(<DateField {...props} />);
|
|
84
|
+
const input = screen.getByLabelText('日期');
|
|
85
|
+
nativeInput(input, '', true);
|
|
86
|
+
rerender(<DateField {...props} value="2026-10-24" />);
|
|
87
|
+
expect(input).toHaveValue('2026-10-24');
|
|
88
|
+
expect(input).not.toHaveAttribute('aria-invalid', 'true');
|
|
89
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
90
|
+
expect(change).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not accept input events from disabled or read-only fields', () => {
|
|
94
|
+
const change = vi.fn();
|
|
95
|
+
const { rerender } = render(<DateField label="日期" value="2026-10-20" onValueChange={change} readOnly />);
|
|
96
|
+
nativeInput(screen.getByLabelText('日期'), '2026-10-21', false);
|
|
97
|
+
expect(change).not.toHaveBeenCalled();
|
|
98
|
+
rerender(<DateField label="日期" value="2026-10-20" onValueChange={change} disabled />);
|
|
99
|
+
nativeInput(screen.getByLabelText('日期'), '', true);
|
|
100
|
+
expect(change).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('aggregates incomplete child input without creating an error feedback loop', () => {
|
|
104
|
+
const change = vi.fn(), validation = vi.fn();
|
|
105
|
+
function Example() {
|
|
106
|
+
const [value, setValue] = useState({ start: '2026-10-20', end: '2026-10-24' });
|
|
107
|
+
const [error, setError] = useState<string | null>(null);
|
|
108
|
+
return <><DateRangePicker label="范围" value={value} onValueChange={next => { change(next); setValue(next); }} onValidationChange={next => { validation(next); setError(next); }} />
|
|
109
|
+
<Button disabled={!!error}>应用日期</Button></>;
|
|
110
|
+
}
|
|
111
|
+
render(<Example />);
|
|
112
|
+
nativeInput(screen.getByLabelText('开始日期'), '', true);
|
|
113
|
+
expect(change).not.toHaveBeenCalled();
|
|
114
|
+
expect(screen.getByRole('button', { name: '应用日期' })).toBeDisabled();
|
|
115
|
+
expect(screen.getByLabelText('结束日期')).not.toHaveAttribute('aria-invalid', 'true');
|
|
116
|
+
nativeInput(screen.getByLabelText('开始日期'), '2026-10-21', false);
|
|
117
|
+
expect(change.mock.calls).toEqual([[{ start: '2026-10-21', end: '2026-10-24' }]]);
|
|
118
|
+
expect(screen.getByRole('button', { name: '应用日期' })).toBeEnabled();
|
|
119
|
+
expect(validation.mock.calls).toEqual([[null], ['请填写完整有效的日期。'], [null]]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('clears an invalid native draft when the accepted range is already empty', () => {
|
|
123
|
+
const change = vi.fn(), validation = vi.fn();
|
|
124
|
+
render(<DateRangePicker label="范围" value={{ start: '', end: '' }} onValueChange={change} onValidationChange={validation} />);
|
|
125
|
+
const input = screen.getByLabelText('开始日期');
|
|
126
|
+
nativeInput(input, '', true);
|
|
127
|
+
const clear = screen.getByRole('button', { name: '清空日期' });
|
|
128
|
+
expect(clear).toBeEnabled();
|
|
129
|
+
fireEvent.click(clear);
|
|
130
|
+
expect(change.mock.calls).toEqual([[{ start: '', end: '' }]]);
|
|
131
|
+
expect(screen.getByLabelText('开始日期')).not.toBe(input);
|
|
132
|
+
expect(screen.getByLabelText('开始日期')).not.toHaveAttribute('aria-invalid', 'true');
|
|
133
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
134
|
+
expect(clear).toBeDisabled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('replaces incomplete drafts with a preset even when its accepted dates have not changed', () => {
|
|
138
|
+
const change = vi.fn(), validation = vi.fn(), value = { start: '2026-10-20', end: '2026-10-24' };
|
|
139
|
+
render(<DateRangePicker label="范围" value={value} onValueChange={change} onValidationChange={validation} presets={[{ label: '当前周', value }]} />);
|
|
140
|
+
nativeInput(screen.getByLabelText('结束日期'), '', true);
|
|
141
|
+
fireEvent.click(screen.getByRole('button', { name: '当前周' }));
|
|
142
|
+
expect(change).toHaveBeenCalledExactlyOnceWith(value);
|
|
143
|
+
expect(screen.getByLabelText('结束日期')).toHaveValue('2026-10-24');
|
|
144
|
+
expect(screen.getByLabelText('结束日期')).not.toHaveAttribute('aria-invalid', 'true');
|
|
145
|
+
expect(validation).toHaveBeenLastCalledWith(null);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { DateRangeFilterExample } from '../preview/DateRangeFilterGallery';
|
|
4
|
+
|
|
5
|
+
function demo(props = {}) {
|
|
6
|
+
render(<div className="rhine-theme"><DateRangeFilterExample {...props} /></div>);
|
|
7
|
+
}
|
|
8
|
+
const trigger = () => screen.getByRole('button', { name: /时间范围:.+选择日期/ });
|
|
9
|
+
async function open() {
|
|
10
|
+
trigger().focus(); fireEvent.click(trigger());
|
|
11
|
+
return screen.findByRole('dialog', { name: '时间范围设置' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('date range filtering composition', () => {
|
|
15
|
+
it('applies a quick preset immediately and always displays its dates', () => {
|
|
16
|
+
demo();
|
|
17
|
+
fireEvent.click(screen.getByRole('radio', { name: '昨天' }));
|
|
18
|
+
expect(screen.getByRole('radio', { name: '昨天' })).toBeChecked();
|
|
19
|
+
expect(trigger()).toHaveTextContent('2026-10-23 — 2026-10-23');
|
|
20
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 1 次');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps edits as drafts, discards cancellation and restores trigger focus', async () => {
|
|
24
|
+
demo(); const dialog = await open();
|
|
25
|
+
fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '2026-10-01' } });
|
|
26
|
+
expect(screen.getByRole('status')).toHaveTextContent('2026-10-24 — 2026-10-24 · 本次操作 0 次');
|
|
27
|
+
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
|
28
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
29
|
+
await waitFor(() => expect(trigger()).toHaveFocus());
|
|
30
|
+
const reopened = await open();
|
|
31
|
+
expect(within(reopened).getByLabelText('开始日期')).toHaveValue('2026-10-24');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not commit on Escape or the overlay close button', async () => {
|
|
35
|
+
demo();
|
|
36
|
+
for (const close of ['escape', 'button']) {
|
|
37
|
+
const dialog = await open();
|
|
38
|
+
fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '2026-10-02' } });
|
|
39
|
+
if (close === 'escape') fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
|
40
|
+
else fireEvent.click(within(dialog).getByRole('button', { name: '关闭详情' }));
|
|
41
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
42
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 0 次');
|
|
43
|
+
await waitFor(() => expect(trigger()).toHaveFocus());
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('blocks incomplete, reversed and out-of-range dates before applying one valid range', async () => {
|
|
48
|
+
demo(); const dialog = await open();
|
|
49
|
+
const start = within(dialog).getByLabelText('开始日期'), end = within(dialog).getByLabelText('结束日期');
|
|
50
|
+
const apply = within(dialog).getByRole('button', { name: '应用日期' });
|
|
51
|
+
for (const [from, to] of [['', '2026-10-24'], ['2026-10-24', ''], ['2026-10-25', '2026-10-24'], ['2024-12-31', '2026-10-24'], ['2026-10-24', '2027-01-01']]) {
|
|
52
|
+
fireEvent.change(start, { target: { value: from } });
|
|
53
|
+
fireEvent.change(end, { target: { value: to } });
|
|
54
|
+
expect(apply).toBeDisabled();
|
|
55
|
+
fireEvent.submit(apply.closest('form')!);
|
|
56
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 0 次');
|
|
57
|
+
}
|
|
58
|
+
fireEvent.change(start, { target: { value: '2025-12-28' } });
|
|
59
|
+
fireEvent.change(end, { target: { value: '2026-01-06' } });
|
|
60
|
+
await waitFor(() => expect(apply).toBeEnabled());
|
|
61
|
+
fireEvent.click(apply);
|
|
62
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
63
|
+
expect(screen.getByRole('status')).toHaveTextContent('2025-12-28 — 2026-01-06 · 本次操作 1 次');
|
|
64
|
+
expect(screen.getAllByRole('radio').every(radio => !(radio as HTMLInputElement).checked)).toBe(true);
|
|
65
|
+
await waitFor(() => expect(trigger()).toHaveFocus());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('applies additional presets immediately instead of submitting an unfinished draft', async () => {
|
|
69
|
+
demo(); const dialog = await open();
|
|
70
|
+
fireEvent.change(within(dialog).getByLabelText('开始日期'), { target: { value: '' } });
|
|
71
|
+
fireEvent.click(within(dialog).getByRole('button', { name: '最近 30 天' }));
|
|
72
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
73
|
+
expect(trigger()).toHaveTextContent('2026-09-25 — 2026-10-24');
|
|
74
|
+
expect(screen.getByRole('status')).toHaveTextContent('本次操作 1 次');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps a disabled range readable and prevents all entry points', () => {
|
|
78
|
+
demo({ disabled: true });
|
|
79
|
+
expect(trigger()).toBeDisabled();
|
|
80
|
+
expect(screen.getAllByRole('radio').every(radio => (radio as HTMLInputElement).disabled)).toBe(true);
|
|
81
|
+
fireEvent.click(trigger());
|
|
82
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
83
|
+
expect(screen.getByRole('status')).toHaveTextContent('2026-10-24 — 2026-10-24 · 本次操作 0 次');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Button, ListFilterBar, Select, TextField, type ListFilterField } from '../components';
|
|
5
|
+
|
|
6
|
+
const label = '记录筛选';
|
|
7
|
+
const selection = (id: string, fieldLabel: string): ListFilterField => ({
|
|
8
|
+
id, label: fieldLabel,
|
|
9
|
+
control: <Select label={fieldLabel} defaultValue="all"><option value="all">全部</option><option value="selected">选中项</option></Select>,
|
|
10
|
+
});
|
|
11
|
+
const theme = (children: React.ReactNode) => <div className="rhine-theme" data-rhine-theme="neo-brutalism" data-rhine-motion="reduced">{children}</div>;
|
|
12
|
+
|
|
13
|
+
async function openFilters() {
|
|
14
|
+
const trigger = screen.getByRole('button', { name: /^更多筛选/ });
|
|
15
|
+
trigger.focus();
|
|
16
|
+
fireEvent.click(trigger);
|
|
17
|
+
return { trigger, dialog: await screen.findByRole('dialog', { name: label }) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('ListFilterBar', () => {
|
|
21
|
+
it('keeps focus on the filter region when the last removable summary has no focusable field', () => {
|
|
22
|
+
function Example() {
|
|
23
|
+
const [active, setActive] = useState(true);
|
|
24
|
+
return <ListFilterBar label={label} primary={[{ id: 'fixed', label: '固定范围', control: <span>只读范围</span>, activeLabel: active ? '本周' : undefined, onClear: () => setActive(false) }]} />;
|
|
25
|
+
}
|
|
26
|
+
const { container } = render(<Example />);
|
|
27
|
+
const chip = screen.getByRole('button', { name: '清除固定范围:本周' });
|
|
28
|
+
chip.focus(); fireEvent.click(chip);
|
|
29
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
30
|
+
expect(container.querySelector('.rhine-list-filter-bar')).toHaveFocus();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('limits primary controls to two and keeps overflow in the same themed drawer', async () => {
|
|
34
|
+
render(theme(<ListFilterBar label={label} primary={[selection('state', '状态'), selection('sort', '排序'), selection('length', '长度')]} filters={[selection('owner', '人员')]} />));
|
|
35
|
+
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
|
36
|
+
expect(screen.queryByRole('combobox', { name: '长度' })).not.toBeInTheDocument();
|
|
37
|
+
const { dialog } = await openFilters();
|
|
38
|
+
expect(within(dialog).getAllByRole('combobox')).toHaveLength(2);
|
|
39
|
+
expect(within(dialog).getByRole('combobox', { name: '长度' })).toHaveValue('all');
|
|
40
|
+
expect(within(dialog).getByRole('combobox', { name: '人员' })).toHaveValue('all');
|
|
41
|
+
expect(dialog.closest('.rhine-theme')).toHaveAttribute('data-rhine-theme', 'neo-brutalism');
|
|
42
|
+
expect(dialog.closest('.rhine-theme')).toHaveAttribute('data-rhine-motion', 'reduced');
|
|
43
|
+
expect(document.querySelectorAll('select')).toHaveLength(4);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does not move or remount primary fields when the container changes width', () => {
|
|
47
|
+
const mounted = vi.fn(), unmounted = vi.fn();
|
|
48
|
+
function LocalField() {
|
|
49
|
+
useEffect(() => { mounted(); return unmounted; }, []);
|
|
50
|
+
return <TextField label="本地输入" defaultValue="保留草稿" />;
|
|
51
|
+
}
|
|
52
|
+
const field = { id: 'local', label: '本地输入', control: <LocalField /> };
|
|
53
|
+
const { rerender } = render(theme(<div style={{ width: 1000 }}><ListFilterBar label={label} primary={[field]} /></div>));
|
|
54
|
+
const input = screen.getByRole('textbox');
|
|
55
|
+
fireEvent.change(input, { target: { value: '尚未提交' } });
|
|
56
|
+
input.focus();
|
|
57
|
+
rerender(theme(<div style={{ width: 320 }}><ListFilterBar label={label} primary={[field]} /></div>));
|
|
58
|
+
fireEvent(window, new Event('resize'));
|
|
59
|
+
expect(screen.getByRole('textbox')).toBe(input);
|
|
60
|
+
expect(input).toHaveValue('尚未提交');
|
|
61
|
+
expect(input).toHaveFocus();
|
|
62
|
+
expect(mounted).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(unmounted).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('forwards controlled search and composition events without trimming or another query state', () => {
|
|
67
|
+
const changed = vi.fn(), start = vi.fn(), end = vi.fn();
|
|
68
|
+
function Example() {
|
|
69
|
+
const [query, setQuery] = useState('');
|
|
70
|
+
return <ListFilterBar label={label} search={{ label: '搜索记录', value: query, onChange: event => { changed(event.target.value); setQuery(event.target.value); }, onCompositionStart: start, onCompositionEnd: end, maxLength: 80, placeholder: '输入关键词' }} />;
|
|
71
|
+
}
|
|
72
|
+
render(theme(<Example />));
|
|
73
|
+
const input = screen.getByRole('searchbox', { name: '搜索记录' });
|
|
74
|
+
fireEvent.compositionStart(input);
|
|
75
|
+
fireEvent.change(input, { target: { value: ' 中文草稿 ' } });
|
|
76
|
+
expect(input).toHaveValue(' 中文草稿 ');
|
|
77
|
+
expect(changed).toHaveBeenCalledWith(' 中文草稿 ');
|
|
78
|
+
fireEvent.compositionEnd(input);
|
|
79
|
+
expect(start).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(end).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(input).toHaveAttribute('maxlength', '80');
|
|
82
|
+
expect(screen.queryByRole('button', { name: /^更多筛选/ })).not.toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('applies host changes immediately and closing the drawer does not undo them', async () => {
|
|
86
|
+
const changes = vi.fn(), reset = vi.fn();
|
|
87
|
+
function Example() {
|
|
88
|
+
const [value, setValue] = useState('all');
|
|
89
|
+
return <ListFilterBar label={label} onReset={reset} result={value === 'all' ? '12 条结果' : '3 条结果'} filters={[{
|
|
90
|
+
id: 'state', label: '状态', activeLabel: value === 'all' ? undefined : '选中项',
|
|
91
|
+
control: <Select label="状态" value={value} onChange={event => { setValue(event.target.value); changes(event.target.value); }}><option value="all">全部</option><option value="selected">选中项</option></Select>,
|
|
92
|
+
}]} />;
|
|
93
|
+
}
|
|
94
|
+
render(theme(<Example />));
|
|
95
|
+
const { dialog, trigger } = await openFilters();
|
|
96
|
+
fireEvent.change(within(dialog).getByRole('combobox', { name: '状态' }), { target: { value: 'selected' } });
|
|
97
|
+
expect(changes).toHaveBeenCalledWith('selected');
|
|
98
|
+
expect(within(dialog).getByRole('status')).toHaveTextContent('3 条结果');
|
|
99
|
+
expect(within(dialog).queryByRole('button', { name: /应用|取消/ })).not.toBeInTheDocument();
|
|
100
|
+
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
|
101
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
102
|
+
await waitFor(() => expect(trigger).toHaveFocus());
|
|
103
|
+
expect(reset).not.toHaveBeenCalled();
|
|
104
|
+
expect(screen.getByRole('status')).toHaveTextContent('3 条结果');
|
|
105
|
+
const reopened = await openFilters();
|
|
106
|
+
expect(within(reopened.dialog).getByRole('combobox', { name: '状态' })).toHaveValue('selected');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('only summarizes explicit active labels and offers reset for an active search', () => {
|
|
110
|
+
const reset = vi.fn(), field = selection('state', '状态');
|
|
111
|
+
const { rerender } = render(theme(<ListFilterBar label={label} primary={[field]} onReset={reset} />));
|
|
112
|
+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'selected' } });
|
|
113
|
+
expect(screen.queryByRole('group', { name: '已选条件' })).not.toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByRole('button', { name: '重置筛选' })).not.toBeInTheDocument();
|
|
115
|
+
rerender(theme(<ListFilterBar label={label} primary={[{ ...field, activeLabel: ' ' }]} onReset={reset} search={{ label: '搜索', value: '真实查询', readOnly: true }} />));
|
|
116
|
+
expect(screen.getByRole('button', { name: '重置筛选' })).toBeVisible();
|
|
117
|
+
expect(screen.queryByText('搜索:真实查询')).not.toBeInTheDocument();
|
|
118
|
+
fireEvent.click(screen.getByRole('button', { name: '重置筛选' }));
|
|
119
|
+
expect(reset).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('keeps full active labels available and returns focus to a field when its chip is cleared', () => {
|
|
123
|
+
const longLabel = '跨部门体验与可访问性协作组'.repeat(12);
|
|
124
|
+
function Example() {
|
|
125
|
+
const [active, setActive] = useState(true);
|
|
126
|
+
return <ListFilterBar label={label} primary={[{ ...selection('team', '团队'), activeLabel: active ? longLabel : undefined, onClear: () => setActive(false) }]} />;
|
|
127
|
+
}
|
|
128
|
+
render(theme(<Example />));
|
|
129
|
+
const chip = screen.getByRole('button', { name: `清除团队:${longLabel}` });
|
|
130
|
+
expect(chip).toHaveTextContent(longLabel);
|
|
131
|
+
chip.focus();
|
|
132
|
+
fireEvent.click(chip);
|
|
133
|
+
expect(screen.queryByRole('button', { name: /^清除团队/ })).not.toBeInTheDocument();
|
|
134
|
+
expect(screen.getByRole('combobox', { name: '团队' })).toHaveFocus();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('shows non-clearable summaries without inventing an action', () => {
|
|
138
|
+
render(theme(<ListFilterBar label={label} primary={[{ ...selection('scope', '范围'), activeLabel: '团队范围' }]} />));
|
|
139
|
+
expect(screen.getByText('范围:团队范围')).toBeVisible();
|
|
140
|
+
expect(screen.queryByRole('button', { name: /^清除/ })).not.toBeInTheDocument();
|
|
141
|
+
expect(screen.queryByRole('button', { name: '重置筛选' })).not.toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('shows pending, accepted results and failures inside the modal without disabling correction', async () => {
|
|
145
|
+
const props = { label, filters: [selection('state', '状态')], result: '上次结果 12 条' };
|
|
146
|
+
const { rerender } = render(theme(<ListFilterBar {...props} pending />));
|
|
147
|
+
const { dialog } = await openFilters();
|
|
148
|
+
expect(within(dialog).getByRole('status')).toHaveTextContent('正在更新结果…');
|
|
149
|
+
expect(within(dialog).getByRole('status')).toHaveTextContent('上次结果 12 条');
|
|
150
|
+
expect(within(dialog).getByRole('combobox')).toBeEnabled();
|
|
151
|
+
rerender(theme(<ListFilterBar {...props} error="连接暂不可用,请重试。" />));
|
|
152
|
+
expect(within(dialog).getByRole('alert')).toHaveTextContent('连接暂不可用,请重试。');
|
|
153
|
+
expect(within(dialog).queryByText('正在更新结果…')).not.toBeInTheDocument();
|
|
154
|
+
expect(within(dialog).getByRole('combobox')).toBeEnabled();
|
|
155
|
+
rerender(theme(<ListFilterBar {...props} result="8 条结果" />));
|
|
156
|
+
expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument();
|
|
157
|
+
expect(within(dialog).getByRole('status')).toHaveTextContent('8 条结果');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('lets host reset query and all fields without closing the drawer or losing focus', async () => {
|
|
161
|
+
function Example() {
|
|
162
|
+
const [query, setQuery] = useState('关键词'), [state, setState] = useState('selected');
|
|
163
|
+
return <ListFilterBar label={label} search={{ label: '搜索', value: query, onChange: event => setQuery(event.target.value) }}
|
|
164
|
+
onReset={() => { setQuery(''); setState('all'); }}
|
|
165
|
+
filters={[{ id: 'state', label: '状态', activeLabel: state === 'all' ? undefined : '选中项', control: <Select label="状态" value={state} onChange={event => setState(event.target.value)}><option value="all">全部</option><option value="selected">选中项</option></Select> }]} />;
|
|
166
|
+
}
|
|
167
|
+
render(theme(<Example />));
|
|
168
|
+
const { dialog } = await openFilters();
|
|
169
|
+
const reset = within(dialog).getByRole('button', { name: '重置筛选' });
|
|
170
|
+
reset.focus();
|
|
171
|
+
fireEvent.click(reset);
|
|
172
|
+
expect(within(dialog).getByRole('combobox')).toHaveValue('all');
|
|
173
|
+
expect(within(dialog).getByRole('combobox')).toHaveFocus();
|
|
174
|
+
expect(within(dialog).queryByRole('button', { name: '重置筛选' })).not.toBeInTheDocument();
|
|
175
|
+
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
|
176
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
177
|
+
expect(screen.getByRole('searchbox')).toHaveValue('');
|
|
178
|
+
expect(screen.queryByRole('button', { name: '重置筛选' })).not.toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('preserves refresh busy focus and disables it independently of the one business action', () => {
|
|
182
|
+
const refresh = vi.fn(), action = vi.fn();
|
|
183
|
+
const props = { label, action: <Button onClick={action}>审查设置</Button> };
|
|
184
|
+
const { rerender } = render(theme(<ListFilterBar {...props} refresh={{ onClick: refresh }} />));
|
|
185
|
+
const button = screen.getByRole('button', { name: '刷新' });
|
|
186
|
+
button.focus();
|
|
187
|
+
rerender(theme(<ListFilterBar {...props} refresh={{ onClick: refresh, busy: true }} />));
|
|
188
|
+
fireEvent.click(button);
|
|
189
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
190
|
+
expect(button).toHaveFocus();
|
|
191
|
+
expect(button).toHaveAttribute('aria-busy', 'true');
|
|
192
|
+
fireEvent.click(screen.getByRole('button', { name: '审查设置' }));
|
|
193
|
+
expect(action).toHaveBeenCalledTimes(1);
|
|
194
|
+
rerender(theme(<ListFilterBar {...props} refresh={{ onClick: refresh, disabled: true }} />));
|
|
195
|
+
expect(button).toBeDisabled();
|
|
196
|
+
expect(screen.getByRole('button', { name: '审查设置' })).toBeEnabled();
|
|
197
|
+
});
|
|
198
|
+
});
|