feat: add SplitPane composite for two-column list/detail layouts

Two-column grid layout with configurable ratio (1:1, 1:2, 2:3), list/detail
slots, and empty state message. Uses CSS custom property for dynamic grid
columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 12:20:53 +01:00
parent 22c098f9b6
commit 5fe7752b46
4 changed files with 145 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
.splitPane {
display: grid;
grid-template-columns: var(--split-columns, 1fr 2fr);
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 0;
height: 100%;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
overflow-y: auto;
}
.detailPane {
background: var(--bg-raised);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-faint);
font-size: 13px;
font-family: var(--font-body);
font-style: italic;
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'
describe('SplitPane', () => {
it('renders list and detail content', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={<div>Detail content</div>}
/>
)
expect(screen.getByText('List items')).toBeInTheDocument()
expect(screen.getByText('Detail content')).toBeInTheDocument()
})
it('shows default empty message when detail is null', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
/>
)
expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<SplitPane
list={<div>List items</div>}
detail={null}
emptyMessage="Pick something"
/>
)
expect(screen.getByText('Pick something')).toBeInTheDocument()
})
it('renders with different ratios (checks --split-columns CSS property)', () => {
const { container, rerender } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="1:1"
/>
)
const root = container.firstChild as HTMLElement
expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')
rerender(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
ratio="2:3"
/>
)
expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
})
it('accepts className', () => {
const { container } = render(
<SplitPane
list={<div>List</div>}
detail={<div>Detail</div>}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -0,0 +1,38 @@
import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'
interface SplitPaneProps {
list: ReactNode
detail: ReactNode | null
emptyMessage?: string
ratio?: '1:1' | '1:2' | '2:3'
className?: string
}
const ratioMap: Record<string, string> = {
'1:1': '1fr 1fr',
'1:2': '1fr 2fr',
'2:3': '2fr 3fr',
}
export function SplitPane({
list,
detail,
emptyMessage = 'Select an item to view details',
ratio = '1:2',
className,
}: SplitPaneProps) {
return (
<div
className={`${styles.splitPane} ${className ?? ''}`}
style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
>
<div className={styles.listPane}>{list}</div>
<div className={styles.detailPane}>
{detail !== null ? detail : (
<div className={styles.emptyDetail}>{emptyMessage}</div>
)}
</div>
</div>
)
}

View File

@@ -34,6 +34,7 @@ export { RouteFlow } from './RouteFlow/RouteFlow'
export type { RouteNode } from './RouteFlow/RouteFlow'
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
export { SplitPane } from './SplitPane/SplitPane'
export { Tabs } from './Tabs/Tabs'
export { ToastProvider, useToast } from './Toast/Toast'
export { TreeView } from './TreeView/TreeView'