import { useDirection } from "@base-ui/react/direction-provider"; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; import { useTranslate } from "@/utils/i18n"; import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_WIDTH_VAR } from "./useSidebarWidth"; /** Arrow-key nudge, matching the 16px rhythm the sidebar rows sit on. */ const KEYBOARD_STEP = 16; interface Props { width: number; minWidth: number; maxWidth: number; onWidthChange: (width: number) => void; /** Element carrying the width custom property, written to directly while dragging. */ targetRef: RefObject; /** The custom property the drag previews into; defaults to the sidebar's. */ cssVariable?: string; /** What a double-click restores. */ defaultWidth?: number; /** Which edge of the rail the handle sits on: dragging past a start edge widens the rail leftward. */ edge?: "start" | "end"; /** Accessible name; defaults to the sidebar's. */ label?: string; } const SidebarResizeHandle = ({ width, minWidth, maxWidth, onWidthChange, targetRef, cssVariable = SIDEBAR_WIDTH_VAR, defaultWidth = SIDEBAR_DEFAULT_WIDTH, edge = "end", label, }: Props) => { const t = useTranslate(); const direction = useDirection(); // Pointer and arrow deltas grow the rail when they move toward its far side: rightward for an // end-edge handle in LTR, leftward for a start-edge one; RTL mirrors both. const growSign = (edge === "end" ? 1 : -1) * (direction === "rtl" ? -1 : 1); // `dragging` drives the band's styling; `draggingRef` is what the handlers and the unmount // cleanup read, so neither depends on a state update having been flushed first. const [dragging, setDragging] = useState(false); const draggingRef = useRef(false); const frameRef = useRef(null); const pendingRef = useRef(width); const originRef = useRef({ x: 0, width }); const clamp = useCallback((next: number) => Math.min(Math.max(Math.round(next), minWidth), maxWidth), [minWidth, maxWidth]); // The drag bypasses React: layout follows the custom property, so the rail tracks the cursor at // frame rate while the tree — sidebar, feed, column grid — re-renders exactly once, on release. const previewWidth = useCallback( (next: number) => { pendingRef.current = next; if (frameRef.current != null) return; frameRef.current = requestAnimationFrame(() => { frameRef.current = null; targetRef.current?.style.setProperty(cssVariable, `${pendingRef.current}px`); }); }, [targetRef, cssVariable], ); const stopPreview = useCallback(() => { if (frameRef.current == null) return; cancelAnimationFrame(frameRef.current); frameRef.current = null; }, []); // Unmounting can interrupt a drag: the window crossing the desktop breakpoint takes the whole // rail with it, so `endDrag` never runs. The shell outlives the rail and React will not rewrite // a style property whose value it thinks is unchanged, so the previewed width would otherwise // survive as layout that disagrees with both state and storage. Put the committed width back — // it is the width the drag started from, since a drag commits nothing until release. useEffect( () => () => { stopPreview(); if (draggingRef.current) { targetRef.current?.style.setProperty(cssVariable, `${originRef.current.width}px`); } }, [stopPreview, targetRef, cssVariable], ); // Hold the resize cursor for the whole gesture, so it does not flicker into a text caret // whenever the pointer outruns the handle and crosses the memo list. useEffect(() => { if (!dragging) return; const { body } = document; const previousCursor = body.style.cursor; const previousUserSelect = body.style.userSelect; body.style.cursor = "col-resize"; body.style.userSelect = "none"; return () => { body.style.cursor = previousCursor; body.style.userSelect = previousUserSelect; }; }, [dragging]); const handlePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); originRef.current = { x: event.clientX, width }; pendingRef.current = width; draggingRef.current = true; setDragging(true); }; const handlePointerMove = (event: ReactPointerEvent) => { if (!draggingRef.current) return; // Track the delta rather than the raw cursor x, so grabbing anywhere in the strip // does not snap the rail's edge to the pointer. const pointerDelta = event.clientX - originRef.current.x; previewWidth(clamp(originRef.current.width + growSign * pointerDelta)); }; const endDrag = (event: ReactPointerEvent) => { if (!draggingRef.current) return; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } stopPreview(); draggingRef.current = false; setDragging(false); onWidthChange(pendingRef.current); }; const handleKeyDown = (event: ReactKeyboardEvent) => { const horizontalStep = growSign * KEYBOARD_STEP; const next = event.key === "ArrowLeft" ? width - horizontalStep : event.key === "ArrowRight" ? width + horizontalStep : event.key === "Home" ? minWidth : event.key === "End" ? maxWidth : undefined; if (next === undefined) return; event.preventDefault(); onWidthChange(next); }; return (
onWidthChange(defaultWidth)} onKeyDown={handleKeyDown} className={cn( "group absolute inset-y-0 z-10 flex w-2 cursor-col-resize touch-none justify-center focus-visible:outline-none", edge === "end" ? "-end-1" : "-start-1", )} > {/* A 2px band centering to whole pixels inside the 8px strip, so it covers the rail's border and stays crisp at 1x instead of antialiasing across a half-pixel seam. */}
); }; export default SidebarResizeHandle;