From 0e34f67be5b4b8bf1fdb135f4eb8ef27258b1535 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 30 Apr 2026 17:07:02 +0200 Subject: [PATCH] feat(split-pane): refactor to generic n-pane SplitPane with cookie persistence New API: panes[], defaultSplit[], cookieKey, tabLabels. Supports arbitrary number of panes with n-1 draggable dividers and JSON cookie persistence. Replaces TriplePane; mobile renders tabs. Co-Authored-By: Claude Sonnet 4.6 --- components/split-pane/split-pane.tsx | 185 +++++++++++++++----------- components/split-pane/triple-pane.tsx | 137 ------------------- 2 files changed, 107 insertions(+), 215 deletions(-) delete mode 100644 components/split-pane/triple-pane.tsx diff --git a/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx index 58cc8d9..1df7940 100644 --- a/components/split-pane/split-pane.tsx +++ b/components/split-pane/split-pane.tsx @@ -1,70 +1,106 @@ 'use client' -import { useRef, useState, useEffect, useCallback } from 'react' +import { Fragment, useRef, useState, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' -const COOKIE_PREFIX = 'split-pane:' +const COOKIE_PREFIX = 'sp:' const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 -function readSplitCookie(key: string): number | null { +function readSplits(cookieKey: string, n: number): number[] | null { if (typeof document === 'undefined') return null - const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_PREFIX}${key}=([^;]+)`)) + const match = document.cookie.match( + new RegExp(`(?:^|; )${COOKIE_PREFIX}${cookieKey}=([^;]+)`) + ) if (!match) return null - const val = parseFloat(decodeURIComponent(match[1])) - return !isNaN(val) && val > 0 && val < 100 ? val : null + try { + const parsed: unknown = JSON.parse(decodeURIComponent(match[1])) + if ( + !Array.isArray(parsed) || + parsed.length !== n || + parsed.some((v) => typeof v !== 'number') || + Math.abs((parsed as number[]).reduce((a, b) => a + b, 0) - 100) > 1 + ) return null + return parsed as number[] + } catch { + return null + } } -function writeSplitCookie(key: string, value: number) { - document.cookie = `${COOKIE_PREFIX}${key}=${value}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` +function writeSplits(cookieKey: string, splits: number[]) { + document.cookie = `${COOKIE_PREFIX}${cookieKey}=${encodeURIComponent( + JSON.stringify(splits) + )}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` } -interface SplitPaneProps { - left: React.ReactNode - right: React.ReactNode - storageKey: string - defaultSplit?: number // percentage for left pane - minSize?: number // minimum px per pane, default 200 +export interface SplitPaneProps { + panes: React.ReactNode[] + defaultSplit: number[] // length n, values sum to 100 + cookieKey: string + tabLabels?: string[] // mobile tab labels, defaults to "Pane N" + minSize?: number // minimum px per pane, default 200 + mobileBreakpoint?: number // default 1024 } export function SplitPane({ - left, - right, - storageKey, - defaultSplit = 20, + panes, + defaultSplit, + cookieKey, + tabLabels, minSize = 200, + mobileBreakpoint = 1024, }: SplitPaneProps) { + const n = panes.length const containerRef = useRef(null) - const [split, setSplit] = useState(() => { - return readSplitCookie(storageKey) ?? defaultSplit - }) - const [isDragging, setIsDragging] = useState(false) - const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState<'left' | 'right'>('left') + const splitsRef = useRef(defaultSplit) + + const [splits, setSplits] = useState(() => { + return readSplits(cookieKey, n) ?? defaultSplit + }) + const [dragging, setDragging] = useState(null) // divider index (0..n-2) + const [isMobile, setIsMobile] = useState(false) + const [activeTab, setActiveTab] = useState(0) + + useEffect(() => { splitsRef.current = splits }, [splits]) - // Detect mobile useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 1024) + const check = () => setIsMobile(window.innerWidth < mobileBreakpoint) check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) - }, []) + }, [mobileBreakpoint]) const onMouseMove = useCallback((e: MouseEvent) => { - if (!isDragging || !containerRef.current) return + if (dragging === null || !containerRef.current) return const rect = containerRef.current.getBoundingClientRect() const containerWidth = rect.width - const offsetX = e.clientX - rect.left const minPct = (minSize / containerWidth) * 100 - const maxPct = 100 - minPct - const newSplit = Math.min(maxPct, Math.max(minPct, (offsetX / containerWidth) * 100)) - setSplit(newSplit) - writeSplitCookie(storageKey, newSplit) - }, [isDragging, minSize, storageKey]) - const onMouseUp = useCallback(() => setIsDragging(false), []) + const cursorPct = ((e.clientX - rect.left) / containerWidth) * 100 + const current = splitsRef.current + // Left edge of pane[dragging] in percentage + const leftEdge = current.slice(0, dragging).reduce((a, b) => a + b, 0) + const combinedWidth = current[dragging] + current[dragging + 1] + + const newLeft = Math.min(Math.max(cursorPct - leftEdge, minPct), combinedWidth - minPct) + const newRight = combinedWidth - newLeft + + setSplits((prev) => { + const next = [...prev] + next[dragging] = newLeft + next[dragging + 1] = newRight + return next + }) + }, [dragging, minSize]) + + const onMouseUp = useCallback(() => { + if (dragging !== null) { + writeSplits(cookieKey, splitsRef.current) + setDragging(null) + } + }, [dragging, cookieKey]) useEffect(() => { - if (isDragging) { + if (dragging !== null) { window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) } @@ -72,37 +108,29 @@ export function SplitPane({ window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) } - }, [isDragging, onMouseMove, onMouseUp]) + }, [dragging, onMouseMove, onMouseUp]) if (isMobile) { return (
- - + {panes.map((_, i) => ( + + ))}
- {activeTab === 'left' ? left : right} + {panes[activeTab]}
) @@ -110,24 +138,25 @@ export function SplitPane({ return (
- {/* Left pane */} -
- {left} -
- - {/* Divider */} -
setIsDragging(true)} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - isDragging && 'bg-primary' - )} - /> - - {/* Right pane */} -
- {right} -
+ {panes.map((pane, i) => ( + + {i > 0 && ( +
setDragging(i - 1)} + className={cn( + 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', + dragging === i - 1 && 'bg-primary' + )} + /> + )} +
+ {pane} +
+ + ))}
) } diff --git a/components/split-pane/triple-pane.tsx b/components/split-pane/triple-pane.tsx deleted file mode 100644 index ee4a728..0000000 --- a/components/split-pane/triple-pane.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client' - -import { useRef, useState, useEffect, useCallback } from 'react' -import { cn } from '@/lib/utils' - -interface TriplePaneProps { - left: React.ReactNode - middle: React.ReactNode - right: React.ReactNode - storageKey: string - defaultLeft?: number // % width for left pane - defaultMiddle?: number // % width for middle pane, right gets the rest - minSize?: number // minimum px per pane -} - -export function TriplePane({ - left, middle, right, storageKey, - defaultLeft = 28, defaultMiddle = 35, minSize = 180, -}: TriplePaneProps) { - const containerRef = useRef(null) - - const load = (key: string, def: number) => { - if (typeof window === 'undefined') return def - const stored = localStorage.getItem(`triple-pane:${storageKey}:${key}`) - if (stored) { - const val = parseFloat(stored) - if (!isNaN(val) && val > 0 && val < 100) return val - } - return def - } - - const [leftPct, setLeftPct] = useState(() => load('left', defaultLeft)) - const [midPct, setMidPct] = useState(() => load('mid', defaultMiddle)) - const [dragging, setDragging] = useState<'left' | 'right' | null>(null) - const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState<'left' | 'middle' | 'right'>('left') - - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 1024) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - const onMouseMove = useCallback((e: MouseEvent) => { - if (!dragging || !containerRef.current) return - const rect = containerRef.current.getBoundingClientRect() - const width = rect.width - const minPct = (minSize / width) * 100 - const offsetPct = ((e.clientX - rect.left) / width) * 100 - - if (dragging === 'left') { - const clamped = Math.min(Math.max(offsetPct, minPct), 100 - midPct - minPct) - setLeftPct(clamped) - localStorage.setItem(`triple-pane:${storageKey}:left`, String(clamped)) - } else { - const clamped = Math.min(Math.max(offsetPct - leftPct, minPct), 100 - leftPct - minPct) - setMidPct(clamped) - localStorage.setItem(`triple-pane:${storageKey}:mid`, String(clamped)) - } - }, [dragging, leftPct, midPct, minSize, storageKey]) - - const onMouseUp = useCallback(() => setDragging(null), []) - - useEffect(() => { - if (dragging) { - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - } - return () => { - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - } - }, [dragging, onMouseMove, onMouseUp]) - - if (isMobile) { - const tabs = ['left', 'middle', 'right'] as const - const labels = ['Backlog', 'Sprint', 'Taken'] - return ( -
-
- {tabs.map((tab, i) => ( - - ))} -
-
- {activeTab === 'left' ? left : activeTab === 'middle' ? middle : right} -
-
- ) - } - - const rightPct = 100 - leftPct - midPct - - return ( -
-
- {left} -
- -
setDragging('left')} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - dragging === 'left' && 'bg-primary' - )} - /> - -
- {middle} -
- -
setDragging('right')} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - dragging === 'right' && 'bg-primary' - )} - /> - -
- {right} -
-
- ) -}