Skip to content

Commit d6adb83

Browse files
authored
feat(react): redesign split component (#116)
* feat(react): redesign split component * docs(react): make split demos theme-aware * chore: lint
1 parent 81e8260 commit d6adb83

22 files changed

Lines changed: 1691 additions & 460 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tiny-design/react': patch
3+
'@tiny-design/tokens': patch
4+
---
5+
6+
Redesign the Split component with a product-grade pane model, refreshed separator styling, updated tokens, and rewritten docs/demos.

packages/react/src/split/__tests__/__snapshots__/split.test.tsx.snap

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 273 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,281 @@
11
import React from 'react';
2-
import { render } from '@testing-library/react';
2+
import { fireEvent, render, waitFor } from '@testing-library/react';
33
import Split from '../index';
44

5+
const rect = ({ width = 400, height = 300 }: { width?: number; height?: number } = {}) => ({
6+
width,
7+
height,
8+
top: 0,
9+
left: 0,
10+
right: width,
11+
bottom: height,
12+
x: 0,
13+
y: 0,
14+
toJSON: () => ({}),
15+
});
16+
517
describe('<Split />', () => {
6-
it('should match the snapshot', () => {
7-
const { asFragment } = render(<Split>Content</Split>);
8-
expect(asFragment()).toMatchSnapshot();
18+
let rectSpy: jest.SpyInstance;
19+
20+
beforeEach(() => {
21+
rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect() {
22+
const element = this as HTMLElement;
23+
if (element.className.includes('ty-split')) {
24+
return rect();
25+
}
26+
return rect({ width: 160, height: 120 });
27+
});
28+
});
29+
30+
afterEach(() => {
31+
rectSpy.mockRestore();
32+
jest.restoreAllMocks();
33+
});
34+
35+
it('renders horizontal layout with pane API by default', async () => {
36+
const { container, getByRole } = render(
37+
<Split defaultSize="32%">
38+
<Split.Pane>
39+
<div>Left</div>
40+
</Split.Pane>
41+
<Split.Pane>
42+
<div>Right</div>
43+
</Split.Pane>
44+
</Split>
45+
);
46+
47+
await waitFor(() => expect(getByRole('separator')).toHaveAttribute('aria-valuenow', '128'));
48+
expect(container.firstChild).toHaveClass('ty-split', 'ty-split_horizontal');
49+
expect(getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical');
950
});
1051

11-
it('should render correctly', () => {
12-
const { container } = render(<Split>Content</Split>);
13-
expect(container.firstChild).toHaveClass('ty-split');
52+
it('supports controlled primary pane size', async () => {
53+
const { getByRole, rerender } = render(
54+
<Split size="25%">
55+
<Split.Pane>
56+
<div>Left</div>
57+
</Split.Pane>
58+
<Split.Pane>
59+
<div>Right</div>
60+
</Split.Pane>
61+
</Split>
62+
);
63+
64+
await waitFor(() => expect(getByRole('separator')).toHaveAttribute('aria-valuenow', '100'));
65+
66+
rerender(
67+
<Split size={180}>
68+
<Split.Pane>
69+
<div>Left</div>
70+
</Split.Pane>
71+
<Split.Pane>
72+
<div>Right</div>
73+
</Split.Pane>
74+
</Split>
75+
);
76+
77+
await waitFor(() => expect(getByRole('separator')).toHaveAttribute('aria-valuenow', '180'));
78+
});
79+
80+
it('supports primary second pane sizing and constraints', async () => {
81+
const { getByRole } = render(
82+
<Split primary="second" defaultSize="25%" min="80px" max="200px">
83+
<Split.Pane min="160px">
84+
<div>Canvas</div>
85+
</Split.Pane>
86+
<Split.Pane>
87+
<div>Inspector</div>
88+
</Split.Pane>
89+
</Split>
90+
);
91+
92+
const separator = getByRole('separator');
93+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '100'));
94+
95+
fireEvent.keyDown(separator, { key: 'End' });
96+
97+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '200'));
98+
});
99+
100+
it('resizes with pointer dragging and emits lifecycle callbacks', async () => {
101+
const onResizeStart = jest.fn();
102+
const onResize = jest.fn();
103+
const onResizeEnd = jest.fn();
104+
const { getByRole } = render(
105+
<Split defaultSize={120} onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd}>
106+
<Split.Pane>
107+
<div>Left</div>
108+
</Split.Pane>
109+
<Split.Pane min="120px">
110+
<div>Right</div>
111+
</Split.Pane>
112+
</Split>
113+
);
114+
115+
const separator = getByRole('separator');
116+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));
117+
118+
fireEvent.mouseDown(separator, { clientX: 120 });
119+
fireEvent.mouseMove(window, { clientX: 220 });
120+
fireEvent.mouseUp(window);
121+
122+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '220'));
123+
expect(onResizeStart).toHaveBeenCalledWith(120);
124+
expect(onResize).toHaveBeenCalledWith(220);
125+
expect(onResizeEnd).toHaveBeenCalledWith(220);
126+
});
127+
128+
it('supports vertical keyboard resizing', async () => {
129+
const { getByRole } = render(
130+
<Split orientation="vertical" defaultSize="40%">
131+
<Split.Pane>
132+
<div>Top</div>
133+
</Split.Pane>
134+
<Split.Pane>
135+
<div>Bottom</div>
136+
</Split.Pane>
137+
</Split>
138+
);
139+
140+
const separator = getByRole('separator');
141+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));
142+
143+
fireEvent.keyDown(separator, { key: 'ArrowDown' });
144+
145+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '132'));
146+
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
147+
});
148+
149+
it('supports collapsible primary pane from keyboard and double click', async () => {
150+
const onCollapseChange = jest.fn();
151+
const { getByRole } = render(
152+
<Split defaultSize={160} collapsible collapsedSize={64} onCollapseChange={onCollapseChange}>
153+
<Split.Pane>
154+
<div>Sidebar</div>
155+
</Split.Pane>
156+
<Split.Pane min="160px">
157+
<div>Content</div>
158+
</Split.Pane>
159+
</Split>
160+
);
161+
162+
const separator = getByRole('separator');
163+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '160'));
164+
165+
fireEvent.keyDown(separator, { key: 'Enter' });
166+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '64'));
167+
168+
fireEvent.doubleClick(separator);
169+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '160'));
170+
171+
expect(onCollapseChange).toHaveBeenNthCalledWith(1, true);
172+
expect(onCollapseChange).toHaveBeenNthCalledWith(2, false);
173+
});
174+
175+
it('does not resize when disabled', async () => {
176+
const onResize = jest.fn();
177+
const { getByRole } = render(
178+
<Split disabled defaultSize={140} onResize={onResize}>
179+
<Split.Pane>
180+
<div>Left</div>
181+
</Split.Pane>
182+
<Split.Pane>
183+
<div>Right</div>
184+
</Split.Pane>
185+
</Split>
186+
);
187+
188+
const separator = getByRole('separator');
189+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '140'));
190+
191+
fireEvent.mouseDown(separator, { clientX: 140 });
192+
fireEvent.mouseMove(window, { clientX: 220 });
193+
fireEvent.mouseUp(window);
194+
fireEvent.keyDown(separator, { key: 'ArrowRight' });
195+
196+
expect(separator).toHaveAttribute('aria-valuenow', '140');
197+
expect(onResize).not.toHaveBeenCalled();
198+
expect(separator).toHaveAttribute('tabindex', '-1');
199+
});
200+
201+
it('warns and skips separator when children count is invalid', () => {
202+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
203+
const { queryByRole } = render(
204+
<Split>{[<Split.Pane key="only">Only</Split.Pane>] as unknown as [React.ReactNode, React.ReactNode]}</Split>
205+
);
206+
207+
expect(warnSpy).toHaveBeenCalledWith('Warning: Split expects exactly two children.');
208+
expect(queryByRole('separator')).not.toBeInTheDocument();
209+
});
210+
211+
it('supports custom separator content', async () => {
212+
const separatorRender = jest.fn(({ dragging, collapsed }: { dragging: boolean; collapsed: boolean }) => (
213+
<span data-testid="custom-separator">
214+
{collapsed ? 'collapsed' : dragging ? 'dragging' : 'idle'}
215+
</span>
216+
));
217+
const { getByRole, getByTestId } = render(
218+
<Split defaultSize={120} collapsible collapsedSize={60} separatorRender={separatorRender}>
219+
<Split.Pane>
220+
<div>Left</div>
221+
</Split.Pane>
222+
<Split.Pane>
223+
<div>Right</div>
224+
</Split.Pane>
225+
</Split>
226+
);
227+
228+
const separator = getByRole('separator');
229+
await waitFor(() => expect(getByTestId('custom-separator')).toHaveTextContent('idle'));
230+
231+
fireEvent.mouseDown(separator, { clientX: 120 });
232+
await waitFor(() => expect(getByTestId('custom-separator')).toHaveTextContent('dragging'));
233+
fireEvent.mouseUp(window);
234+
235+
fireEvent.keyDown(separator, { key: 'Enter' });
236+
await waitFor(() => expect(getByTestId('custom-separator')).toHaveTextContent('collapsed'));
237+
expect(separatorRender).toHaveBeenCalled();
238+
});
239+
240+
it('keeps separator layout size independent from the hit area size', async () => {
241+
const { getByRole } = render(
242+
<Split defaultSize={120} separatorSize={2} separatorHitAreaSize={24}>
243+
<Split.Pane>
244+
<div>Left</div>
245+
</Split.Pane>
246+
<Split.Pane>
247+
<div>Right</div>
248+
</Split.Pane>
249+
</Split>
250+
);
251+
252+
const separator = getByRole('separator');
253+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));
254+
255+
expect(separator.parentElement).toHaveStyle({ width: '2px' });
256+
expect(separator).toHaveStyle({ width: '24px' });
257+
expect(separator).toHaveStyle('--ty-split-bar-hit-area-size: 24px');
258+
});
259+
260+
it('applies separatorClassName and separatorStyle to the interaction container', async () => {
261+
const { getByRole } = render(
262+
<Split
263+
defaultSize={120}
264+
separatorClassName="split-separator-test"
265+
separatorStyle={{ background: 'rgba(59, 130, 246, 0.24)' }}>
266+
<Split.Pane>
267+
<div>Left</div>
268+
</Split.Pane>
269+
<Split.Pane>
270+
<div>Right</div>
271+
</Split.Pane>
272+
</Split>
273+
);
274+
275+
const separator = getByRole('separator');
276+
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));
277+
278+
expect(separator).toHaveClass('split-separator-test');
279+
expect(separator).toHaveStyle({ background: 'rgba(59, 130, 246, 0.24)' });
14280
});
15281
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
import { Split } from '@tiny-design/react';
3+
4+
export default function CollapseDemo() {
5+
return (
6+
<Split
7+
defaultSize={240}
8+
min="128px"
9+
collapsible
10+
collapsedSize={72}
11+
style={{
12+
height: 220,
13+
border: '1px solid var(--ty-color-border-secondary)',
14+
background: 'var(--ty-color-fill)',
15+
}}>
16+
<Split.Pane style={{ padding: 16, background: 'var(--ty-color-bg-container)' }}>
17+
Collapsible sidebar
18+
</Split.Pane>
19+
<Split.Pane min="160px" style={{ padding: 16 }}>
20+
Press Enter on the separator or double-click it to collapse.
21+
</Split.Pane>
22+
</Split>
23+
);
24+
}

0 commit comments

Comments
 (0)