157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
import {
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type HTMLAttributes,
|
|
type KeyboardEvent,
|
|
type PointerEvent,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
import { cn } from "./cn.js";
|
|
|
|
export type SplitPaneOrientation = "vertical" | "horizontal";
|
|
|
|
export interface SplitPaneProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
primary: ReactNode;
|
|
secondary: ReactNode;
|
|
primarySize: number;
|
|
onPrimarySizeChange: (primarySize: number) => void;
|
|
orientation?: SplitPaneOrientation;
|
|
minPrimarySize?: number;
|
|
minSecondarySize?: number;
|
|
step?: number;
|
|
resizable?: boolean;
|
|
separatorLabel: string;
|
|
}
|
|
|
|
const clamp = (value: number, minimum: number, maximum: number) => (
|
|
Math.min(Math.max(value, minimum), maximum)
|
|
);
|
|
|
|
const normalizedLimits = (minPrimarySize: number, minSecondarySize: number) => {
|
|
const primary = clamp(minPrimarySize, 0, 100);
|
|
const secondary = clamp(minSecondarySize, 0, 100);
|
|
if (primary + secondary <= 100) return { minimum: primary, maximum: 100 - secondary };
|
|
const scale = 100 / (primary + secondary);
|
|
return { minimum: primary * scale, maximum: 100 - secondary * scale };
|
|
};
|
|
|
|
export function SplitPane({
|
|
primary,
|
|
secondary,
|
|
primarySize,
|
|
onPrimarySizeChange,
|
|
orientation = "vertical",
|
|
minPrimarySize = 20,
|
|
minSecondarySize = 20,
|
|
step = 2,
|
|
resizable = true,
|
|
separatorLabel,
|
|
className,
|
|
style,
|
|
...props
|
|
}: SplitPaneProps) {
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const activePointerIdRef = useRef<number | null>(null);
|
|
const [dragging, setDragging] = useState(false);
|
|
const limits = normalizedLimits(minPrimarySize, minSecondarySize);
|
|
const size = clamp(Number.isFinite(primarySize) ? primarySize : 50, limits.minimum, limits.maximum);
|
|
const keyboardIncrement = Number.isFinite(step) && step > 0 ? step : 2;
|
|
|
|
const emitPointerSize = (event: PointerEvent<HTMLElement>) => {
|
|
const root = rootRef.current;
|
|
if (!root) return;
|
|
const rect = root.getBoundingClientRect();
|
|
const available = orientation === "vertical" ? rect.width : rect.height;
|
|
if (available <= 0) return;
|
|
const offset = orientation === "vertical"
|
|
? event.clientX - rect.left
|
|
: event.clientY - rect.top;
|
|
onPrimarySizeChange(clamp(offset / available * 100, limits.minimum, limits.maximum));
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
const multiplier = event.shiftKey ? 5 : 1;
|
|
const decrement = orientation === "vertical" ? "ArrowLeft" : "ArrowUp";
|
|
const increment = orientation === "vertical" ? "ArrowRight" : "ArrowDown";
|
|
let next: number | null = null;
|
|
if (event.key === decrement) next = size - keyboardIncrement * multiplier;
|
|
if (event.key === increment) next = size + keyboardIncrement * multiplier;
|
|
if (event.key === "Home") next = limits.minimum;
|
|
if (event.key === "End") next = limits.maximum;
|
|
if (next === null) return;
|
|
event.preventDefault();
|
|
onPrimarySizeChange(clamp(next, limits.minimum, limits.maximum));
|
|
};
|
|
|
|
const splitStyle = {
|
|
...style,
|
|
"--nodedc-split-pane-primary": `${size}%`,
|
|
} as CSSProperties;
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
className={cn("nodedc-split-pane", className)}
|
|
data-orientation={orientation}
|
|
data-dragging={dragging ? "true" : undefined}
|
|
style={splitStyle}
|
|
{...props}
|
|
>
|
|
<div className="nodedc-split-pane__panel" data-pane="primary">
|
|
{primary}
|
|
</div>
|
|
<div className="nodedc-split-pane__panel" data-pane="secondary">
|
|
{secondary}
|
|
</div>
|
|
{resizable ? (
|
|
<div
|
|
className="nodedc-split-pane__separator"
|
|
role="separator"
|
|
tabIndex={0}
|
|
aria-label={separatorLabel}
|
|
aria-orientation={orientation}
|
|
aria-valuemin={Math.round(limits.minimum)}
|
|
aria-valuemax={Math.round(limits.maximum)}
|
|
aria-valuenow={Math.round(size)}
|
|
aria-valuetext={`${Math.round(size)}% / ${Math.round(100 - size)}%`}
|
|
onKeyDown={handleKeyDown}
|
|
onPointerDown={(event) => {
|
|
if (event.button !== 0) return;
|
|
event.currentTarget.focus();
|
|
activePointerIdRef.current = event.pointerId;
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
setDragging(true);
|
|
emitPointerSize(event);
|
|
event.preventDefault();
|
|
}}
|
|
onPointerMove={(event) => {
|
|
if (activePointerIdRef.current !== event.pointerId) return;
|
|
emitPointerSize(event);
|
|
event.preventDefault();
|
|
}}
|
|
onPointerUp={(event) => {
|
|
if (activePointerIdRef.current !== event.pointerId) return;
|
|
activePointerIdRef.current = null;
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
setDragging(false);
|
|
}}
|
|
onPointerCancel={(event) => {
|
|
if (activePointerIdRef.current !== event.pointerId) return;
|
|
activePointerIdRef.current = null;
|
|
setDragging(false);
|
|
}}
|
|
onLostPointerCapture={(event) => {
|
|
if (activePointerIdRef.current !== event.pointerId) return;
|
|
activePointerIdRef.current = null;
|
|
setDragging(false);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|