Skip to content

Commit 619e69c

Browse files
committed
chore: clean up
1 parent 65895e2 commit 619e69c

21 files changed

Lines changed: 595 additions & 40 deletions

File tree

packages/react/src/auto-complete/__tests__/autocomplete.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,22 @@ describe('<AutoComplete />', () => {
195195
expect(items[0]).toHaveTextContent('Cherry');
196196
});
197197

198+
it('should render empty content as a listbox option when no results match', () => {
199+
const { container } = render(
200+
<AutoComplete options={options} notFoundContent="No data" />
201+
);
202+
const input = container.querySelector('input')!;
203+
fireEvent.change(input, { target: { value: 'zzz' } });
204+
205+
const listbox = document.querySelector('.ty-auto-complete__dropdown');
206+
const emptyItem = document.querySelector('.ty-auto-complete__empty');
207+
208+
expect(listbox?.querySelector('li.ty-auto-complete__empty')).toBeInTheDocument();
209+
expect(emptyItem).toHaveAttribute('role', 'option');
210+
expect(emptyItem).toHaveAttribute('aria-disabled', 'true');
211+
expect(emptyItem).toHaveTextContent('No data');
212+
});
213+
198214
it('should not select disabled option', () => {
199215
const onSelect = jest.fn();
200216
const disabledOptions = [

packages/react/src/auto-complete/auto-complete.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,11 @@ const AutoComplete = React.forwardRef<HTMLDivElement, AutoCompleteProps>(
113113
const renderDropdown = () => {
114114
if (combo.filteredItems.length === 0) {
115115
if (notFoundContent) {
116-
return <div className={`${prefixCls}__empty`}>{notFoundContent}</div>;
116+
return (
117+
<li className={`${prefixCls}__empty`} role="option" aria-disabled="true">
118+
{notFoundContent}
119+
</li>
120+
);
117121
}
118122
return null;
119123
}

packages/react/src/cascader/__tests__/cascader.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,4 +256,17 @@ describe('<Cascader />', () => {
256256
expect(onDropdownVisibleChange).toHaveBeenCalledWith(false);
257257
});
258258
});
259+
260+
it('should keep the dropdown open in controlled mode when Escape requests closing', () => {
261+
const onDropdownVisibleChange = jest.fn();
262+
const { container } = render(
263+
<Cascader options={options} open onDropdownVisibleChange={onDropdownVisibleChange} />
264+
);
265+
266+
const selector = container.querySelector('.ty-cascader__selector') as HTMLElement;
267+
fireEvent.keyDown(selector, { key: 'Escape' });
268+
269+
expect(container.querySelector('.ty-cascader')).toHaveClass('ty-cascader_open');
270+
expect(onDropdownVisibleChange).toHaveBeenCalledWith(false);
271+
});
259272
});

packages/react/src/color-picker/__tests__/color-picker.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,27 @@ describe('<ColorPicker />', () => {
8181
});
8282
});
8383

84+
it('should move focus into the panel when opened and restore it on close', async () => {
85+
const trigger = document.createElement('button');
86+
trigger.textContent = 'outside-trigger';
87+
document.body.appendChild(trigger);
88+
trigger.focus();
89+
90+
const { container, rerender } = render(<ColorPicker open />);
91+
92+
await waitFor(() => {
93+
expect(document.body.querySelector('.ty-color-picker__format-btn')).toHaveFocus();
94+
});
95+
96+
rerender(<ColorPicker open={false} />);
97+
98+
await waitFor(() => {
99+
expect(trigger).toHaveFocus();
100+
});
101+
102+
document.body.removeChild(trigger);
103+
});
104+
84105
it('should render presets', () => {
85106
render(<ColorPicker open presets={['#ff0000', '#00ff00', '#0000ff']} />);
86107
const presets = document.body.querySelectorAll('.ty-color-picker__preset');

packages/react/src/color-picker/color-picker.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>((props, _
4444
const hueRef = useRef<HTMLDivElement>(null);
4545
const alphaRef = useRef<HTMLDivElement>(null);
4646
const wrapperRef = useRef<HTMLDivElement>(null);
47+
const previousFocusRef = useRef<HTMLElement | null>(null);
4748
const colorRef = useRef<Color>(color);
4849
const popupId = useId();
4950

@@ -76,6 +77,26 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>((props, _
7677
if ('open' in props) setOpen(props.open as boolean);
7778
}, [props.open]);
7879

80+
useEffect(() => {
81+
if (!isOpen) {
82+
previousFocusRef.current?.focus();
83+
return;
84+
}
85+
86+
previousFocusRef.current = document.activeElement as HTMLElement;
87+
const frameId = requestAnimationFrame(() => {
88+
const panel = document.getElementById(popupId);
89+
const focusable = panel?.querySelector<HTMLElement>(
90+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
91+
);
92+
focusable?.focus();
93+
});
94+
95+
return () => {
96+
cancelAnimationFrame(frameId);
97+
};
98+
}, [isOpen, popupId]);
99+
79100
const emitChange = useCallback(
80101
(c: Color, nextFormat: ColorFormat = format) => {
81102
const formatted = formatColor(c, nextFormat);

packages/react/src/drawer/__tests__/drawer.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,34 @@ describe('<Drawer />', () => {
7171
expect(document.getElementById(dialog.getAttribute('aria-labelledby') || '')).toHaveTextContent('Filters');
7272
expect(document.getElementById(dialog.getAttribute('aria-describedby') || '')).toHaveTextContent('Drawer content');
7373
});
74+
75+
it('should keep focus inside the drawer across rerenders while visible', async () => {
76+
const { rerender } = render(
77+
<div>
78+
<button>Trigger</button>
79+
<Drawer visible closable={false} size={280}>
80+
Content
81+
</Drawer>
82+
</div>
83+
);
84+
85+
const trigger = screen.getByText('Trigger');
86+
trigger.focus();
87+
88+
await waitFor(() => {
89+
expect(screen.getByRole('dialog')).toHaveFocus();
90+
});
91+
92+
rerender(
93+
<div>
94+
<button>Trigger</button>
95+
<Drawer visible closable={false} size={320}>
96+
Content
97+
</Drawer>
98+
</div>
99+
);
100+
101+
expect(screen.getByRole('dialog')).toHaveFocus();
102+
expect(trigger).not.toHaveFocus();
103+
});
74104
});

packages/react/src/drawer/drawer.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,20 @@ const Drawer = React.forwardRef<HTMLDivElement, DrawerProps>((props, ref) => {
3636
placement === 'top' || placement === 'bottom' ? { height: size } : { width: size };
3737
const nodeRef = useRef<HTMLDivElement>(null);
3838
const previousFocusRef = useRef<HTMLElement | null>(null);
39+
const onCloseRef = useRef(onClose);
3940
const titleId = useId();
4041
const bodyId = useId();
4142

43+
onCloseRef.current = onClose;
44+
4245
// Focus trap + Escape key
4346
useEffect(() => {
4447
if (!visible) return;
4548
previousFocusRef.current = document.activeElement as HTMLElement;
4649

4750
const handleKeyDown = (e: KeyboardEvent) => {
4851
if (keyboard && e.key === 'Escape') {
49-
onClose?.(e as unknown as React.MouseEvent);
52+
onCloseRef.current?.(e as unknown as React.MouseEvent);
5053
return;
5154
}
5255
if (e.key === 'Tab' && nodeRef.current) {
@@ -82,7 +85,7 @@ const Drawer = React.forwardRef<HTMLDivElement, DrawerProps>((props, ref) => {
8285
document.removeEventListener('keydown', handleKeyDown);
8386
previousFocusRef.current?.focus();
8487
};
85-
}, [keyboard, visible, onClose]);
88+
}, [keyboard, visible]);
8689

8790
return (
8891
<Overlay

packages/react/src/modal/__tests__/modal.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,36 @@ describe('<Modal />', () => {
108108
expect(document.getElementById(dialog.getAttribute('aria-describedby') || '')).toHaveTextContent('Modal content');
109109
});
110110

111+
it('should keep focus inside the modal across rerenders while visible', async () => {
112+
const { rerender } = render(
113+
<div>
114+
<button>Trigger</button>
115+
<Modal visible footer={null} closable={false} style={{ width: 320 }}>
116+
Content
117+
</Modal>
118+
</div>
119+
);
120+
121+
const trigger = screen.getByText('Trigger');
122+
trigger.focus();
123+
124+
await waitFor(() => {
125+
expect(screen.getByRole('dialog')).toHaveFocus();
126+
});
127+
128+
rerender(
129+
<div>
130+
<button>Trigger</button>
131+
<Modal visible footer={null} closable={false} style={{ width: 360 }}>
132+
Content
133+
</Modal>
134+
</div>
135+
);
136+
137+
expect(screen.getByRole('dialog')).toHaveFocus();
138+
expect(trigger).not.toHaveFocus();
139+
});
140+
111141
it('should render custom button text', () => {
112142
const { getByText } = render(<Modal visible confirmText="Yes" cancelText="No">Content</Modal>);
113143
expect(getByText('Yes')).toBeInTheDocument();

packages/react/src/modal/modal.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,28 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>((props, ref) => {
5050
const cls = classNames(prefixCls, className, { [`${prefixCls}_centered`]: centered });
5151
const nodeRef = useRef<HTMLDivElement>(null);
5252
const previousFocusRef = useRef<HTMLElement | null>(null);
53+
const onCloseRef = useRef(onCloseProp);
54+
const onCancelRef = useRef(onCancelProp);
5355
const titleId = useId();
5456
const bodyId = useId();
5557

58+
onCloseRef.current = onCloseProp;
59+
onCancelRef.current = onCancelProp;
60+
5661
const handleCancel = (e: React.MouseEvent): void => {
57-
if (onCancelProp) {
58-
onCancelProp(e);
62+
if (onCancelRef.current) {
63+
onCancelRef.current(e);
5964
return;
6065
}
61-
onCloseProp?.(e);
66+
onCloseRef.current?.(e);
6267
};
6368

6469
const handleClose = (e: React.MouseEvent): void => {
65-
if (onCloseProp) {
66-
onCloseProp(e);
70+
if (onCloseRef.current) {
71+
onCloseRef.current(e);
6772
return;
6873
}
69-
onCancelProp?.(e);
74+
onCancelRef.current?.(e);
7075
};
7176

7277
// Focus trap + Escape key
@@ -113,7 +118,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>((props, ref) => {
113118
document.removeEventListener('keydown', handleKeyDown);
114119
previousFocusRef.current?.focus();
115120
};
116-
}, [handleClose, keyboard, visible]);
121+
}, [keyboard, visible]);
117122

118123
const renderFooter = (): React.ReactNode => {
119124
if (React.isValidElement(footer)) {

packages/react/src/table/__tests__/table.test.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,120 @@ describe('<Table />', () => {
125125
expect(queryByText('Alice')).not.toBeInTheDocument();
126126
});
127127

128+
it('should preserve uncontrolled current page when pagination object rerenders without current', () => {
129+
const RerenderingTable = () => {
130+
const [version, setVersion] = React.useState(1);
131+
return (
132+
<>
133+
<Table
134+
columns={columns}
135+
dataSource={[
136+
...dataSource,
137+
{ key: '4', name: 'David', age: 22 },
138+
{ key: '5', name: 'Eve', age: 28 },
139+
]}
140+
pagination={{ pageSize: 2, showQuickJumper: version > 1 }}
141+
/>
142+
<button onClick={() => setVersion((current) => current + 1)}>rerender</button>
143+
</>
144+
);
145+
};
146+
147+
const { getByText, queryByText } = render(<RerenderingTable />);
148+
fireEvent.click(getByText('2'));
149+
150+
expect(getByText('Charlie')).toBeInTheDocument();
151+
expect(queryByText('Alice')).not.toBeInTheDocument();
152+
153+
fireEvent.click(getByText('rerender'));
154+
155+
expect(getByText('Charlie')).toBeInTheDocument();
156+
expect(queryByText('Alice')).not.toBeInTheDocument();
157+
});
158+
159+
it('should preserve active sort when columns rerender without changing the chosen sort', () => {
160+
const SortingTable = () => {
161+
const [wide, setWide] = React.useState(false);
162+
return (
163+
<>
164+
<Table
165+
columns={[
166+
{ title: 'Name', dataIndex: 'name', key: 'name', width: wide ? 180 : 160 },
167+
{ title: 'Age', dataIndex: 'age', key: 'age', sorter: (a: any, b: any) => a.age - b.age },
168+
]}
169+
dataSource={dataSource}
170+
pagination={false}
171+
/>
172+
<button onClick={() => setWide((current) => !current)}>rerender</button>
173+
</>
174+
);
175+
};
176+
177+
const { container, getByText } = render(<SortingTable />);
178+
const ageHeader = container.querySelector('.ty-table__cell_sortable');
179+
fireEvent.click(ageHeader!);
180+
181+
let rows = container.querySelectorAll('.ty-table__tbody .ty-table__row');
182+
expect(rows[0]).toHaveTextContent(/Bob/);
183+
184+
fireEvent.click(getByText('rerender'));
185+
186+
rows = container.querySelectorAll('.ty-table__tbody .ty-table__row');
187+
expect(rows[0]).toHaveTextContent(/Bob/);
188+
});
189+
190+
it('should prune uncontrolled selected keys when the data source removes those rows', () => {
191+
const DynamicSelectionTable = () => {
192+
const [rows, setRows] = React.useState(dataSource);
193+
return (
194+
<>
195+
<Table
196+
columns={columns}
197+
dataSource={rows}
198+
pagination={false}
199+
rowSelection={{}}
200+
/>
201+
<button onClick={() => setRows(dataSource.slice(1))}>shrink</button>
202+
</>
203+
);
204+
};
205+
206+
const { container, getByText } = render(<DynamicSelectionTable />);
207+
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
208+
fireEvent.click(checkboxes[1]);
209+
expect(checkboxes[1]).toBeChecked();
210+
211+
fireEvent.click(getByText('shrink'));
212+
213+
const nextCheckboxes = container.querySelectorAll('input[type="checkbox"]');
214+
expect(nextCheckboxes[1]).not.toBeChecked();
215+
});
216+
217+
it('should clamp uncontrolled current page when the data source shrinks', () => {
218+
const DynamicPaginationTable = () => {
219+
const [rows, setRows] = React.useState([
220+
...dataSource,
221+
{ key: '4', name: 'David', age: 22 },
222+
{ key: '5', name: 'Eve', age: 28 },
223+
]);
224+
return (
225+
<>
226+
<Table columns={columns} dataSource={rows} pagination={{ pageSize: 2 }} />
227+
<button onClick={() => setRows(dataSource.slice(0, 2))}>shrink</button>
228+
</>
229+
);
230+
};
231+
232+
const { getByText, queryByText } = render(<DynamicPaginationTable />);
233+
fireEvent.click(getByText('3'));
234+
expect(getByText('Eve')).toBeInTheDocument();
235+
236+
fireEvent.click(getByText('shrink'));
237+
238+
expect(getByText('Alice')).toBeInTheDocument();
239+
expect(queryByText('Eve')).not.toBeInTheDocument();
240+
});
241+
128242
it('should show loading state', () => {
129243
const { getByText } = render(<Table columns={columns} dataSource={dataSource} loading />);
130244
expect(getByText('Loading...')).toBeInTheDocument();

0 commit comments

Comments
 (0)