Move split layout

This commit is contained in:
Gregory Schier
2026-03-12 14:00:29 -07:00
parent 0b7705d915
commit 7e7faa69df
25 changed files with 113 additions and 84 deletions

View File

@@ -0,0 +1,112 @@
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
const START_DISTANCE = 7;
export interface ResizeHandleEvent {
x: number;
y: number;
xStart: number;
yStart: number;
}
interface Props {
style?: CSSProperties;
className?: string;
onResizeStart?: () => void;
onResizeEnd?: () => void;
onResizeMove?: (e: ResizeHandleEvent) => void;
onReset?: () => void;
side: 'left' | 'right' | 'top';
justify: 'center' | 'end' | 'start';
}
export function ResizeHandle({
style,
justify,
className,
onResizeStart,
onResizeEnd,
onResizeMove,
onReset,
side,
}: Props) {
const vertical = side === 'top';
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void;
calledStart: boolean;
xStart: number;
yStart: number;
} | null>(null);
const handlePointerDown = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
function move(e: MouseEvent) {
if (moveState.current == null) return;
const xDistance = moveState.current.xStart - e.clientX;
const yDistance = moveState.current.yStart - e.clientY;
const distance = Math.abs(vertical ? yDistance : xDistance);
if (moveState.current.calledStart) {
onResizeMove?.({
x: e.clientX,
y: e.clientY,
xStart: moveState.current.xStart,
yStart: moveState.current.yStart,
});
} else if (distance > START_DISTANCE) {
onResizeStart?.();
moveState.current.calledStart = true;
setIsResizing(true);
}
}
function up() {
setIsResizing(false);
moveState.current = null;
document.documentElement.removeEventListener('mousemove', move);
document.documentElement.removeEventListener('mouseup', up);
onResizeEnd?.();
}
moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up };
document.documentElement.addEventListener('mousemove', move);
document.documentElement.addEventListener('mouseup', up);
},
[onResizeEnd, onResizeMove, onResizeStart, vertical],
);
return (
<div
aria-hidden
style={style}
onDoubleClick={onReset}
onPointerDown={handlePointerDown}
className={classNames(
className,
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
vertical ? 'w-full h-1.5 cursor-row-resize' : 'h-full w-1.5 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',
side === 'right' && 'right-0',
side === 'left' && 'left-0',
side === 'top' && 'top-0',
)}
>
{isResizing && (
<div
className={classNames(
'fixed -left-[100vw] -right-[100vw] -top-[100vh] -bottom-[100vh]',
vertical && 'cursor-row-resize',
!vertical && 'cursor-col-resize',
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use';
import { useContainerSize } from '../hooks/useContainerSize';
import { clamp } from '../lib/clamp';
import type { ResizeHandleEvent } from './ResizeHandle';
import { ResizeHandle } from './ResizeHandle';
export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical';
export interface SlotProps {
orientation: 'horizontal' | 'vertical';
style: CSSProperties;
}
interface Props {
storageKey: string;
firstSlot: (props: SlotProps) => ReactNode;
secondSlot: null | ((props: SlotProps) => ReactNode);
style?: CSSProperties;
className?: string;
defaultRatio?: number;
minHeightPx?: number;
minWidthPx?: number;
layout?: SplitLayoutLayout;
resizeHandleClassName?: string;
}
const baseProperties = { minWidth: 0 };
const areaL = { ...baseProperties, gridArea: 'left' };
const areaR = { ...baseProperties, gridArea: 'right' };
const areaD = { ...baseProperties, gridArea: 'drag' };
const STACK_VERTICAL_WIDTH = 500;
export function SplitLayout({
style,
firstSlot,
secondSlot,
className,
storageKey,
layout = 'responsive',
resizeHandleClassName,
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [widthRaw, setWidth] = useLocalStorage<number>(`${storageKey}_width`);
const [heightRaw, setHeight] = useLocalStorage<number>(`${storageKey}_height`);
const width = widthRaw ?? defaultRatio;
let height = heightRaw ?? defaultRatio;
if (!secondSlot) {
height = 0;
minHeightPx = 0;
}
const size = useContainerSize(containerRef);
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
const vertical = layout !== 'horizontal' && (layout === 'vertical' || verticalBasedOnSize);
const styles = useMemo<CSSProperties>(() => {
return {
...style,
gridTemplate: vertical
? `
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
' ${areaD.gridArea}' 0
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
/ 1fr
`
: `
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
/ ${1 - width}fr 0 ${width}fr
`,
};
}, [style, vertical, height, minHeightPx, width]);
const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio);
else setWidth(defaultRatio);
}, [vertical, setHeight, defaultRatio, setWidth]);
const handleResizeMove = useCallback(
(e: ResizeHandleEvent) => {
if (containerRef.current === null) return;
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
containerRef.current,
);
const $c = containerRef.current;
const containerWidth =
$c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);
const containerHeight =
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
const mouseStartX = e.xStart;
const mouseStartY = e.yStart;
const startWidth = containerWidth * width;
const startHeight = containerHeight * height;
if (vertical) {
const maxHeightPx = containerHeight - minHeightPx;
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
setHeight(newHeightPx / containerHeight);
} else {
const maxWidthPx = containerWidth - minWidthPx;
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
setWidth(newWidthPx / containerWidth);
}
},
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
);
return (
<div
ref={containerRef}
style={styles}
className={classNames(className, 'grid w-full h-full overflow-hidden')}
>
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
{secondSlot && (
<>
<ResizeHandle
style={areaD}
className={classNames(
resizeHandleClassName,
vertical ? '-translate-y-1' : '-translate-x-1',
)}
onResizeMove={handleResizeMove}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
</>
)}
</div>
);
}

View File

@@ -5,13 +5,15 @@ export function Table({
children,
className,
scrollable,
style,
}: {
children: ReactNode;
className?: string;
scrollable?: boolean;
style?: React.CSSProperties;
}) {
return (
<div className={classNames('w-full', scrollable && 'h-full overflow-y-auto')}>
<div style={style} className={classNames('w-full', scrollable && 'h-full overflow-y-auto')}>
<table
className={classNames(
className,

View File

@@ -0,0 +1,30 @@
import type { RefObject } from 'react';
import { useLayoutEffect, useState } from 'react';
export function useContainerSize(ref: RefObject<HTMLElement | null>) {
const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = ref.current;
if (el) {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === el) {
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
}
}
});
observer.observe(el);
return () => {
observer.unobserve(el);
observer.disconnect();
};
}
return undefined;
}, [ref]);
return size;
}

View File

@@ -20,4 +20,10 @@ export type { TreeNode } from "./components/tree/common";
export type { TreeItemProps } from "./components/tree/TreeItem";
export { isSelectedFamily, selectedIdsFamily } from "./components/tree/atoms";
export { minPromiseMillis } from "./lib/minPromiseMillis";
export { ResizeHandle } from "./components/ResizeHandle";
export type { ResizeHandleEvent } from "./components/ResizeHandle";
export { SplitLayout } from "./components/SplitLayout";
export type { SplitLayoutLayout, SlotProps } from "./components/SplitLayout";
export { Table, TableBody, TableHead, TableRow, TableCell, TruncatedWideTableCell, TableHeaderCell } from "./components/Table";
export { clamp } from "./lib/clamp";
export { useContainerSize } from "./hooks/useContainerSize";

View File

@@ -0,0 +1,3 @@
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}