@linhey/rhine-design 0.8.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 +7 -0
- package/components/AdvancedForms.tsx +38 -8
- package/components/ListFilterBar.tsx +104 -0
- package/components/OrbitBackdrop.tsx +9 -3
- package/components/README.md +1 -0
- package/components/index.ts +1 -0
- package/components/list-filter-bar.css +33 -0
- package/components/orbit.css +21 -0
- package/docs/BACKGROUND_FILTER_VERIFICATION.md +12 -0
- package/docs/DESIGN_SYSTEM.md +4 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/LIST_FILTER_BAR.md +73 -0
- package/docs/ORBIT_BACKDROP.md +25 -0
- package/package.json +1 -1
- package/preview/BackgroundFiltersFixture.tsx +28 -0
- package/preview/GalleryShell.tsx +1 -1
- package/preview/ListFilterBarGallery.tsx +170 -0
- package/preview/OrbitGallery.tsx +14 -0
- package/preview/main.tsx +4 -1
- package/preview/preview.css +15 -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/tests/DateNativeInput.test.tsx +147 -0
- package/tests/ListFilterBar.test.tsx +198 -0
- package/tests/OrbitBackdrop.test.tsx +28 -1
|
@@ -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,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
|
+
});
|
|
@@ -158,6 +158,33 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
|
|
|
158
158
|
advance(100); expect(snapshot()).toEqual(before); expect(frames.size).toBe(1);
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
it('omits local controls without replacing the canvas, losing drafts or bypassing reduced motion', () => {
|
|
162
|
+
const view = (controls: boolean, composition: 'full' | 'corner', framed: boolean) => <OrbitBackdrop controls={controls} composition={composition} framed={framed}>
|
|
163
|
+
<input aria-label="背景草稿" defaultValue="起点" />
|
|
164
|
+
</OrbitBackdrop>;
|
|
165
|
+
const { container, rerender } = render(view(true, 'full', true));
|
|
166
|
+
const canvas = container.querySelector('canvas');
|
|
167
|
+
fireEvent.change(screen.getByLabelText('背景草稿'), { target: { value: '继续保留' } });
|
|
168
|
+
fireEvent.click(screen.getByRole('button', { name: '暂停动效' }));
|
|
169
|
+
rerender(view(false, 'corner', false));
|
|
170
|
+
expect(container.querySelector('.rhine-orbit-controls')).not.toBeInTheDocument();
|
|
171
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
172
|
+
expect(container.querySelector('canvas')).toBe(canvas);
|
|
173
|
+
expect(screen.getByLabelText('背景草稿')).toHaveValue('继续保留');
|
|
174
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-composition', 'corner');
|
|
175
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-framed', 'false');
|
|
176
|
+
expect(frames.size).toBe(0);
|
|
177
|
+
rerender(view(true, 'corner', false));
|
|
178
|
+
fireEvent.click(screen.getByRole('button', { name: '继续动效' }));
|
|
179
|
+
rerender(view(false, 'corner', false));
|
|
180
|
+
expect(frames.size).toBe(1);
|
|
181
|
+
act(() => reduced.set(true));
|
|
182
|
+
expect(frames.size).toBe(0);
|
|
183
|
+
expect(container.querySelector('.rhine-orbit')).toHaveAttribute('data-rhine-orbit-state', 'reduced');
|
|
184
|
+
act(() => reduced.set(false));
|
|
185
|
+
expect(frames.size).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
161
188
|
it('recolors a paused frame when its theme or appearance changes without remounting or losing content', async () => {
|
|
162
189
|
const view = (theme: string, appearance: 'theme' | 'original' = 'theme') => <div className="orbit-palette-fixture" data-rhine-theme={theme}>
|
|
163
190
|
<OrbitBackdrop appearance={appearance}><input aria-label="轨道草稿" defaultValue="起点" /></OrbitBackdrop>
|
|
@@ -412,7 +439,7 @@ describe('OrbitBackdrop artwork and lifecycle', () => {
|
|
|
412
439
|
await act(async () => { fireEvent.click(screen.getByRole('radio', { name: '跟随主题' })); });
|
|
413
440
|
expect(within(main).getByRole('button', { name: '继续动效' })).toBeEnabled();
|
|
414
441
|
const replay = screen.getByRole('button', { name: '重播轨道' }); replay.focus(); fireEvent.click(replay);
|
|
415
|
-
expect(container.querySelector('canvas')).not.toBe(canvas); expect(replay).toHaveFocus(); expect(frames.size).toBe(
|
|
442
|
+
expect(container.querySelector('canvas')).not.toBe(canvas); expect(replay).toHaveFocus(); expect(frames.size).toBe(4);
|
|
416
443
|
expect(screen.getByRole('textbox', { name: '直接叠加的输入框' })).toHaveValue('新的轨道名称');
|
|
417
444
|
expect(screen.getByRole('textbox', { name: '工作空间名称' })).toHaveValue('新的工作空间');
|
|
418
445
|
fireEvent.click(screen.getByRole('switch', { name: '减少轨道动态' })); expect(frames.size).toBe(0);
|