Better and more stable splitter

This commit is contained in:
Nate Kelley 2025-08-13 11:35:31 -06:00
parent d725be5505
commit 9e0b975999
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 282 additions and 226 deletions

View File

@ -1,8 +1,9 @@
import type React from 'react'; import type React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { LayoutSize } from './AppSplitter';
import { AppSplitter } from './AppSplitter/AppSplitter'; import { AppSplitter } from './AppSplitter/AppSplitter';
const DEFAULT_LAYOUT = ['230px', 'auto']; const DEFAULT_LAYOUT: LayoutSize = ['230px', 'auto'];
/** /**
* @param floating - Applies floating styles with padding and border (default: true) * @param floating - Applies floating styles with padding and border (default: true)
@ -17,7 +18,7 @@ export const AppLayout: React.FC<
floating?: boolean; floating?: boolean;
className?: string; className?: string;
sidebar?: React.ReactNode; sidebar?: React.ReactNode;
defaultLayout?: [string, string]; defaultLayout?: LayoutSize;
leftHidden?: boolean; leftHidden?: boolean;
autoSaveId?: string; autoSaveId?: string;
}> }>

View File

@ -3,7 +3,8 @@ import React, { useRef } from 'react';
import { Button } from '@/components/ui/buttons/Button'; import { Button } from '@/components/ui/buttons/Button';
import { Text } from '@/components/ui/typography/Text'; import { Text } from '@/components/ui/typography/Text';
import { Title } from '@/components/ui/typography/Title'; import { Title } from '@/components/ui/typography/Title';
import { AppSplitter, type AppSplitterRef } from './AppSplitter'; import { AppSplitter } from './AppSplitter';
import type { AppSplitterRef } from './AppSplitter.types';
import { useAppSplitterContext } from './AppSplitterProvider'; import { useAppSplitterContext } from './AppSplitterProvider';
const meta: Meta<typeof AppSplitter> = { const meta: Meta<typeof AppSplitter> = {

View File

@ -15,177 +15,17 @@ import { useCookieState } from '@/hooks/useCookieState';
import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { useMount } from '@/hooks/useMount'; import { useMount } from '@/hooks/useMount';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import type { AppSplitterRef, IAppSplitterProps, SplitterState } from './AppSplitter.types';
import { AppSplitterProvider } from './AppSplitterProvider'; import { AppSplitterProvider } from './AppSplitterProvider';
import { createAutoSaveId, easeInOutCubic, sizeToPixels } from './helpers'; import { createAutoSaveId, easeInOutCubic, sizeToPixels } from './helpers';
import { Panel } from './Panel'; import { Panel } from './Panel';
import { Splitter } from './Splitter'; import { Splitter } from './Splitter';
import { useInitialValue } from './useInitialValue';
// ================================ // ================================
// INTERFACES AND TYPES // INTERFACES AND TYPES
// ================================ // ================================
/**
* Props for the AppSplitter component
*/
interface IAppSplitterProps {
/** Content to display in the left panel */
leftChildren: React.ReactNode;
/** Content to display in the right panel */
rightChildren: React.ReactNode;
/** Unique identifier for auto-saving layout to cookies */
autoSaveId: string;
/**
* Initial preserved-side size from cookies (in pixels)
*/
initialLayout?: (`${number}px` | `${number}%` | 'auto' | number)[];
/**
* Default layout configuration as [left, right] sizes
* Can be numbers (pixels), percentages (strings like "50%"), or "auto"
*/
defaultLayout: (`${number}px` | `${number}%` | 'auto' | number)[];
/**
* Minimum size for the left panel
* Can be a number (pixels) or string (percentage)
* @default 0
*/
leftPanelMinSize?: number | string;
/**
* Minimum size for the right panel
* Can be a number (pixels) or string (percentage)
* @default 0
*/
rightPanelMinSize?: number | string;
/**
* Maximum size for the left panel
* Can be a number (pixels) or string (percentage)
* If not specified, defaults to container size
*/
leftPanelMaxSize?: number | string;
/**
* Maximum size for the right panel
* Can be a number (pixels) or string (percentage)
* If not specified, defaults to container size
*/
rightPanelMaxSize?: number | string;
/** Additional CSS classes for the container */
className?: string;
/**
* Whether the splitter can be resized by dragging
* @default true
*/
allowResize?: boolean;
/**
* Split direction
* @default 'vertical'
*/
split?: 'vertical' | 'horizontal';
/** Additional CSS classes for the splitter element */
splitterClassName?: string;
/**
* Which side to preserve when resizing
* 'left' - left panel maintains its size, right panel adjusts
* 'right' - right panel maintains its size, left panel adjusts
*/
preserveSide: 'left' | 'right';
/**
* Whether to hide the right panel completely
* @default false
*/
rightHidden?: boolean;
/**
* Whether to hide the left panel completely
* @default false
*/
leftHidden?: boolean;
/** Inline styles for the container */
style?: React.CSSProperties;
/**
* Whether to hide the splitter handle
* @default false
*/
hideSplitter?: boolean;
/** Additional CSS classes for the left panel */
leftPanelClassName?: string;
/** Additional CSS classes for the right panel */
rightPanelClassName?: string;
/**
* Whether to clear saved layout from cookies on initialization
* Can be a boolean or a function that returns a boolean based on preserved side value and container width
*/
bustStorageOnInit?: boolean | ((preservedSideValue: number | null, refSize: number) => boolean);
}
/**
* Ref interface for controlling the AppSplitter imperatively
*/
export interface AppSplitterRef {
/**
* Animate a panel to a specific width
* @param width - Target width (pixels or percentage)
* @param side - Which side to animate
* @param duration - Animation duration in milliseconds
*/
animateWidth: (
width: string | number,
side: 'left' | 'right',
duration?: number
) => Promise<void>;
/**
* Set the split sizes programmatically
* @param sizes - [left, right] sizes as pixels or percentages
*/
setSplitSizes: (sizes: [string | number, string | number]) => void;
/**
* Check if a side is closed (hidden or 0px)
* @param side - Which side to check
*/
isSideClosed: (side: 'left' | 'right') => boolean;
/**
* Get current sizes in pixels
* @returns [leftSize, rightSize] in pixels
*/
getSizesInPixels: () => [number, number];
}
/**
* Internal state interface for the splitter
*/
interface SplitterState {
/** Current container size in pixels */
containerSize: number;
/** Whether the user is currently dragging the splitter */
isDragging: boolean;
/** Whether an animation is currently in progress */
isAnimating: boolean;
/** Whether the current size was set by an animation */
sizeSetByAnimation: boolean;
/** Whether the user has interacted with the splitter */
hasUserInteracted: boolean;
}
const AppSplitterContext = createContext<{ const AppSplitterContext = createContext<{
splitterAutoSaveId: string; splitterAutoSaveId: string;
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
@ -227,6 +67,26 @@ const AppSplitterWrapper = forwardRef<AppSplitterRef, IAppSplitterProps>(
const splitterAutoSaveId = createAutoSaveId(autoSaveId); const splitterAutoSaveId = createAutoSaveId(autoSaveId);
const { splitterAutoSaveId: parentSplitterAutoSaveId } = useAppSplitterContext(); const { splitterAutoSaveId: parentSplitterAutoSaveId } = useAppSplitterContext();
const {
leftPanelMinSize,
preserveSide,
rightPanelMinSize,
leftPanelMaxSize,
rightPanelMaxSize,
} = props;
// Calculate initialValue using custom hook
const initialValue = useInitialValue({
initialLayout: props.initialLayout,
split,
preserveSide,
leftPanelMinSize,
rightPanelMinSize,
leftPanelMaxSize,
rightPanelMaxSize,
containerRef,
mounted,
});
useMount(async () => { useMount(async () => {
//we need to wait for the parent to be mounted and the container to be sized //we need to wait for the parent to be mounted and the container to be sized
@ -255,6 +115,7 @@ const AppSplitterWrapper = forwardRef<AppSplitterRef, IAppSplitterProps>(
containerRef={containerRef} containerRef={containerRef}
splitterAutoSaveId={splitterAutoSaveId} splitterAutoSaveId={splitterAutoSaveId}
split={split} split={split}
calculatedInitialValue={initialValue}
/> />
)} )}
</div> </div>
@ -271,17 +132,17 @@ AppSplitterWrapper.displayName = 'AppSplitterWrapper';
const AppSplitterBase = forwardRef< const AppSplitterBase = forwardRef<
AppSplitterRef, AppSplitterRef,
Omit<IAppSplitterProps, 'autoSaveId' | 'style' | 'className'> & { Omit<IAppSplitterProps, 'autoSaveId' | 'style' | 'className' | 'initialLayout'> & {
isVertical: boolean; isVertical: boolean;
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
splitterAutoSaveId: string; splitterAutoSaveId: string;
calculatedInitialValue: number | null;
} }
>( >(
( (
{ {
leftChildren, leftChildren,
rightChildren, rightChildren,
initialLayout,
defaultLayout, defaultLayout,
leftPanelMinSize = 0, leftPanelMinSize = 0,
rightPanelMinSize = 0, rightPanelMinSize = 0,
@ -299,6 +160,7 @@ const AppSplitterBase = forwardRef<
splitterAutoSaveId, splitterAutoSaveId,
containerRef, containerRef,
split = 'vertical', split = 'vertical',
calculatedInitialValue,
}, },
ref ref
) => { ) => {
@ -344,63 +206,10 @@ const AppSplitterBase = forwardRef<
return result; return result;
}, [defaultLayout, split, preserveSide]); }, [defaultLayout, split, preserveSide]);
const initialValue = useCallback(() => {
if (initialLayout) {
const [leftValue, rightValue] = initialLayout;
const containerSize =
split === 'vertical'
? (containerRef.current?.offsetWidth ?? 0)
: (containerRef.current?.offsetHeight ?? 0);
if (preserveSide === 'left' && leftValue === 'auto') {
return containerSize;
}
if (preserveSide === 'right' && rightValue === 'auto') {
return containerSize;
}
const preserveValue = preserveSide === 'left' ? leftValue : rightValue;
const result = sizeToPixels(preserveValue, containerSize);
// Check if the result is within min/max bounds
if (containerSize > 0) {
const minSize =
preserveSide === 'left'
? sizeToPixels(leftPanelMinSize, containerSize)
: sizeToPixels(rightPanelMinSize, containerSize);
const maxSize =
preserveSide === 'left'
? leftPanelMaxSize
? sizeToPixels(leftPanelMaxSize, containerSize)
: containerSize
: rightPanelMaxSize
? sizeToPixels(rightPanelMaxSize, containerSize)
: containerSize;
// If the result is outside the min/max bounds, use the default value
if (result < minSize || result > maxSize) {
return defaultValue();
}
}
return result;
}
return defaultValue();
}, [
initialLayout,
split,
preserveSide,
leftPanelMinSize,
rightPanelMinSize,
leftPanelMaxSize,
rightPanelMaxSize,
defaultValue,
]);
// Load saved layout from cookies // Load saved layout from cookies
const [savedLayout, setSavedLayout] = useCookieState<number | null>(splitterAutoSaveId, { const [savedLayout, setSavedLayout] = useCookieState<number | null>(splitterAutoSaveId, {
defaultValue, defaultValue,
initialValue, initialValue: () => calculatedInitialValue ?? defaultValue(),
}); });
// ================================ // ================================
@ -814,10 +623,7 @@ const AppSplitterBase = forwardRef<
const showSplitter = !leftHidden && !rightHidden; const showSplitter = !leftHidden && !rightHidden;
const sizes = useMemo<[string | number, string | number]>( const sizes: [string | number, string | number] = [`${leftSize}px`, `${rightSize}px`];
() => [`${leftSize}px`, `${rightSize}px`],
[leftSize, rightSize]
);
const content = ( const content = (
<> <>

View File

@ -0,0 +1,165 @@
export type PanelSize = `${number}px` | `${number}%` | 'auto' | number;
export type LayoutSize = [PanelSize, PanelSize];
export interface IAppSplitterProps {
/** Content to display in the left panel */
leftChildren: React.ReactNode;
/** Content to display in the right panel */
rightChildren: React.ReactNode;
/** Unique identifier for auto-saving layout to cookies */
autoSaveId: string;
/**
* Initial preserved-side size from cookies (in pixels)
*/
initialLayout?: LayoutSize;
/**
* Default layout configuration as [left, right] sizes
* Can be numbers (pixels), percentages (strings like "50%"), or "auto"
*/
defaultLayout: LayoutSize;
/**
* Minimum size for the left panel
* Can be a number (pixels) or string (percentage)
* @default 0
*/
leftPanelMinSize?: number | string;
/**
* Minimum size for the right panel
* Can be a number (pixels) or string (percentage)
* @default 0
*/
rightPanelMinSize?: number | string;
/**
* Maximum size for the left panel
* Can be a number (pixels) or string (percentage)
* If not specified, defaults to container size
*/
leftPanelMaxSize?: number | string;
/**
* Maximum size for the right panel
* Can be a number (pixels) or string (percentage)
* If not specified, defaults to container size
*/
rightPanelMaxSize?: number | string;
/** Additional CSS classes for the container */
className?: string;
/**
* Whether the splitter can be resized by dragging
* @default true
*/
allowResize?: boolean;
/**
* Split direction
* @default 'vertical'
*/
split?: 'vertical' | 'horizontal';
/** Additional CSS classes for the splitter element */
splitterClassName?: string;
/**
* Which side to preserve when resizing
* 'left' - left panel maintains its size, right panel adjusts
* 'right' - right panel maintains its size, left panel adjusts
*/
preserveSide: 'left' | 'right';
/**
* Whether to hide the right panel completely
* @default false
*/
rightHidden?: boolean;
/**
* Whether to hide the left panel completely
* @default false
*/
leftHidden?: boolean;
/** Inline styles for the container */
style?: React.CSSProperties;
/**
* Whether to hide the splitter handle
* @default false
*/
hideSplitter?: boolean;
/** Additional CSS classes for the left panel */
leftPanelClassName?: string;
/** Additional CSS classes for the right panel */
rightPanelClassName?: string;
/**
* Whether to clear saved layout from cookies on initialization
* Can be a boolean or a function that returns a boolean based on preserved side value and container width
*/
bustStorageOnInit?: boolean | ((preservedSideValue: number | null, refSize: number) => boolean);
}
/**
* Props for the AppSplitter component
*/
/**
* Ref interface for controlling the AppSplitter imperatively
*/
export interface AppSplitterRef {
/**
* Animate a panel to a specific width
* @param width - Target width (pixels or percentage)
* @param side - Which side to animate
* @param duration - Animation duration in milliseconds
*/
animateWidth: (
width: string | number,
side: 'left' | 'right',
duration?: number
) => Promise<void>;
/**
* Set the split sizes programmatically
* @param sizes - [left, right] sizes as pixels or percentages
*/
setSplitSizes: (sizes: [string | number, string | number]) => void;
/**
* Check if a side is closed (hidden or 0px)
* @param side - Which side to check
*/
isSideClosed: (side: 'left' | 'right') => boolean;
/**
* Get current sizes in pixels
* @returns [leftSize, rightSize] in pixels
*/
getSizesInPixels: () => [number, number];
}
/**
* Internal state interface for the splitter
*/
export interface SplitterState {
/** Current container size in pixels */
containerSize: number;
/** Whether the user is currently dragging the splitter */
isDragging: boolean;
/** Whether an animation is currently in progress */
isAnimating: boolean;
/** Whether the current size was set by an animation */
sizeSetByAnimation: boolean;
/** Whether the user has interacted with the splitter */
hasUserInteracted: boolean;
}

View File

@ -1,4 +1,5 @@
export { AppSplitter, type AppSplitterRef } from './AppSplitter'; export { AppSplitter } from './AppSplitter';
export type { AppSplitterRef, IAppSplitterProps, LayoutSize, PanelSize } from './AppSplitter.types';
export { AppSplitterProvider, useAppSplitterContext } from './AppSplitterProvider'; export { AppSplitterProvider, useAppSplitterContext } from './AppSplitterProvider';
export { createAutoSaveId } from './helpers'; export { createAutoSaveId } from './helpers';
export { Panel } from './Panel'; export { Panel } from './Panel';

View File

@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { sizeToPixels } from './helpers';
interface UseInitialValueProps {
initialLayout?: (`${number}px` | `${number}%` | 'auto' | number)[];
split: 'vertical' | 'horizontal';
preserveSide: 'left' | 'right';
leftPanelMinSize?: number | string;
rightPanelMinSize?: number | string;
leftPanelMaxSize?: number | string;
rightPanelMaxSize?: number | string;
containerRef: React.RefObject<HTMLDivElement | null>;
mounted: boolean;
}
/**
* Custom hook to calculate the initial value for the AppSplitter component
* based on the initial layout and container size constraints
*/
export const useInitialValue = ({
initialLayout,
split,
preserveSide,
leftPanelMinSize = 0,
rightPanelMinSize = 0,
leftPanelMaxSize,
rightPanelMaxSize,
containerRef,
mounted,
}: UseInitialValueProps): number | null => {
return useMemo(() => {
if (initialLayout) {
const [leftValue, rightValue] = initialLayout;
const containerSize =
split === 'vertical'
? (containerRef.current?.offsetWidth ?? 0)
: (containerRef.current?.offsetHeight ?? 0);
if (preserveSide === 'left' && leftValue === 'auto') {
return containerSize;
}
if (preserveSide === 'right' && rightValue === 'auto') {
return containerSize;
}
const preserveValue = preserveSide === 'left' ? leftValue : rightValue;
const result = sizeToPixels(preserveValue, containerSize);
// Check if the result is within min/max bounds
if (containerSize > 0) {
const minSize =
preserveSide === 'left'
? sizeToPixels(leftPanelMinSize, containerSize)
: sizeToPixels(rightPanelMinSize, containerSize);
const maxSize =
preserveSide === 'left'
? leftPanelMaxSize
? sizeToPixels(leftPanelMaxSize, containerSize)
: containerSize
: rightPanelMaxSize
? sizeToPixels(rightPanelMaxSize, containerSize)
: containerSize;
// If the result is outside the min/max bounds, return null to use default value
if (result < minSize || result > maxSize) {
return null;
}
}
return result;
}
return null;
}, [
mounted,
initialLayout,
split,
preserveSide,
leftPanelMinSize,
rightPanelMinSize,
leftPanelMaxSize,
rightPanelMaxSize,
]);
};