Skip to content

Commit c64b459

Browse files
VINAYMADIVALVinay Nagesh Madival
andauthored
feat:Add Accessible Resizable Panel Group Component (#220)
* Add self-contained Spinner component and integrate into components data * Add self-contained Kbd component with integrated search bar demo and components data entry * Rename Kbd component entry to 'Keyboard Shortcut' for consistency with naming conventions * Add self-contained Input OTP component with 3-3 digit layout and integrated demo * Add self-contained Resizable Panels component with keyboard & pointer support --------- Co-authored-by: Vinay Nagesh Madival <you@example.com> Co-authored-by: Vinay Nagesh Madival <[email protected]>
1 parent a983158 commit c64b459

4 files changed

Lines changed: 353 additions & 0 deletions

File tree

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"react": "19.1.0",
3535
"react-day-picker": "^9.11.0",
3636
"react-dom": "19.1.0",
37+
"react-resizable-panels": "^3.0.6",
3738
"react-syntax-highlighter": "^5.8.0",
3839
"recharts": "^3.2.1",
3940
"shiki": "^3.13.0",

src/components/ui/resizable.tsx

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import * as React from "react";
2+
import { cn } from "@/lib/utils";
3+
4+
/* ---------- Types ---------- */
5+
type Direction = "horizontal" | "vertical";
6+
7+
type ResizablePanelGroupProps = {
8+
direction?: Direction;
9+
className?: string;
10+
children: React.ReactNode;
11+
};
12+
13+
type ResizablePanelProps = {
14+
defaultSize?: number; // percent for this panel (0-100)
15+
children?: React.ReactNode;
16+
} & React.HTMLAttributes<HTMLDivElement>;
17+
18+
type ResizableHandleProps = {
19+
withHandle?: boolean;
20+
} & React.HTMLAttributes<HTMLDivElement>;
21+
22+
/* ---------- Group ---------- */
23+
const ResizablePanelGroupInner = React.forwardRef<HTMLDivElement, ResizablePanelGroupProps>(
24+
({ direction = "horizontal", children, className, ...props }, ref) => {
25+
// Expect three children: panel, handle, panel
26+
const childs = React.Children.toArray(children) as React.ReactElement[];
27+
28+
// read default sizes from first/last panel props if provided
29+
const leftDefault =
30+
(childs[0] && (childs[0].props as any)?.defaultSize) ?? 50;
31+
const rightDefault =
32+
(childs[2] && (childs[2].props as any)?.defaultSize) ??
33+
Math.max(0, 100 - leftDefault);
34+
35+
const [primarySize, setPrimarySize] = React.useState<number>(
36+
Math.max(0, Math.min(100, leftDefault))
37+
);
38+
39+
const containerRef = React.useRef<HTMLDivElement | null>(null);
40+
const draggingRef = React.useRef(false);
41+
42+
// expose resize function for handle
43+
const startDrag = React.useCallback(() => {
44+
draggingRef.current = true;
45+
}, []);
46+
47+
const stopDrag = React.useCallback(() => {
48+
draggingRef.current = false;
49+
}, []);
50+
51+
React.useEffect(() => {
52+
const onPointerMove = (e: PointerEvent) => {
53+
if (!draggingRef.current || !containerRef.current) return;
54+
const rect = containerRef.current.getBoundingClientRect();
55+
if (direction === "horizontal") {
56+
const x = e.clientX - rect.left;
57+
const percent = (x / rect.width) * 100;
58+
setPrimarySize(Math.max(5, Math.min(95, percent)));
59+
} else {
60+
const y = e.clientY - rect.top;
61+
const percent = (y / rect.height) * 100;
62+
setPrimarySize(Math.max(5, Math.min(95, percent)));
63+
}
64+
};
65+
66+
const onPointerUp = () => {
67+
draggingRef.current = false;
68+
};
69+
70+
window.addEventListener("pointermove", onPointerMove);
71+
window.addEventListener("pointerup", onPointerUp);
72+
return () => {
73+
window.removeEventListener("pointermove", onPointerMove);
74+
window.removeEventListener("pointerup", onPointerUp);
75+
};
76+
}, [direction]);
77+
78+
// keyboard adjustments for the handle (exposed via context)
79+
const adjust = React.useCallback(
80+
(deltaPercent: number) => {
81+
setPrimarySize((prev) => Math.max(5, Math.min(95, prev + deltaPercent)));
82+
},
83+
[]
84+
);
85+
86+
// Provide a simple context so handle/panels can communicate (optional)
87+
const ctx = React.useMemo(
88+
() => ({
89+
direction,
90+
primarySize,
91+
startDrag,
92+
stopDrag,
93+
adjust,
94+
}),
95+
[direction, primarySize, startDrag, stopDrag, adjust]
96+
);
97+
98+
return (
99+
<ResizableContext.Provider value={ctx}>
100+
<div
101+
ref={(el) => {
102+
containerRef.current = el;
103+
if (typeof ref === "function") ref(el);
104+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
105+
}}
106+
className={cn(
107+
direction === "horizontal" ? "inline-flex" : "inline-flex flex-col",
108+
"items-stretch",
109+
"overflow-hidden",
110+
className
111+
)}
112+
{...props}
113+
>
114+
{/* render children but inject sizes for first and last panel */}
115+
{React.Children.map(children, (child, idx) => {
116+
if (!React.isValidElement(child)) return child;
117+
// first panel: style flex-basis from primarySize
118+
if (idx === 0) {
119+
const style =
120+
direction === "horizontal"
121+
? { flexBasis: `${primarySize}%`, minWidth: 0 }
122+
: { flexBasis: `${primarySize}%`, minHeight: 0 };
123+
return React.cloneElement(child as React.ReactElement<any>, { style });
124+
}
125+
// third panel: inverse
126+
if (idx === 2) {
127+
const style =
128+
direction === "horizontal"
129+
? { flexBasis: `${100 - primarySize}%`, minWidth: 0 }
130+
: { flexBasis: `${100 - primarySize}%`, minHeight: 0 };
131+
return React.cloneElement(child as React.ReactElement<any>, { style });
132+
}
133+
// handle or other children: render as-is
134+
return child;
135+
})}
136+
</div>
137+
</ResizableContext.Provider>
138+
);
139+
}
140+
);
141+
ResizablePanelGroupInner.displayName = "ResizablePanelGroup";
142+
143+
/* ---------- Context ---------- */
144+
type ResizableContext = {
145+
direction: Direction;
146+
primarySize: number;
147+
startDrag: () => void;
148+
stopDrag: () => void;
149+
adjust: (deltaPercent: number) => void;
150+
} | null;
151+
152+
const ResizableContext = React.createContext<ResizableContext>(null);
153+
const useResizable = () => {
154+
const c = React.useContext(ResizableContext);
155+
if (!c) throw new Error("Resizable components must be used inside <ResizablePanelGroup>");
156+
return c;
157+
};
158+
159+
/* ---------- Panel ---------- */
160+
const ResizablePanel = React.forwardRef<HTMLDivElement, ResizablePanelProps>(
161+
({ defaultSize = 50, children, className, style, ...props }, ref) => {
162+
// panel is mostly a styled container; defaultSize is consumed by the group
163+
return (
164+
<div
165+
ref={ref}
166+
{...props}
167+
style={style}
168+
className={cn("min-w-0 min-h-0", className)}
169+
>
170+
{children}
171+
</div>
172+
);
173+
}
174+
);
175+
ResizablePanel.displayName = "ResizablePanel";
176+
177+
/* ---------- Handle ---------- */
178+
const ResizableHandle = React.forwardRef<HTMLDivElement, ResizableHandleProps>(
179+
({ withHandle = false, className, ...props }, ref) => {
180+
const { direction, primarySize, startDrag, stopDrag, adjust } = useResizable();
181+
182+
// pointer down initiates dragging
183+
const onPointerDown: React.PointerEventHandler<HTMLDivElement> = (e) => {
184+
// only primary button
185+
if (e.button !== 0) return;
186+
(e.target as Element).setPointerCapture?.((e as any).pointerId);
187+
startDrag();
188+
};
189+
190+
const onPointerUp: React.PointerEventHandler<HTMLDivElement> = () => {
191+
stopDrag();
192+
};
193+
194+
// keyboard support: arrow keys move handle
195+
const onKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
196+
const step = 2; // percent per key press
197+
if (direction === "horizontal") {
198+
if (e.key === "ArrowLeft") {
199+
e.preventDefault();
200+
adjust(-step);
201+
} else if (e.key === "ArrowRight") {
202+
e.preventDefault();
203+
adjust(step);
204+
}
205+
} else {
206+
if (e.key === "ArrowUp") {
207+
e.preventDefault();
208+
adjust(-step);
209+
} else if (e.key === "ArrowDown") {
210+
e.preventDefault();
211+
adjust(step);
212+
}
213+
}
214+
};
215+
216+
// aria values: show current primary panel size
217+
const ariaOrientation = direction === "horizontal" ? "horizontal" : "vertical";
218+
219+
return (
220+
<div
221+
ref={ref}
222+
role="separator"
223+
tabIndex={0}
224+
aria-orientation={ariaOrientation}
225+
aria-valuemin={5}
226+
aria-valuemax={95}
227+
aria-valuenow={Math.round(primarySize)}
228+
onPointerDown={onPointerDown}
229+
onPointerUp={onPointerUp}
230+
onKeyDown={onKeyDown}
231+
className={cn(
232+
// visual separator styling
233+
direction === "horizontal"
234+
? "flex items-center justify-center cursor-col-resize"
235+
: "flex items-center justify-center cursor-row-resize",
236+
// small focus ring for keyboard accessibility
237+
"focus:outline-none focus:ring-2 focus:ring-ring rounded",
238+
className
239+
)}
240+
{...props}
241+
>
242+
{/* optional visible handle button */}
243+
{withHandle ? (
244+
<div
245+
className={cn(
246+
"inline-flex h-8 w-2 items-center justify-center rounded bg-muted/60",
247+
direction === "horizontal" ? "mx-2" : "my-2"
248+
)}
249+
>
250+
{/* three dots like a grip */}
251+
<div className="flex flex-col gap-1">
252+
<span className="h-0.5 w-0.5 rounded bg-muted-foreground block" />
253+
<span className="h-0.5 w-0.5 rounded bg-muted-foreground block" />
254+
<span className="h-0.5 w-0.5 rounded bg-muted-foreground block" />
255+
</div>
256+
</div>
257+
) : (
258+
// a thin bar for visual separation
259+
<div
260+
className={cn(
261+
direction === "horizontal" ? "h-10 w-[1px] bg-border" : "w-10 h-[1px] bg-border"
262+
)}
263+
/>
264+
)}
265+
</div>
266+
);
267+
}
268+
);
269+
ResizableHandle.displayName = "ResizableHandle";
270+
271+
/* ---------- Preview built-in (so components.tsx imports only group) ---------- */
272+
const ResizablePreview: React.FC = () => {
273+
return (
274+
<div className="w-full flex justify-center">
275+
<ResizablePanelGroupInner
276+
direction="horizontal"
277+
className="min-h-[200px] max-w-md rounded-lg border md:min-w-[450px] bg-card"
278+
>
279+
<ResizablePanel defaultSize={25}>
280+
<div className="flex h-full items-center justify-center p-6">
281+
<span className="font-semibold">Sidebar</span>
282+
</div>
283+
</ResizablePanel>
284+
<ResizableHandle withHandle />
285+
<ResizablePanel defaultSize={75}>
286+
<div className="flex h-full items-center justify-center p-6">
287+
<span className="font-semibold">Content</span>
288+
</div>
289+
</ResizablePanel>
290+
</ResizablePanelGroupInner>
291+
</div>
292+
);
293+
};
294+
295+
/* ---------- Attach preview and exports ---------- */
296+
type RGroup = typeof ResizablePanelGroupInner & { Preview: React.FC };
297+
const ResizablePanelGroup = ResizablePanelGroupInner as RGroup;
298+
ResizablePanelGroup.Preview = ResizablePreview;
299+
300+
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
301+
export default ResizablePanelGroup;

src/data/components.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ import { Kbd } from "@/components/ui/kbd";
9595

9696
import { InputOTP } from "@/components/ui/input-otp";
9797

98+
import { ResizablePanelGroup } from "@/components/ui/resizable";
99+
98100
export const componentsData = [
99101
{
100102
id: "button",
@@ -2104,4 +2106,42 @@ export function InputOTPDemo() {
21042106
{ name: "children", type: "ReactNode", description: "Slot groups and separators." },
21052107
],
21062108
},
2109+
{
2110+
id: "resizable-panels",
2111+
title: "Resizable Panels",
2112+
description: "Accessible, resizable panel group with keyboard and pointer support (horizontal/vertical).",
2113+
category: "Layout",
2114+
preview: <ResizablePanelGroup.Preview />,
2115+
code: `import {
2116+
ResizablePanelGroup,
2117+
ResizablePanel,
2118+
ResizableHandle,
2119+
} from "@/components/ui/resizable"
2120+
2121+
export function ResizableHandleDemo() {
2122+
return (
2123+
<ResizablePanelGroup
2124+
direction="horizontal"
2125+
className="min-h-[200px] max-w-md rounded-lg border md:min-w-[450px]"
2126+
>
2127+
<ResizablePanel defaultSize={25}>
2128+
<div className="flex h-full items-center justify-center p-6">
2129+
<span className="font-semibold">Sidebar</span>
2130+
</div>
2131+
</ResizablePanel>
2132+
<ResizableHandle withHandle />
2133+
<ResizablePanel defaultSize={75}>
2134+
<div className="flex h-full items-center justify-center p-6">
2135+
<span className="font-semibold">Content</span>
2136+
</div>
2137+
</ResizablePanel>
2138+
</ResizablePanelGroup>
2139+
)
2140+
}`,
2141+
propsData: [
2142+
{ name: "direction", type: '"horizontal" | "vertical"', description: "Resize axis.", default: "horizontal" },
2143+
{ name: "className", type: "string", description: "Additional classes for wrapper.", default: '""' },
2144+
{ name: "ResizablePanel.defaultSize", type: "number", description: "Default percentage size for a panel.", default: "50" },
2145+
],
2146+
},
21072147
];

0 commit comments

Comments
 (0)