A tool for people curious about the React Server Components protocol rscexplorer.dev/
rsc react
35
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add resizable panes to workspace (#9)

* feat: add resizable panes to workspace

Added the ability to resize the 4 workspace panes (server, client, flight, preview) by dragging the dividers between them.

Features:

- Drag any edge to resize horizontally or vertically

- Drag the center intersection to resize both directions at once

- Double-click any handle to reset to 50/50 split

- Panes are constrained to 20-80% to prevent collapsing

- Includes keyboard accessibility (tab focus, proper ARIA roles)

* fix: add touch support for mobile devices

Added touch event handlers (touchstart, touchmove, touchend) so resizable panes work on mobile/tablet.

Also increased handle hit targets on touch devices using pointer: coarse media query.

* slightly simplify resizing

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Deepak Kumar
Dan Abramov
and committed by
GitHub
45fe38ce e18af292

+381 -87
+100
src/client/ui/ResizablePanes.css
··· 1 + /* ResizablePanes component styles */ 2 + 3 + .ResizablePanes { 4 + flex: 1; 5 + min-height: 0; 6 + display: grid; 7 + grid-template-columns: var(--col-percent) calc(100% - var(--col-percent)); 8 + grid-template-rows: var(--row-percent) calc(100% - var(--row-percent)); 9 + overflow: hidden; 10 + position: relative; 11 + } 12 + 13 + /* Pane containers */ 14 + .ResizablePanes-pane { 15 + display: flex; 16 + min-width: 0; 17 + min-height: 0; 18 + overflow: hidden; 19 + } 20 + 21 + .ResizablePanes-pane > * { 22 + flex: 1; 23 + min-width: 0; 24 + min-height: 0; 25 + } 26 + 27 + .ResizablePanes-topLeft { 28 + grid-column: 1; 29 + grid-row: 1; 30 + border-right: 1px solid var(--border); 31 + border-bottom: 1px solid var(--border); 32 + } 33 + 34 + .ResizablePanes-topRight { 35 + grid-column: 2; 36 + grid-row: 1; 37 + border-bottom: 1px solid var(--border); 38 + } 39 + 40 + .ResizablePanes-bottomLeft { 41 + grid-column: 1; 42 + grid-row: 2; 43 + border-right: 1px solid var(--border); 44 + } 45 + 46 + .ResizablePanes-bottomRight { 47 + grid-column: 2; 48 + grid-row: 2; 49 + } 50 + 51 + /* Dividers - continuous handles spanning full height/width */ 52 + .ResizablePanes-divider { 53 + position: absolute; 54 + z-index: 10; 55 + background: transparent; 56 + transition: background-color 0.15s ease; 57 + } 58 + 59 + .ResizablePanes-divider:hover, 60 + .ResizablePanes-divider:focus-visible { 61 + background: var(--border); 62 + } 63 + 64 + .ResizablePanes-divider:focus-visible { 65 + outline: 2px solid #ffd54f; 66 + outline-offset: -1px; 67 + } 68 + 69 + /* Vertical divider (full height) - controls column split */ 70 + .ResizablePanes-divider--vertical { 71 + top: 0; 72 + bottom: 0; 73 + left: var(--col-percent); 74 + width: 5px; 75 + margin-left: -2px; 76 + cursor: col-resize; 77 + } 78 + 79 + /* Horizontal divider (full width) - controls row split */ 80 + .ResizablePanes-divider--horizontal { 81 + left: 0; 82 + right: 0; 83 + top: var(--row-percent); 84 + height: 5px; 85 + margin-top: -2px; 86 + cursor: row-resize; 87 + } 88 + 89 + /* Touch device improvements - larger hit targets */ 90 + @media (pointer: coarse) { 91 + .ResizablePanes-divider--vertical { 92 + width: 16px; 93 + margin-left: -8px; 94 + } 95 + 96 + .ResizablePanes-divider--horizontal { 97 + height: 16px; 98 + margin-top: -8px; 99 + } 100 + }
+236
src/client/ui/ResizablePanes.tsx
··· 1 + import React, { 2 + useRef, 3 + useState, 4 + useCallback, 5 + useEffect, 6 + type ReactNode, 7 + type MouseEvent as ReactMouseEvent, 8 + type TouchEvent as ReactTouchEvent, 9 + type KeyboardEvent as ReactKeyboardEvent, 10 + } from "react"; 11 + import "./ResizablePanes.css"; 12 + 13 + type ResizablePanesProps = { 14 + topLeft: ReactNode; 15 + topRight: ReactNode; 16 + bottomLeft: ReactNode; 17 + bottomRight: ReactNode; 18 + }; 19 + 20 + // Constants for sizing constraints 21 + const MIN_SIZE_PERCENT = 20; 22 + const MAX_SIZE_PERCENT = 80; 23 + const KEYBOARD_STEP = 2; 24 + 25 + type DragAxis = "horizontal" | "vertical"; 26 + 27 + type DragState = { 28 + axis: DragAxis; 29 + startPos: number; 30 + startPercent: number; 31 + }; 32 + 33 + export function ResizablePanes({ 34 + topLeft, 35 + topRight, 36 + bottomLeft, 37 + bottomRight, 38 + }: ResizablePanesProps): React.ReactElement { 39 + // Column split: percentage of width for left column (0-100) 40 + const [colPercent, setColPercent] = useState(50); 41 + // Row split: percentage of height for top row (0-100) 42 + const [rowPercent, setRowPercent] = useState(50); 43 + 44 + const containerRef = useRef<HTMLDivElement>(null); 45 + const dragStateRef = useRef<DragState | null>(null); 46 + 47 + const clamp = (value: number): number => { 48 + return Math.min(MAX_SIZE_PERCENT, Math.max(MIN_SIZE_PERCENT, value)); 49 + }; 50 + 51 + // Start drag (mouse) 52 + const handleMouseDown = useCallback( 53 + (axis: DragAxis) => (e: ReactMouseEvent) => { 54 + e.preventDefault(); 55 + const startPos = axis === "horizontal" ? e.clientX : e.clientY; 56 + const startPercent = axis === "horizontal" ? colPercent : rowPercent; 57 + dragStateRef.current = { axis, startPos, startPercent }; 58 + document.body.style.cursor = axis === "horizontal" ? "col-resize" : "row-resize"; 59 + document.body.style.userSelect = "none"; 60 + }, 61 + [colPercent, rowPercent], 62 + ); 63 + 64 + // Start drag (touch) 65 + const handleTouchStart = useCallback( 66 + (axis: DragAxis) => (e: ReactTouchEvent) => { 67 + const touch = e.touches[0]; 68 + if (!touch) return; 69 + const startPos = axis === "horizontal" ? touch.clientX : touch.clientY; 70 + const startPercent = axis === "horizontal" ? colPercent : rowPercent; 71 + dragStateRef.current = { axis, startPos, startPercent }; 72 + }, 73 + [colPercent, rowPercent], 74 + ); 75 + 76 + // Keyboard handler for accessibility 77 + const handleKeyDown = useCallback( 78 + (axis: DragAxis) => (e: ReactKeyboardEvent) => { 79 + const setter = axis === "horizontal" ? setColPercent : setRowPercent; 80 + 81 + switch (e.key) { 82 + case "ArrowLeft": 83 + case "ArrowUp": 84 + e.preventDefault(); 85 + setter((prev) => clamp(prev - KEYBOARD_STEP)); 86 + break; 87 + case "ArrowRight": 88 + case "ArrowDown": 89 + e.preventDefault(); 90 + setter((prev) => clamp(prev + KEYBOARD_STEP)); 91 + break; 92 + case "Home": 93 + e.preventDefault(); 94 + setter(MIN_SIZE_PERCENT); 95 + break; 96 + case "End": 97 + e.preventDefault(); 98 + setter(MAX_SIZE_PERCENT); 99 + break; 100 + } 101 + }, 102 + [], 103 + ); 104 + 105 + useEffect(() => { 106 + const handleMouseMove = (e: MouseEvent): void => { 107 + const dragState = dragStateRef.current; 108 + if (!dragState || !containerRef.current) return; 109 + 110 + const rect = containerRef.current.getBoundingClientRect(); 111 + const currentPos = dragState.axis === "horizontal" ? e.clientX : e.clientY; 112 + const size = dragState.axis === "horizontal" ? rect.width : rect.height; 113 + const deltaPercent = ((currentPos - dragState.startPos) / size) * 100; 114 + const newPercent = clamp(dragState.startPercent + deltaPercent); 115 + 116 + if (dragState.axis === "horizontal") { 117 + setColPercent(newPercent); 118 + } else { 119 + setRowPercent(newPercent); 120 + } 121 + }; 122 + 123 + const handleTouchMove = (e: TouchEvent): void => { 124 + const dragState = dragStateRef.current; 125 + if (!dragState || !containerRef.current) return; 126 + 127 + const touch = e.touches[0]; 128 + if (!touch) return; 129 + 130 + e.preventDefault(); 131 + 132 + const rect = containerRef.current.getBoundingClientRect(); 133 + const currentPos = dragState.axis === "horizontal" ? touch.clientX : touch.clientY; 134 + const size = dragState.axis === "horizontal" ? rect.width : rect.height; 135 + const deltaPercent = ((currentPos - dragState.startPos) / size) * 100; 136 + const newPercent = clamp(dragState.startPercent + deltaPercent); 137 + 138 + if (dragState.axis === "horizontal") { 139 + setColPercent(newPercent); 140 + } else { 141 + setRowPercent(newPercent); 142 + } 143 + }; 144 + 145 + const handleDragEnd = (): void => { 146 + if (dragStateRef.current) { 147 + dragStateRef.current = null; 148 + document.body.style.cursor = ""; 149 + document.body.style.userSelect = ""; 150 + } 151 + }; 152 + 153 + document.addEventListener("mousemove", handleMouseMove); 154 + document.addEventListener("mouseup", handleDragEnd); 155 + document.addEventListener("touchmove", handleTouchMove, { passive: false }); 156 + document.addEventListener("touchend", handleDragEnd); 157 + document.addEventListener("touchcancel", handleDragEnd); 158 + 159 + return () => { 160 + document.removeEventListener("mousemove", handleMouseMove); 161 + document.removeEventListener("mouseup", handleDragEnd); 162 + document.removeEventListener("touchmove", handleTouchMove); 163 + document.removeEventListener("touchend", handleDragEnd); 164 + document.removeEventListener("touchcancel", handleDragEnd); 165 + }; 166 + }, []); 167 + 168 + // Reset to 50/50 on double-click 169 + const handleDoubleClick = useCallback( 170 + (axis: DragAxis) => () => { 171 + if (axis === "horizontal") { 172 + setColPercent(50); 173 + } else { 174 + setRowPercent(50); 175 + } 176 + }, 177 + [], 178 + ); 179 + 180 + return ( 181 + <div 182 + className="ResizablePanes" 183 + ref={containerRef} 184 + style={ 185 + { 186 + "--col-percent": `${colPercent}%`, 187 + "--row-percent": `${rowPercent}%`, 188 + } as React.CSSProperties 189 + } 190 + > 191 + {/* Top-left pane */} 192 + <div className="ResizablePanes-pane ResizablePanes-topLeft">{topLeft}</div> 193 + 194 + {/* Top-right pane */} 195 + <div className="ResizablePanes-pane ResizablePanes-topRight">{topRight}</div> 196 + 197 + {/* Bottom-left pane */} 198 + <div className="ResizablePanes-pane ResizablePanes-bottomLeft">{bottomLeft}</div> 199 + 200 + {/* Bottom-right pane */} 201 + <div className="ResizablePanes-pane ResizablePanes-bottomRight">{bottomRight}</div> 202 + 203 + {/* Vertical divider (full height) - controls column split */} 204 + <div 205 + className="ResizablePanes-divider ResizablePanes-divider--vertical" 206 + onMouseDown={handleMouseDown("horizontal")} 207 + onTouchStart={handleTouchStart("horizontal")} 208 + onDoubleClick={handleDoubleClick("horizontal")} 209 + onKeyDown={handleKeyDown("horizontal")} 210 + role="separator" 211 + aria-orientation="vertical" 212 + aria-label="Resize left and right columns" 213 + aria-valuenow={Math.round(colPercent)} 214 + aria-valuemin={MIN_SIZE_PERCENT} 215 + aria-valuemax={MAX_SIZE_PERCENT} 216 + tabIndex={0} 217 + /> 218 + 219 + {/* Horizontal divider (full width) - controls row split */} 220 + <div 221 + className="ResizablePanes-divider ResizablePanes-divider--horizontal" 222 + onMouseDown={handleMouseDown("vertical")} 223 + onTouchStart={handleTouchStart("vertical")} 224 + onDoubleClick={handleDoubleClick("vertical")} 225 + onKeyDown={handleKeyDown("vertical")} 226 + role="separator" 227 + aria-orientation="horizontal" 228 + aria-label="Resize top and bottom rows" 229 + aria-valuenow={Math.round(rowPercent)} 230 + aria-valuemin={MIN_SIZE_PERCENT} 231 + aria-valuemax={MAX_SIZE_PERCENT} 232 + tabIndex={0} 233 + /> 234 + </div> 235 + ); 236 + }
+1 -46
src/client/ui/Workspace.css
··· 3 3 .Workspace { 4 4 flex: 1; 5 5 min-height: 0; 6 - display: grid; 7 - grid-template-columns: 50% 50%; 8 - grid-template-rows: 50% 50%; 9 - grid-template-areas: 10 - "server flight" 11 - "client preview"; 12 - overflow: hidden; 13 - } 14 - 15 - /* Grid positioning */ 16 - 17 - .Workspace-server, 18 - .Workspace-client, 19 - .Workspace-flight, 20 - .Workspace-preview { 21 6 display: flex; 22 - min-width: 0; 23 - min-height: 0; 24 - } 25 - 26 - .Workspace-server > *, 27 - .Workspace-client > *, 28 - .Workspace-flight > *, 29 - .Workspace-preview > * { 30 - flex: 1; 31 - min-width: 0; 32 - min-height: 0; 33 - } 34 - 35 - .Workspace-server { 36 - grid-area: server; 37 - border-right: 1px solid var(--border); 38 - border-bottom: 1px solid var(--border); 39 - } 40 - 41 - .Workspace-client { 42 - grid-area: client; 43 - border-right: 1px solid var(--border); 44 - } 45 - 46 - .Workspace-flight { 47 - grid-area: flight; 48 - border-bottom: 1px solid var(--border); 49 - } 50 - 51 - .Workspace-preview { 52 - grid-area: preview; 7 + overflow: hidden; 53 8 } 54 9 55 10 /* Loading states */
+44 -41
src/client/ui/Workspace.tsx
··· 4 4 import { FlightLog } from "./FlightLog.tsx"; 5 5 import { LivePreview } from "./LivePreview.tsx"; 6 6 import { Pane } from "./Pane.tsx"; 7 + import { ResizablePanes } from "./ResizablePanes.tsx"; 7 8 import "./Workspace.css"; 8 9 9 10 type WorkspaceProps = { ··· 65 66 66 67 return ( 67 68 <main className="Workspace"> 68 - <div className="Workspace-server"> 69 - <CodeEditor label="server" defaultValue={serverCode} onChange={handleServerChange} /> 70 - </div> 71 - <div className="Workspace-client"> 72 - <CodeEditor label="client" defaultValue={clientCode} onChange={handleClientChange} /> 73 - </div> 74 - <div className="Workspace-flight"> 75 - <Pane label="flight"> 76 - {isLoading ? ( 77 - <div className="Workspace-loadingOutput"> 78 - <span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting"> 79 - Loading 80 - </span> 81 - </div> 82 - ) : isError ? ( 83 - <pre className="Workspace-errorOutput">{session.state.message}</pre> 84 - ) : ( 85 - <FlightLog 86 - entries={entries} 87 - cursor={cursor} 88 - availableActions={session.state.availableActions} 89 - onAddRawAction={session.addRawAction} 90 - onDeleteEntry={session.timeline.deleteEntry} 91 - /> 92 - )} 93 - </Pane> 94 - </div> 95 - <div className="Workspace-preview"> 96 - <LivePreview 97 - entries={entries} 98 - cursor={cursor} 99 - totalChunks={totalChunks} 100 - isAtStart={isAtStart} 101 - isAtEnd={isAtEnd} 102 - isStreaming={isStreaming} 103 - isLoading={isLoading || isError} 104 - onStep={timeline.stepForward} 105 - onSkip={timeline.skipToEntryEnd} 106 - onReset={reset} 107 - /> 108 - </div> 69 + <ResizablePanes 70 + topLeft={ 71 + <CodeEditor label="server" defaultValue={serverCode} onChange={handleServerChange} /> 72 + } 73 + bottomLeft={ 74 + <CodeEditor label="client" defaultValue={clientCode} onChange={handleClientChange} /> 75 + } 76 + topRight={ 77 + <Pane label="flight"> 78 + {isLoading ? ( 79 + <div className="Workspace-loadingOutput"> 80 + <span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting"> 81 + Loading 82 + </span> 83 + </div> 84 + ) : isError ? ( 85 + <pre className="Workspace-errorOutput">{session.state.message}</pre> 86 + ) : ( 87 + <FlightLog 88 + entries={entries} 89 + cursor={cursor} 90 + availableActions={session.state.availableActions} 91 + onAddRawAction={session.addRawAction} 92 + onDeleteEntry={session.timeline.deleteEntry} 93 + /> 94 + )} 95 + </Pane> 96 + } 97 + bottomRight={ 98 + <LivePreview 99 + entries={entries} 100 + cursor={cursor} 101 + totalChunks={totalChunks} 102 + isAtStart={isAtStart} 103 + isAtEnd={isAtEnd} 104 + isStreaming={isStreaming} 105 + isLoading={isLoading || isError} 106 + onStep={timeline.stepForward} 107 + onSkip={timeline.skipToEntryEnd} 108 + onReset={reset} 109 + /> 110 + } 111 + /> 109 112 </main> 110 113 ); 111 114 }