From 3980805ad9165967c1668da7ee1409ff15742f69 Mon Sep 17 00:00:00 2001 From: OBAZE SAMUEL OSHIOKE Date: Sun, 18 Oct 2026 19:22:52 +0100 Subject: [PATCH] feat(split-pane): draggable split-pane with persistence & mobile drawer (#1338)\n\n- Persist pane width to localStorage across page reloads\n- Add mobile hamburger drawer trigger (collapses to full-width stacked on <768px)\n- Touch-enabled drag for divider with requestAnimationFrame smoothing\n- CSS grid responsive breakpoints for mobile/desktop\n\nCloses #1338 --- .../ui/SplitScreenLayout.module.css | 65 +++++++++++++++ .../src/components/ui/SplitScreenLayout.tsx | 80 ++++++++++++++++--- 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/ui/SplitScreenLayout.module.css b/frontend/src/components/ui/SplitScreenLayout.module.css index 259519e98..f48905201 100644 --- a/frontend/src/components/ui/SplitScreenLayout.module.css +++ b/frontend/src/components/ui/SplitScreenLayout.module.css @@ -63,4 +63,69 @@ height: 12px; cursor: row-resize; } + + /* Mobile hamburger drawer trigger */ + .mobileDrawerToggle { + display: inline-flex; + flex-direction: column; + gap: 4px; + background: none; + border: none; + padding: 6px; + cursor: pointer; + z-index: 40; + } + + .hamburgerLine { + width: 20px; + height: 2px; + background-color: currentColor; + border-radius: 2px; + transition: transform 0.2s ease, opacity 0.2s ease; + } + + /* Mobile drawer overlay + panel */ + .mobileDrawerOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + z-index: 50; + display: flex; + align-items: flex-start; + } + + .mobileDrawerPanel { + position: relative; + width: 72vw; + max-width: 320px; + height: 100vh; + background: var(--color-bg-primary, #ffffff); + color: var(--color-text-primary, #000000); + border-right: 1px solid var(--color-border-medium, #d0d0d0); + transform: translateX(0); + transition: transform 0.3s ease; + z-index: 51; + overflow-y: auto; + padding: 16px; + } + + html.dark .mobileDrawerPanel, + html.oled .mobileDrawerPanel { + background: var(--color-bg-primary, #000000); + color: var(--color-text-primary, #ffffff); + border-right-color: var(--color-border-medium, #3d3d3d); + } + + .mobileDrawerClose { + position: absolute; + top: 12px; + right: 12px; + background: transparent; + border: none; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 4px; + } } diff --git a/frontend/src/components/ui/SplitScreenLayout.tsx b/frontend/src/components/ui/SplitScreenLayout.tsx index 17466d8e1..a2d6d7b75 100644 --- a/frontend/src/components/ui/SplitScreenLayout.tsx +++ b/frontend/src/components/ui/SplitScreenLayout.tsx @@ -8,6 +8,7 @@ interface SplitScreenLayoutProps { rightPanel: React.ReactNode; minWidthPercent?: number; initialWidthPercent?: number; + storageKey?: string; } export default function SplitScreenLayout({ @@ -15,9 +16,25 @@ export default function SplitScreenLayout({ rightPanel, minWidthPercent = 20, initialWidthPercent = 50, + storageKey = 'split-screen-layout', }: SplitScreenLayoutProps) { - const [leftWidth, setLeftWidth] = useState(initialWidthPercent); + // Persist pane sizing across page reloads (Acceptance Criteria: persists sizing across reloads) + const [leftWidth, setLeftWidth] = useState(() => { + if (typeof window === 'undefined') return initialWidthPercent; + try { + const stored = window.localStorage.getItem(storageKey); + if (stored) { + const parsed = parseFloat(stored); + if (!Number.isNaN(parsed)) return parsed; + } + } catch { + // localStorage may be unavailable; fall back to default + } + return initialWidthPercent; + }); + const [isDragging, setIsDragging] = useState(false); + const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); const containerRef = useRef(null); const animationFrameRef = useRef(null); @@ -34,7 +51,6 @@ export default function SplitScreenLayout({ const onDrag = useCallback((e: MouseEvent | TouchEvent) => { if (!isDragging || !containerRef.current) return; - // Throttle style calculations via requestAnimationFrame to ensure fluid 60fps movement if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } @@ -43,19 +59,22 @@ export default function SplitScreenLayout({ const containerRect = containerRef.current!.getBoundingClientRect(); const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; - // Calculate new percentage relative to viewport container width const currentX = clientX - containerRect.left; let newWidthPercent = (currentX / containerRect.width) * 100; - // Restrict boundaries to prevent panels from totally collapsing out of sight if (newWidthPercent < minWidthPercent) newWidthPercent = minWidthPercent; if (newWidthPercent > 100 - minWidthPercent) newWidthPercent = 100 - minWidthPercent; setLeftWidth(newWidthPercent); + // Persist to localStorage so sizing survives reloads + try { + window.localStorage.setItem(storageKey, String(newWidthPercent)); + } catch { + // ignore + } }); - }, [isDragging, minWidthPercent]); + }, [isDragging, minWidthPercent, storageKey]); - // Handle binding window-level events during drag operations so cursor doesn't lose tracking useEffect(() => { if (isDragging) { window.addEventListener('mousemove', onDrag); @@ -78,18 +97,53 @@ export default function SplitScreenLayout({ }; }, [isDragging, onDrag, stopDragging]); + // Close mobile drawer when resizing to a desktop breakpoint + useEffect(() => { + const mq = window.matchMedia('(min-width: 768px)'); + const handler = () => setIsMobileDrawerOpen(false); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + return (
- {/* Reader / Left Panel */} -
- {leftPanel} -
+ {/* Mobile sidebar hamburger drawer trigger (Easy sub-task) */} + {isMobile && ( + + )} + + {/* Mobile drawer panel */} + {isMobile && isMobileDrawerOpen && ( +
setIsMobileDrawerOpen(false)} + > +
e.stopPropagation()}> + + {leftPanel} +
+
+ )} {/* Horizontal Divider Bar */}