diff --git a/package-lock.json b/package-lock.json index b9ad87f..6709fe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "react": "19.1.0", "react-day-picker": "^9.11.0", "react-dom": "19.1.0", + "react-resizable-panels": "^3.0.6", "react-syntax-highlighter": "^5.8.0", "recharts": "^3.2.1", "shiki": "^3.13.0", @@ -3638,6 +3639,16 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", + "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", diff --git a/package.json b/package.json index d66f586..c57f0d2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react": "19.1.0", "react-day-picker": "^9.11.0", "react-dom": "19.1.0", + "react-resizable-panels": "^3.0.6", "react-syntax-highlighter": "^5.8.0", "recharts": "^3.2.1", "shiki": "^3.13.0", diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx new file mode 100644 index 0000000..0dcf3fd --- /dev/null +++ b/src/components/ui/resizable.tsx @@ -0,0 +1,301 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +/* ---------- Types ---------- */ +type Direction = "horizontal" | "vertical"; + +type ResizablePanelGroupProps = { + direction?: Direction; + className?: string; + children: React.ReactNode; +}; + +type ResizablePanelProps = { + defaultSize?: number; // percent for this panel (0-100) + children?: React.ReactNode; +} & React.HTMLAttributes; + +type ResizableHandleProps = { + withHandle?: boolean; +} & React.HTMLAttributes; + +/* ---------- Group ---------- */ +const ResizablePanelGroupInner = React.forwardRef( + ({ direction = "horizontal", children, className, ...props }, ref) => { + // Expect three children: panel, handle, panel + const childs = React.Children.toArray(children) as React.ReactElement[]; + + // read default sizes from first/last panel props if provided + const leftDefault = + (childs[0] && (childs[0].props as any)?.defaultSize) ?? 50; + const rightDefault = + (childs[2] && (childs[2].props as any)?.defaultSize) ?? + Math.max(0, 100 - leftDefault); + + const [primarySize, setPrimarySize] = React.useState( + Math.max(0, Math.min(100, leftDefault)) + ); + + const containerRef = React.useRef(null); + const draggingRef = React.useRef(false); + + // expose resize function for handle + const startDrag = React.useCallback(() => { + draggingRef.current = true; + }, []); + + const stopDrag = React.useCallback(() => { + draggingRef.current = false; + }, []); + + React.useEffect(() => { + const onPointerMove = (e: PointerEvent) => { + if (!draggingRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + if (direction === "horizontal") { + const x = e.clientX - rect.left; + const percent = (x / rect.width) * 100; + setPrimarySize(Math.max(5, Math.min(95, percent))); + } else { + const y = e.clientY - rect.top; + const percent = (y / rect.height) * 100; + setPrimarySize(Math.max(5, Math.min(95, percent))); + } + }; + + const onPointerUp = () => { + draggingRef.current = false; + }; + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + }; + }, [direction]); + + // keyboard adjustments for the handle (exposed via context) + const adjust = React.useCallback( + (deltaPercent: number) => { + setPrimarySize((prev) => Math.max(5, Math.min(95, prev + deltaPercent))); + }, + [] + ); + + // Provide a simple context so handle/panels can communicate (optional) + const ctx = React.useMemo( + () => ({ + direction, + primarySize, + startDrag, + stopDrag, + adjust, + }), + [direction, primarySize, startDrag, stopDrag, adjust] + ); + + return ( + +
{ + containerRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) (ref as React.MutableRefObject).current = el; + }} + className={cn( + direction === "horizontal" ? "inline-flex" : "inline-flex flex-col", + "items-stretch", + "overflow-hidden", + className + )} + {...props} + > + {/* render children but inject sizes for first and last panel */} + {React.Children.map(children, (child, idx) => { + if (!React.isValidElement(child)) return child; + // first panel: style flex-basis from primarySize + if (idx === 0) { + const style = + direction === "horizontal" + ? { flexBasis: `${primarySize}%`, minWidth: 0 } + : { flexBasis: `${primarySize}%`, minHeight: 0 }; + return React.cloneElement(child as React.ReactElement, { style }); + } + // third panel: inverse + if (idx === 2) { + const style = + direction === "horizontal" + ? { flexBasis: `${100 - primarySize}%`, minWidth: 0 } + : { flexBasis: `${100 - primarySize}%`, minHeight: 0 }; + return React.cloneElement(child as React.ReactElement, { style }); + } + // handle or other children: render as-is + return child; + })} +
+
+ ); + } +); +ResizablePanelGroupInner.displayName = "ResizablePanelGroup"; + +/* ---------- Context ---------- */ +type ResizableContext = { + direction: Direction; + primarySize: number; + startDrag: () => void; + stopDrag: () => void; + adjust: (deltaPercent: number) => void; +} | null; + +const ResizableContext = React.createContext(null); +const useResizable = () => { + const c = React.useContext(ResizableContext); + if (!c) throw new Error("Resizable components must be used inside "); + return c; +}; + +/* ---------- Panel ---------- */ +const ResizablePanel = React.forwardRef( + ({ defaultSize = 50, children, className, style, ...props }, ref) => { + // panel is mostly a styled container; defaultSize is consumed by the group + return ( +
+ {children} +
+ ); + } +); +ResizablePanel.displayName = "ResizablePanel"; + +/* ---------- Handle ---------- */ +const ResizableHandle = React.forwardRef( + ({ withHandle = false, className, ...props }, ref) => { + const { direction, primarySize, startDrag, stopDrag, adjust } = useResizable(); + + // pointer down initiates dragging + const onPointerDown: React.PointerEventHandler = (e) => { + // only primary button + if (e.button !== 0) return; + (e.target as Element).setPointerCapture?.((e as any).pointerId); + startDrag(); + }; + + const onPointerUp: React.PointerEventHandler = () => { + stopDrag(); + }; + + // keyboard support: arrow keys move handle + const onKeyDown: React.KeyboardEventHandler = (e) => { + const step = 2; // percent per key press + if (direction === "horizontal") { + if (e.key === "ArrowLeft") { + e.preventDefault(); + adjust(-step); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + adjust(step); + } + } else { + if (e.key === "ArrowUp") { + e.preventDefault(); + adjust(-step); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + adjust(step); + } + } + }; + + // aria values: show current primary panel size + const ariaOrientation = direction === "horizontal" ? "horizontal" : "vertical"; + + return ( +
+ {/* optional visible handle button */} + {withHandle ? ( +
+ {/* three dots like a grip */} +
+ + + +
+
+ ) : ( + // a thin bar for visual separation +
+ )} +
+ ); + } +); +ResizableHandle.displayName = "ResizableHandle"; + +/* ---------- Preview built-in (so components.tsx imports only group) ---------- */ +const ResizablePreview: React.FC = () => { + return ( +
+ + +
+ Sidebar +
+
+ + +
+ Content +
+
+
+
+ ); +}; + +/* ---------- Attach preview and exports ---------- */ +type RGroup = typeof ResizablePanelGroupInner & { Preview: React.FC }; +const ResizablePanelGroup = ResizablePanelGroupInner as RGroup; +ResizablePanelGroup.Preview = ResizablePreview; + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; +export default ResizablePanelGroup; diff --git a/src/data/components.tsx b/src/data/components.tsx index 0a10610..58af4c3 100644 --- a/src/data/components.tsx +++ b/src/data/components.tsx @@ -95,6 +95,8 @@ import { Kbd } from "@/components/ui/kbd"; import { InputOTP } from "@/components/ui/input-otp"; +import { ResizablePanelGroup } from "@/components/ui/resizable"; + export const componentsData = [ { id: "button", @@ -2104,4 +2106,42 @@ export function InputOTPDemo() { { name: "children", type: "ReactNode", description: "Slot groups and separators." }, ], }, + { + id: "resizable-panels", + title: "Resizable Panels", + description: "Accessible, resizable panel group with keyboard and pointer support (horizontal/vertical).", + category: "Layout", + preview: , + code: `import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + +export function ResizableHandleDemo() { + return ( + + +
+ Sidebar +
+
+ + +
+ Content +
+
+
+ ) +}`, + propsData: [ + { name: "direction", type: '"horizontal" | "vertical"', description: "Resize axis.", default: "horizontal" }, + { name: "className", type: "string", description: "Additional classes for wrapper.", default: '""' }, + { name: "ResizablePanel.defaultSize", type: "number", description: "Default percentage size for a panel.", default: "50" }, + ], + }, ];