From 5fe7752b4609403ae2b5171ca06ca90878310805 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:20:53 +0100 Subject: [PATCH] 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) --- .../composites/SplitPane/SplitPane.module.css | 37 ++++++++++ .../composites/SplitPane/SplitPane.test.tsx | 69 +++++++++++++++++++ .../composites/SplitPane/SplitPane.tsx | 38 ++++++++++ src/design-system/composites/index.ts | 1 + 4 files changed, 145 insertions(+) create mode 100644 src/design-system/composites/SplitPane/SplitPane.module.css create mode 100644 src/design-system/composites/SplitPane/SplitPane.test.tsx create mode 100644 src/design-system/composites/SplitPane/SplitPane.tsx diff --git a/src/design-system/composites/SplitPane/SplitPane.module.css b/src/design-system/composites/SplitPane/SplitPane.module.css new file mode 100644 index 0000000..ab0840b --- /dev/null +++ b/src/design-system/composites/SplitPane/SplitPane.module.css @@ -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; +} diff --git a/src/design-system/composites/SplitPane/SplitPane.test.tsx b/src/design-system/composites/SplitPane/SplitPane.test.tsx new file mode 100644 index 0000000..f285376 --- /dev/null +++ b/src/design-system/composites/SplitPane/SplitPane.test.tsx @@ -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( + List items} + detail={
Detail content
} + /> + ) + expect(screen.getByText('List items')).toBeInTheDocument() + expect(screen.getByText('Detail content')).toBeInTheDocument() + }) + + it('shows default empty message when detail is null', () => { + render( + List items} + detail={null} + /> + ) + expect(screen.getByText('Select an item to view details')).toBeInTheDocument() + }) + + it('shows custom empty message', () => { + render( + List items} + 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( + List} + detail={
Detail
} + ratio="1:1" + /> + ) + const root = container.firstChild as HTMLElement + expect(root.style.getPropertyValue('--split-columns')).toBe('1fr 1fr') + + rerender( + List} + detail={
Detail
} + ratio="2:3" + /> + ) + expect(root.style.getPropertyValue('--split-columns')).toBe('2fr 3fr') + }) + + it('accepts className', () => { + const { container } = render( + List} + detail={
Detail
} + className="custom-class" + /> + ) + expect(container.firstChild).toHaveClass('custom-class') + }) +}) diff --git a/src/design-system/composites/SplitPane/SplitPane.tsx b/src/design-system/composites/SplitPane/SplitPane.tsx new file mode 100644 index 0000000..bbb68e6 --- /dev/null +++ b/src/design-system/composites/SplitPane/SplitPane.tsx @@ -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 = { + '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 ( +
+
{list}
+
+ {detail !== null ? detail : ( +
{emptyMessage}
+ )} +
+
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 7fe8060..08890f2 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -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'