diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx
new file mode 100644
index 0000000..f57e53f
--- /dev/null
+++ b/__tests__/components/backlog/backlog-split-pane.test.tsx
@@ -0,0 +1,85 @@
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { useSelectionStore } from '@/stores/selection-store'
+import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
+
+const PANES = [
+
PBI pane
,
+ Stories pane
,
+ Tasks pane
,
+]
+
+function renderPane() {
+ return render(
+
+ )
+}
+
+beforeEach(() => {
+ useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null })
+ // Force mobile viewport
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
+ window.dispatchEvent(new Event('resize'))
+})
+
+describe('BacklogSplitPane auto-switch', () => {
+ it('starts on tab 0 with no selection', () => {
+ renderPane()
+ expect(screen.getByText('PBI pane')).toBeTruthy()
+ expect(screen.queryByText('Stories pane')).toBeNull()
+ })
+
+ it('auto-switches to tab 1 when PBI is selected', () => {
+ const { rerender } = renderPane()
+ useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null })
+ rerender(
+
+ )
+ expect(screen.getByText('Stories pane')).toBeTruthy()
+ expect(screen.queryByText('PBI pane')).toBeNull()
+ })
+
+ it('auto-switches to tab 2 when story is selected', () => {
+ const { rerender } = renderPane()
+ useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
+ rerender(
+
+ )
+ expect(screen.getByText('Tasks pane')).toBeTruthy()
+ expect(screen.queryByText('PBI pane')).toBeNull()
+ })
+
+ it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => {
+ // Start with story selected (tab 2)
+ useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' })
+ const { rerender } = renderPane()
+
+ // Cascade-reset: new PBI → story clears
+ useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null })
+ rerender(
+
+ )
+ expect(screen.getByText('Stories pane')).toBeTruthy()
+ })
+})
diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx
index 208304a..cd166c0 100644
--- a/__tests__/components/split-pane.test.tsx
+++ b/__tests__/components/split-pane.test.tsx
@@ -135,6 +135,80 @@ describe('SplitPane', () => {
expect(screen.getByText('Content B')).toBeTruthy()
})
+ it('back button not visible on tab 0 in mobile', () => {
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
+ window.dispatchEvent(new Event('resize'))
+
+ render(
+ A, B
, C
]}
+ defaultSplit={[33, 33, 34]}
+ cookieKey="test-back-hidden"
+ tabLabels={['T1', 'T2', 'T3']}
+ />
+ )
+
+ // On tab 0, no back button
+ expect(screen.queryByLabelText('Terug')).toBeNull()
+ })
+
+ it('back button visible on tab > 0 and navigates back', () => {
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
+ window.dispatchEvent(new Event('resize'))
+
+ render(
+ A, B
, C
]}
+ defaultSplit={[33, 33, 34]}
+ cookieKey="test-back-nav"
+ tabLabels={['T1', 'T2', 'T3']}
+ />
+ )
+
+ // Switch to tab 2
+ fireEvent.click(screen.getByText('T3'))
+ expect(screen.getByText('C')).toBeTruthy()
+ expect(screen.getByLabelText('Terug')).toBeTruthy()
+
+ // Click back → tab 1
+ fireEvent.click(screen.getByLabelText('Terug'))
+ expect(screen.getByText('B')).toBeTruthy()
+
+ // Click back again → tab 0, no back button
+ fireEvent.click(screen.getByLabelText('Terug'))
+ expect(screen.getByText('A')).toBeTruthy()
+ expect(screen.queryByLabelText('Terug')).toBeNull()
+ })
+
+ it('controlled activeTab prop switches the active pane', () => {
+ Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
+ window.dispatchEvent(new Event('resize'))
+
+ const { rerender } = render(
+ A, B
, C
]}
+ defaultSplit={[33, 33, 34]}
+ cookieKey="test-controlled"
+ tabLabels={['T1', 'T2', 'T3']}
+ activeTab={0}
+ onActiveTabChange={vi.fn()}
+ />
+ )
+ expect(screen.getByText('A')).toBeTruthy()
+
+ rerender(
+ A, B
, C
]}
+ defaultSplit={[33, 33, 34]}
+ cookieKey="test-controlled"
+ tabLabels={['T1', 'T2', 'T3']}
+ activeTab={2}
+ onActiveTabChange={vi.fn()}
+ />
+ )
+ expect(screen.getByText('C')).toBeTruthy()
+ })
+
it('does not render dividers on mobile', () => {
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 })
window.dispatchEvent(new Event('resize'))
diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx
index 33484ca..647d8c6 100644
--- a/app/(app)/products/[id]/page.tsx
+++ b/app/(app)/products/[id]/page.tsx
@@ -4,7 +4,7 @@ import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
-import { SplitPane } from '@/components/split-pane/split-pane'
+import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
import type { Story } from '@/components/backlog/story-panel'
@@ -130,7 +130,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
tasksByStory,
}}
>
-
+
+export function BacklogSplitPane(props: Props) {
+ const { selectedPbiId, selectedStoryId } = useSelectionStore()
+ const [activeTab, setActiveTab] = useState(0)
+
+ // React-recommended "derived state from props" pattern: update state during render
+ // instead of useEffect to avoid cascading renders.
+ const [prevPbiId, setPrevPbiId] = useState(selectedPbiId)
+ const [prevStoryId, setPrevStoryId] = useState(selectedStoryId)
+
+ if (selectedStoryId !== prevStoryId) {
+ setPrevStoryId(selectedStoryId)
+ if (selectedStoryId) setActiveTab(2)
+ }
+ if (selectedPbiId !== prevPbiId) {
+ setPrevPbiId(selectedPbiId)
+ if (selectedPbiId && !selectedStoryId) setActiveTab(1)
+ }
+
+ return (
+
+ )
+}
diff --git a/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx
index 1df7940..13dac6f 100644
--- a/components/split-pane/split-pane.tsx
+++ b/components/split-pane/split-pane.tsx
@@ -39,6 +39,8 @@ export interface SplitPaneProps {
tabLabels?: string[] // mobile tab labels, defaults to "Pane N"
minSize?: number // minimum px per pane, default 200
mobileBreakpoint?: number // default 1024
+ activeTab?: number // controlled: parent manages which tab is active
+ onActiveTabChange?: (index: number) => void
}
export function SplitPane({
@@ -48,7 +50,10 @@ export function SplitPane({
tabLabels,
minSize = 200,
mobileBreakpoint = 1024,
+ activeTab: activeTabProp,
+ onActiveTabChange,
}: SplitPaneProps) {
+ const isControlled = activeTabProp !== undefined
const n = panes.length
const containerRef = useRef(null)
const splitsRef = useRef(defaultSplit)
@@ -58,7 +63,13 @@ export function SplitPane({
})
const [dragging, setDragging] = useState(null) // divider index (0..n-2)
const [isMobile, setIsMobile] = useState(false)
- const [activeTab, setActiveTab] = useState(0)
+ const [internalTab, setInternalTab] = useState(0)
+ const activeTab = isControlled ? activeTabProp : internalTab
+
+ const handleTabChange = (i: number) => {
+ if (!isControlled) setInternalTab(i)
+ onActiveTabChange?.(i)
+ }
useEffect(() => { splitsRef.current = splits }, [splits])
@@ -113,11 +124,20 @@ export function SplitPane({
if (isMobile) {
return (
-
+
+ {activeTab > 0 && (
+
+ )}
{panes.map((_, i) => (