Files
NODEDC_DESIGN_GUIDELINE/packages/ui-react/src/SplitPane.tsx
T

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>
);
}