Skip to content

Commit ecb2fb7

Browse files
frankbriaTest User
andauthored
feat(web-ui): SplitPane layout component — draggable, collapsible, persistent (#507)
- Draggable divider updates pane widths in real-time via direct DOM mutation during mousemove (no setState jank), flush to state on mouseup - Collapse/expand buttons on each pane edge (ArrowLeft01Icon/ArrowRight01Icon) - Split position persists to localStorage; restored on mount - minPanePercent enforced (default 15%) during drag - Smooth CSS transition on collapse/expand, disabled during drag - Mobile (<768px): vertical stack layout, divider hidden - 15 tests covering all acceptance criteria - Adds ArrowLeft01Icon to @hugeicons/react mock Co-authored-by: Test User <test@example.com>
1 parent c73024b commit ecb2fb7

3 files changed

Lines changed: 466 additions & 0 deletions

File tree

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,6 @@ module.exports = {
5858
// AgentChatPanel
5959
ArrowRight01Icon: createIconMock('ArrowRight01Icon'),
6060
Alert01Icon: createIconMock('Alert01Icon'),
61+
// SplitPane
62+
ArrowLeft01Icon: createIconMock('ArrowLeft01Icon'),
6163
};
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { render, screen, fireEvent, act } from '@testing-library/react';
2+
import { SplitPane } from '@/components/sessions/SplitPane';
3+
4+
// ── localStorage mock ────────────────────────────────────────────────────
5+
6+
const localStorageMock = (() => {
7+
let store: Record<string, string> = {};
8+
return {
9+
getItem: (key: string) => store[key] ?? null,
10+
setItem: (key: string, value: string) => {
11+
store[key] = value;
12+
},
13+
removeItem: (key: string) => {
14+
delete store[key];
15+
},
16+
clear: () => {
17+
store = {};
18+
},
19+
};
20+
})();
21+
22+
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
23+
24+
// ── matchMedia mock ──────────────────────────────────────────────────────
25+
26+
function mockMatchMedia(matches: boolean) {
27+
Object.defineProperty(window, 'matchMedia', {
28+
writable: true,
29+
value: jest.fn().mockImplementation((query: string) => ({
30+
matches,
31+
media: query,
32+
onchange: null,
33+
addListener: jest.fn(),
34+
removeListener: jest.fn(),
35+
addEventListener: jest.fn(),
36+
removeEventListener: jest.fn(),
37+
dispatchEvent: jest.fn(),
38+
})),
39+
});
40+
}
41+
42+
// ── getBoundingClientRect mock ───────────────────────────────────────────
43+
44+
function mockContainerRect(left = 0, width = 1000) {
45+
jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
46+
left,
47+
width,
48+
top: 0,
49+
right: left + width,
50+
bottom: 100,
51+
height: 100,
52+
x: left,
53+
y: 0,
54+
toJSON: () => ({}),
55+
});
56+
}
57+
58+
// ── Tests ────────────────────────────────────────────────────────────────
59+
60+
beforeEach(() => {
61+
localStorageMock.clear();
62+
mockMatchMedia(true); // desktop by default
63+
});
64+
65+
afterEach(() => {
66+
jest.restoreAllMocks();
67+
});
68+
69+
describe('SplitPane', () => {
70+
it('renders left and right children', () => {
71+
render(<SplitPane left={<div>Left content</div>} right={<div>Right content</div>} />);
72+
expect(screen.getByText('Left content')).toBeInTheDocument();
73+
expect(screen.getByText('Right content')).toBeInTheDocument();
74+
});
75+
76+
it('uses defaultSplit=45 when no localStorage value exists', () => {
77+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} defaultSplit={45} />);
78+
const leftPane = screen.getByTestId('split-pane-left');
79+
expect(leftPane).toHaveStyle({ width: '45%' });
80+
});
81+
82+
it('restores split position from localStorage on mount', () => {
83+
localStorageMock.setItem('split-pane-position', '60');
84+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} />);
85+
const leftPane = screen.getByTestId('split-pane-left');
86+
expect(leftPane).toHaveStyle({ width: '60%' });
87+
});
88+
89+
it('uses custom storageKey for localStorage', () => {
90+
localStorageMock.setItem('my-custom-key', '70');
91+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} storageKey="my-custom-key" />);
92+
const leftPane = screen.getByTestId('split-pane-left');
93+
expect(leftPane).toHaveStyle({ width: '70%' });
94+
});
95+
96+
it('persists position to localStorage on drag end', () => {
97+
mockContainerRect(0, 1000);
98+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} storageKey="test-key" />);
99+
const divider = screen.getByTestId('split-pane-divider');
100+
101+
fireEvent.mouseDown(divider);
102+
fireEvent.mouseMove(document, { clientX: 600 });
103+
fireEvent.mouseUp(document);
104+
105+
expect(localStorageMock.getItem('test-key')).toBe('60');
106+
});
107+
108+
it('enforces minPanePercent during drag (default 15%)', () => {
109+
mockContainerRect(0, 1000);
110+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} storageKey="test-key" />);
111+
const divider = screen.getByTestId('split-pane-divider');
112+
113+
fireEvent.mouseDown(divider);
114+
fireEvent.mouseMove(document, { clientX: 50 }); // 5% — below min
115+
fireEvent.mouseUp(document);
116+
117+
// Should be clamped to 15%
118+
expect(localStorageMock.getItem('test-key')).toBe('15');
119+
});
120+
121+
it('enforces minPanePercent on the right side during drag', () => {
122+
mockContainerRect(0, 1000);
123+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} storageKey="test-key" />);
124+
const divider = screen.getByTestId('split-pane-divider');
125+
126+
fireEvent.mouseDown(divider);
127+
fireEvent.mouseMove(document, { clientX: 980 }); // 98% — right pane below min
128+
fireEvent.mouseUp(document);
129+
130+
// Should be clamped to 85% (100 - 15)
131+
expect(localStorageMock.getItem('test-key')).toBe('85');
132+
});
133+
134+
it('collapses left pane when left collapse button is clicked', () => {
135+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} />);
136+
const collapseLeft = screen.getByTestId('collapse-left');
137+
fireEvent.click(collapseLeft);
138+
const leftPane = screen.getByTestId('split-pane-left');
139+
expect(leftPane).toHaveStyle({ width: '0%' });
140+
});
141+
142+
it('expands left pane when collapse button is clicked again', () => {
143+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} defaultSplit={45} />);
144+
const collapseLeft = screen.getByTestId('collapse-left');
145+
fireEvent.click(collapseLeft); // collapse
146+
fireEvent.click(collapseLeft); // expand
147+
const leftPane = screen.getByTestId('split-pane-left');
148+
expect(leftPane).toHaveStyle({ width: '45%' });
149+
});
150+
151+
it('collapses right pane when right collapse button is clicked', () => {
152+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} />);
153+
const collapseRight = screen.getByTestId('collapse-right');
154+
fireEvent.click(collapseRight);
155+
const rightPane = screen.getByTestId('split-pane-right');
156+
expect(rightPane).toHaveStyle({ width: '0%' });
157+
});
158+
159+
it('expands right pane when collapse button is clicked again', () => {
160+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} defaultSplit={45} />);
161+
const collapseRight = screen.getByTestId('collapse-right');
162+
fireEvent.click(collapseRight); // collapse
163+
fireEvent.click(collapseRight); // expand
164+
const rightPane = screen.getByTestId('split-pane-right');
165+
expect(rightPane).toHaveStyle({ width: '55%' });
166+
});
167+
168+
it('does not apply inline width styles on mobile', () => {
169+
mockMatchMedia(false); // mobile
170+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} />);
171+
const container = screen.getByTestId('split-pane-container');
172+
expect(container.className).toContain('flex-col');
173+
});
174+
175+
it('hides divider on mobile', () => {
176+
mockMatchMedia(false);
177+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} />);
178+
const divider = screen.getByTestId('split-pane-divider');
179+
expect(divider.className).toContain('hidden');
180+
});
181+
182+
it('applies custom className to outer container', () => {
183+
render(
184+
<SplitPane left={<div>L</div>} right={<div>R</div>} className="my-custom-class" />,
185+
);
186+
const container = screen.getByTestId('split-pane-container');
187+
expect(container.className).toContain('my-custom-class');
188+
});
189+
190+
it('does not move divider if mouse was not pressed (no drag started)', () => {
191+
localStorageMock.setItem('split-pane-position', '45');
192+
mockContainerRect(0, 1000);
193+
render(<SplitPane left={<div>L</div>} right={<div>R</div>} storageKey="split-pane-position" />);
194+
195+
// Move without mousedown
196+
fireEvent.mouseMove(document, { clientX: 700 });
197+
fireEvent.mouseUp(document);
198+
199+
expect(localStorageMock.getItem('split-pane-position')).toBe('45');
200+
});
201+
});

0 commit comments

Comments
 (0)