feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6052aa81fb
commit
3e86a8d5c9
5 changed files with 218 additions and 5 deletions
85
__tests__/components/backlog/backlog-split-pane.test.tsx
Normal file
85
__tests__/components/backlog/backlog-split-pane.test.tsx
Normal file
|
|
@ -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 = [
|
||||
<div key="a">PBI pane</div>,
|
||||
<div key="b">Stories pane</div>,
|
||||
<div key="c">Tasks pane</div>,
|
||||
]
|
||||
|
||||
function renderPane() {
|
||||
return render(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
defaultSplit={[33, 33, 34]}
|
||||
cookieKey="test-backlog"
|
||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
defaultSplit={[33, 33, 34]}
|
||||
cookieKey="test-backlog"
|
||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
defaultSplit={[33, 33, 34]}
|
||||
cookieKey="test-backlog"
|
||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<BacklogSplitPane
|
||||
panes={PANES}
|
||||
defaultSplit={[33, 33, 34]}
|
||||
cookieKey="test-backlog"
|
||||
tabLabels={["PBI's", 'Stories', 'Taken']}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Stories pane')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
<SplitPane
|
||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||
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(
|
||||
<SplitPane
|
||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||
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(
|
||||
<SplitPane
|
||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||
defaultSplit={[33, 33, 34]}
|
||||
cookieKey="test-controlled"
|
||||
tabLabels={['T1', 'T2', 'T3']}
|
||||
activeTab={0}
|
||||
onActiveTabChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('A')).toBeTruthy()
|
||||
|
||||
rerender(
|
||||
<SplitPane
|
||||
panes={[<div key="a">A</div>, <div key="b">B</div>, <div key="c">C</div>]}
|
||||
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'))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
<SplitPane
|
||||
<BacklogSplitPane
|
||||
cookieKey={`backlog-${id}`}
|
||||
defaultSplit={[20, 45, 35]}
|
||||
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
||||
|
|
|
|||
34
components/backlog/backlog-split-pane.tsx
Normal file
34
components/backlog/backlog-split-pane.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane'
|
||||
|
||||
type Props = Omit<SplitPaneProps, 'activeTab' | 'onActiveTabChange'>
|
||||
|
||||
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 (
|
||||
<SplitPane
|
||||
{...props}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={setActiveTab}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(null)
|
||||
const splitsRef = useRef<number[]>(defaultSplit)
|
||||
|
|
@ -58,7 +63,13 @@ export function SplitPane({
|
|||
})
|
||||
const [dragging, setDragging] = useState<number | null>(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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex border-b border-border shrink-0">
|
||||
<div className="flex items-center border-b border-border shrink-0">
|
||||
{activeTab > 0 && (
|
||||
<button
|
||||
onClick={() => handleTabChange(activeTab - 1)}
|
||||
className="px-3 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label="Terug"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
{panes.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveTab(i)}
|
||||
onClick={() => handleTabChange(i)}
|
||||
className={cn(
|
||||
'flex-1 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === i
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue