Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/major-split-product-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tiny-design/react': patch
'@tiny-design/tokens': patch
---

Redesign the Split component with a product-grade pane model, refreshed separator styling, updated tokens, and rewritten docs/demos.

This file was deleted.

280 changes: 273 additions & 7 deletions packages/react/src/split/__tests__/split.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,281 @@
import React from 'react';
import { render } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import Split from '../index';

const rect = ({ width = 400, height = 300 }: { width?: number; height?: number } = {}) => ({
width,
height,
top: 0,
left: 0,
right: width,
bottom: height,
x: 0,
y: 0,
toJSON: () => ({}),
});

describe('<Split />', () => {
it('should match the snapshot', () => {
const { asFragment } = render(<Split>Content</Split>);
expect(asFragment()).toMatchSnapshot();
let rectSpy: jest.SpyInstance;

beforeEach(() => {
rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function mockRect() {
const element = this as HTMLElement;
if (element.className.includes('ty-split')) {
return rect();
}
return rect({ width: 160, height: 120 });
});
});

afterEach(() => {
rectSpy.mockRestore();
jest.restoreAllMocks();
});

it('renders horizontal layout with pane API by default', async () => {
const { container, getByRole } = render(
<Split defaultSize="32%">
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

await waitFor(() => expect(getByRole('separator')).toHaveAttribute('aria-valuenow', '128'));
expect(container.firstChild).toHaveClass('ty-split', 'ty-split_horizontal');
expect(getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical');
});

it('should render correctly', () => {
const { container } = render(<Split>Content</Split>);
expect(container.firstChild).toHaveClass('ty-split');
it('supports controlled primary pane size', async () => {
const { getByRole, rerender } = render(
<Split size="25%">
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

await waitFor(() => expect(getByRole('separator')).toHaveAttribute('aria-valuenow', '100'));

rerender(
<Split size={180}>
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

await waitFor(() => expect(getByRole('separator')).toHaveAttribute('aria-valuenow', '180'));
});

it('supports primary second pane sizing and constraints', async () => {
const { getByRole } = render(
<Split primary="second" defaultSize="25%" min="80px" max="200px">
<Split.Pane min="160px">
<div>Canvas</div>
</Split.Pane>
<Split.Pane>
<div>Inspector</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '100'));

fireEvent.keyDown(separator, { key: 'End' });

await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '200'));
});

it('resizes with pointer dragging and emits lifecycle callbacks', async () => {
const onResizeStart = jest.fn();
const onResize = jest.fn();
const onResizeEnd = jest.fn();
const { getByRole } = render(
<Split defaultSize={120} onResizeStart={onResizeStart} onResize={onResize} onResizeEnd={onResizeEnd}>
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane min="120px">
<div>Right</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));

fireEvent.mouseDown(separator, { clientX: 120 });
fireEvent.mouseMove(window, { clientX: 220 });
fireEvent.mouseUp(window);

await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '220'));
expect(onResizeStart).toHaveBeenCalledWith(120);
expect(onResize).toHaveBeenCalledWith(220);
expect(onResizeEnd).toHaveBeenCalledWith(220);
});

it('supports vertical keyboard resizing', async () => {
const { getByRole } = render(
<Split orientation="vertical" defaultSize="40%">
<Split.Pane>
<div>Top</div>
</Split.Pane>
<Split.Pane>
<div>Bottom</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));

fireEvent.keyDown(separator, { key: 'ArrowDown' });

await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '132'));
expect(separator).toHaveAttribute('aria-orientation', 'horizontal');
});

it('supports collapsible primary pane from keyboard and double click', async () => {
const onCollapseChange = jest.fn();
const { getByRole } = render(
<Split defaultSize={160} collapsible collapsedSize={64} onCollapseChange={onCollapseChange}>
<Split.Pane>
<div>Sidebar</div>
</Split.Pane>
<Split.Pane min="160px">
<div>Content</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '160'));

fireEvent.keyDown(separator, { key: 'Enter' });
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '64'));

fireEvent.doubleClick(separator);
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '160'));

expect(onCollapseChange).toHaveBeenNthCalledWith(1, true);
expect(onCollapseChange).toHaveBeenNthCalledWith(2, false);
});

it('does not resize when disabled', async () => {
const onResize = jest.fn();
const { getByRole } = render(
<Split disabled defaultSize={140} onResize={onResize}>
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '140'));

fireEvent.mouseDown(separator, { clientX: 140 });
fireEvent.mouseMove(window, { clientX: 220 });
fireEvent.mouseUp(window);
fireEvent.keyDown(separator, { key: 'ArrowRight' });

expect(separator).toHaveAttribute('aria-valuenow', '140');
expect(onResize).not.toHaveBeenCalled();
expect(separator).toHaveAttribute('tabindex', '-1');
});

it('warns and skips separator when children count is invalid', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { queryByRole } = render(
<Split>{[<Split.Pane key="only">Only</Split.Pane>] as unknown as [React.ReactNode, React.ReactNode]}</Split>
);

expect(warnSpy).toHaveBeenCalledWith('Warning: Split expects exactly two children.');
expect(queryByRole('separator')).not.toBeInTheDocument();
});

it('supports custom separator content', async () => {
const separatorRender = jest.fn(({ dragging, collapsed }: { dragging: boolean; collapsed: boolean }) => (
<span data-testid="custom-separator">
{collapsed ? 'collapsed' : dragging ? 'dragging' : 'idle'}
</span>
));
const { getByRole, getByTestId } = render(
<Split defaultSize={120} collapsible collapsedSize={60} separatorRender={separatorRender}>
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(getByTestId('custom-separator')).toHaveTextContent('idle'));

fireEvent.mouseDown(separator, { clientX: 120 });
await waitFor(() => expect(getByTestId('custom-separator')).toHaveTextContent('dragging'));
fireEvent.mouseUp(window);

fireEvent.keyDown(separator, { key: 'Enter' });
await waitFor(() => expect(getByTestId('custom-separator')).toHaveTextContent('collapsed'));
expect(separatorRender).toHaveBeenCalled();
});

it('keeps separator layout size independent from the hit area size', async () => {
const { getByRole } = render(
<Split defaultSize={120} separatorSize={2} separatorHitAreaSize={24}>
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));

expect(separator.parentElement).toHaveStyle({ width: '2px' });
expect(separator).toHaveStyle({ width: '24px' });
expect(separator).toHaveStyle('--ty-split-bar-hit-area-size: 24px');
});

it('applies separatorClassName and separatorStyle to the interaction container', async () => {
const { getByRole } = render(
<Split
defaultSize={120}
separatorClassName="split-separator-test"
separatorStyle={{ background: 'rgba(59, 130, 246, 0.24)' }}>
<Split.Pane>
<div>Left</div>
</Split.Pane>
<Split.Pane>
<div>Right</div>
</Split.Pane>
</Split>
);

const separator = getByRole('separator');
await waitFor(() => expect(separator).toHaveAttribute('aria-valuenow', '120'));

expect(separator).toHaveClass('split-separator-test');
expect(separator).toHaveStyle({ background: 'rgba(59, 130, 246, 0.24)' });
});
});
24 changes: 24 additions & 0 deletions packages/react/src/split/demo/Collapse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { Split } from '@tiny-design/react';

export default function CollapseDemo() {
return (
<Split
defaultSize={240}
min="128px"
collapsible
collapsedSize={72}
style={{
height: 220,
border: '1px solid var(--ty-color-border-secondary)',
background: 'var(--ty-color-fill)',
}}>
<Split.Pane style={{ padding: 16, background: 'var(--ty-color-bg-container)' }}>
Collapsible sidebar
</Split.Pane>
<Split.Pane min="160px" style={{ padding: 16 }}>
Press Enter on the separator or double-click it to collapse.
</Split.Pane>
</Split>
);
}
Loading
Loading