Add initialValue support to useCookieState and create AppSplitter component

Co-authored-by: natemkelley <natemkelley@gmail.com>
This commit is contained in:
Cursor Agent 2025-08-13 01:07:37 +00:00
parent 66623fb2a5
commit 3c6b56dbee
3 changed files with 874 additions and 2 deletions

View File

@ -0,0 +1,862 @@
'use client';
import React, {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useCookieState } from '@/hooks/useCookieState';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { useMount } from '@/hooks/useMount';
import { cn } from '@/lib/classMerge';
import { AppSplitterProvider } from './AppSplitterProvider';
import { createAutoSaveId, easeInOutCubic, sizeToPixels } from './helpers';
import { Panel } from './Panel';
import { Splitter } from './Splitter';
// ================================
// 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 cookie */
autoSaveId: string;
/**
* Default layout configuration as [left, right] sizes
* Can be numbers (pixels), percentages (strings like "50%"), or "auto"
*/
defaultLayout: (string | number)[];
/**
* Initial layout configuration as [left, right] sizes. If provided,
* this will be converted to the preserved side's pixel value and used
* as the cookie initialValue (subject to bust logic). If not provided,
* we fallback to the cookie's parsed value, and ultimately to defaultLayout.
*/
initialLayout?: (string | 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 storage 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);
/**
* Whether to render the left panel content
* @default true
*/
renderLeftPanel?: boolean;
/**
* Whether to render the right panel content
* @default true
*/
renderRightPanel?: 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<{
splitterAutoSaveId: string;
containerRef: React.RefObject<HTMLDivElement | null>;
}>({
splitterAutoSaveId: '',
containerRef: { current: null },
});
const useAppSplitterContext = () => {
return useContext(AppSplitterContext);
};
// ================================
// MAIN COMPONENT
// ================================
/**
* AppSplitter - A resizable split panel component with cookie persistence
*
* Features:
* - Horizontal or vertical splitting
* - Drag to resize with constraints
* - Auto-save layout to cookie
* - Smooth animations
* - Responsive container resizing
* - Panel hiding/showing
* - Imperative API via ref
*/
const AppSplitterWrapper = forwardRef<AppSplitterRef, IAppSplitterProps>(
({ autoSaveId, style, className, split = 'vertical', ...props }, componentRef) => {
const containerRef = useRef<HTMLDivElement>(null);
const isVertical = split === 'vertical';
const [mounted, setMounted] = useState(!props.bustStorageOnInit);
const splitterAutoSaveId = createAutoSaveId(autoSaveId);
const { splitterAutoSaveId: parentSplitterAutoSaveId } = useAppSplitterContext();
useMount(async () => {
// we need to wait for the parent to be mounted and the container to be sized
if (parentSplitterAutoSaveId || !containerRef.current?.offsetWidth) {
requestAnimationFrame(() => {
setMounted(true);
});
} else {
setMounted(true);
}
});
return (
<AppSplitterContext.Provider value={{ splitterAutoSaveId, containerRef }}>
<div
ref={containerRef}
id={splitterAutoSaveId}
className={cn('flex h-full w-full', isVertical ? 'flex-row' : 'flex-col', className)}
style={style}
>
{mounted && (
<AppSplitterBase
{...props}
ref={componentRef}
isVertical={isVertical}
containerRef={containerRef}
splitterAutoSaveId={splitterAutoSaveId}
split={split}
/>
)}
</div>
</AppSplitterContext.Provider>
);
}
);
AppSplitterWrapper.displayName = 'AppSplitterWrapper';
// ================================
// CORE IMPLEMENTATION
// ================================
const AppSplitterBase = forwardRef<
AppSplitterRef,
Omit<IAppSplitterProps, 'autoSaveId' | 'style' | 'className'> & {
isVertical: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
splitterAutoSaveId: string;
}
>(
(
{
leftChildren,
rightChildren,
defaultLayout,
initialLayout,
leftPanelMinSize = 0,
rightPanelMinSize = 0,
leftPanelMaxSize,
rightPanelMaxSize,
allowResize = true,
splitterClassName,
preserveSide,
rightHidden = false,
leftHidden = false,
renderLeftPanel = true,
renderRightPanel = true,
hideSplitter: hideSplitterProp = false,
leftPanelClassName,
rightPanelClassName,
isVertical,
splitterAutoSaveId,
containerRef,
bustStorageOnInit,
split = 'vertical',
},
ref
) => {
// ================================
// REFS AND STATE
// ================================
const startPosRef = useRef(0);
const startSizeRef = useRef(0);
const animationRef = useRef<number | null>(null);
// Consolidated state management
const [state, setState] = useState<SplitterState>({
containerSize:
split === 'vertical'
? (containerRef.current?.offsetWidth ?? 0)
: (containerRef.current?.offsetHeight ?? 0),
isDragging: false,
isAnimating: false,
sizeSetByAnimation: false,
hasUserInteracted: false,
});
// ================================
// STORAGE MANAGEMENT
// ================================
const bustStorageOnInitSplitter = (preservedSideValue: number | null) => {
const refSize =
split === 'vertical'
? containerRef.current?.offsetWidth
: containerRef.current?.offsetHeight;
// Don't bust storage if container hasn't been sized yet
if (!refSize || refSize === 0) {
return false;
}
return typeof bustStorageOnInit === 'function'
? bustStorageOnInit(preservedSideValue, refSize)
: !!bustStorageOnInit;
};
const defaultValue = () => {
const [leftValue, rightValue] = defaultLayout;
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);
return result;
};
const initialValueFromProp = (() => {
if (!initialLayout) return undefined;
const [leftValue, rightValue] = initialLayout;
const containerSize =
split === 'vertical'
? (containerRef.current?.offsetWidth ?? 0)
: (containerRef.current?.offsetHeight ?? 0);
if (preserveSide === 'left') {
if (leftValue === 'auto') return containerSize;
return sizeToPixels(leftValue, containerSize);
}
if (rightValue === 'auto') return containerSize;
return sizeToPixels(rightValue, containerSize);
})();
// Load saved layout from cookie
const [savedLayout, setSavedLayout] = useCookieState<number>(splitterAutoSaveId, {
defaultValue,
bustStorageOnInit: (layout) => bustStorageOnInitSplitter(layout ?? null),
initialValue: initialValueFromProp,
});
// ================================
// SIZE CALCULATION LOGIC
// ================================
// Calculate initial size based on default layout
const calculateInitialSize = useMemoizedFn((containerSize: number): number => {
if (containerSize === 0) return 0;
const [leftValue, rightValue] = defaultLayout;
if (preserveSide === 'left' && leftValue !== 'auto') {
return sizeToPixels(leftValue, containerSize);
} else if (preserveSide === 'right' && rightValue !== 'auto') {
return sizeToPixels(rightValue, containerSize);
}
if (preserveSide === 'left') {
return containerSize;
}
if (preserveSide === 'right') {
return containerSize;
}
return 280; // Default fallback
});
// Calculate size constraints once per container size change
const constraints = useMemo(() => {
if (!state.containerSize) return null;
return {
leftMin: sizeToPixels(leftPanelMinSize, state.containerSize),
leftMax: leftPanelMaxSize
? sizeToPixels(leftPanelMaxSize, state.containerSize)
: state.containerSize,
rightMin: sizeToPixels(rightPanelMinSize, state.containerSize),
rightMax: rightPanelMaxSize
? sizeToPixels(rightPanelMaxSize, state.containerSize)
: state.containerSize,
};
}, [
state.containerSize,
leftPanelMinSize,
rightPanelMinSize,
leftPanelMaxSize,
rightPanelMaxSize,
]);
// Apply constraints to a size value
const applyConstraints = useMemoizedFn((size: number): number => {
if (!constraints || !state.containerSize) return size;
let constrainedSize = size;
if (preserveSide === 'left') {
constrainedSize = Math.max(constraints.leftMin, Math.min(size, constraints.leftMax));
const rightSize = state.containerSize - constrainedSize;
if (rightSize < constraints.rightMin) {
constrainedSize = state.containerSize - constraints.rightMin;
}
if (rightSize > constraints.rightMax) {
constrainedSize = state.containerSize - constraints.rightMax;
}
} else {
constrainedSize = Math.max(constraints.rightMin, Math.min(size, constraints.rightMax));
const leftSize = state.containerSize - constrainedSize;
if (leftSize < constraints.leftMin) {
constrainedSize = state.containerSize - constraints.leftMin;
}
if (leftSize > constraints.leftMax) {
constrainedSize = state.containerSize - constraints.leftMax;
}
}
return constrainedSize;
});
// Calculate panel sizes with simplified logic
const { leftSize, rightSize } = useMemo(() => {
const { containerSize, isAnimating, sizeSetByAnimation, isDragging, hasUserInteracted } =
state;
if (!containerSize) {
return { leftSize: 0, rightSize: 0 };
}
// Handle hidden panels
if (leftHidden && !rightHidden) return { leftSize: 0, rightSize: containerSize };
if (rightHidden && !leftHidden) return { leftSize: containerSize, rightSize: 0 };
if (leftHidden && rightHidden) return { leftSize: 0, rightSize: 0 };
const currentSize = savedLayout ?? 0;
// Check if a panel is at 0px and should remain at 0px
const isLeftPanelZero = currentSize === 0 && preserveSide === 'left';
const isRightPanelZero = currentSize === 0 && preserveSide === 'right';
// If a panel is at 0px, keep it at 0px and give all space to the other panel
if (isLeftPanelZero) {
return { leftSize: 0, rightSize: containerSize };
}
if (isRightPanelZero) {
return { leftSize: containerSize, rightSize: 0 };
}
// During animation or when size was set by animation (and not currently dragging),
// don't apply constraints to allow smooth animations
const shouldApplyConstraints =
!isAnimating && !sizeSetByAnimation && hasUserInteracted && !isDragging;
const finalSize = shouldApplyConstraints ? applyConstraints(currentSize) : currentSize;
if (preserveSide === 'left') {
const left = Math.max(0, finalSize);
const right = Math.max(0, containerSize - left);
return { leftSize: left, rightSize: right };
} else {
const right = Math.max(0, finalSize);
const left = Math.max(0, containerSize - right);
return { leftSize: left, rightSize: right };
}
}, [state, savedLayout, leftHidden, rightHidden, preserveSide, applyConstraints]);
// ================================
// CONTAINER RESIZE HANDLING
// ================================
// Update container size and handle initialization
const updateContainerSize = useMemoizedFn(() => {
if (!containerRef.current) return;
const size = isVertical
? containerRef.current.offsetWidth
: containerRef.current.offsetHeight;
setState((prev) => {
if (prev.containerSize === size) return prev;
const newState = { ...prev, containerSize: size };
// Initialize if needed - only when container has actual size
if (!prev.isAnimating && size > 0) {
// Set initial size if no saved layout exists
if (savedLayout === null || savedLayout === undefined) {
const initialSize = calculateInitialSize(size);
setSavedLayout(initialSize);
}
}
// Handle container resize when one panel is at 0px
// Only adjust layout during resize if we're not currently animating
if (prev.containerSize > 0 && size > 0 && savedLayout != null && !prev.isAnimating) {
const currentSavedSize = savedLayout;
// If a panel is at 0px, preserve the other panel's size during resize
if (currentSavedSize === 0) {
if (preserveSide === 'left') {
// Left panel is 0px, preserve right panel's size (which is the full previous container)
setSavedLayout(0); // Keep left at 0
} else {
// Right panel is 0px, preserve left panel's size (which is the full previous container)
setSavedLayout(0); // Keep right at 0
}
} else {
// Check if the current layout represents a panel that should remain preserved
const oldContainerSize = prev.containerSize;
if (preserveSide === 'left') {
// If left panel was at full size (right was 0), keep it at full size
if (currentSavedSize === oldContainerSize) {
setSavedLayout(size);
}
} else {
// If right panel was at full size (left was 0), keep it at full size
if (currentSavedSize === oldContainerSize) {
setSavedLayout(size);
}
}
}
}
return newState;
});
});
// ================================
// ANIMATION LOGIC
// ================================
// Animation function
const animateWidth = useMemoizedFn(
async (
width: string | number,
side: 'left' | 'right',
duration: number = 250
): Promise<void> => {
return new Promise((resolve) => {
if (!state.containerSize) {
resolve();
return;
}
setState((prev) => ({ ...prev, isAnimating: true }));
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
const targetPixels = sizeToPixels(width, state.containerSize);
let targetSize: number;
if (side === 'left') {
targetSize =
preserveSide === 'left' ? targetPixels : state.containerSize - targetPixels;
} else {
targetSize =
preserveSide === 'right' ? targetPixels : state.containerSize - targetPixels;
}
const startSize = savedLayout ?? 0;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeInOutCubic(progress);
const currentSize = startSize + (targetSize - startSize) * easedProgress;
setSavedLayout(currentSize);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
animationRef.current = null;
setState((prev) => ({
...prev,
isAnimating: false,
sizeSetByAnimation: true,
}));
resolve();
}
};
animationRef.current = requestAnimationFrame(animate);
});
}
);
// ================================
// IMPERATIVE API METHODS
// ================================
// Set split sizes function
const setSplitSizes = useMemoizedFn((sizes: [string | number, string | number]) => {
if (!state.containerSize) return;
const [leftValue, rightValue] = sizes;
// Calculate both potential sizes
const leftPixels = leftValue !== 'auto' ? sizeToPixels(leftValue, state.containerSize) : 0;
const rightPixels = rightValue !== 'auto' ? sizeToPixels(rightValue, state.containerSize) : 0;
// Determine which side to actually preserve based on which panel has content (non-zero size)
let effectivePreserveSide = preserveSide;
if (preserveSide === 'left' && leftValue !== 'auto') {
// If left panel would be 0px, preserve right side instead
if (leftPixels === 0 && rightValue !== 'auto') {
effectivePreserveSide = 'right';
}
} else if (preserveSide === 'right' && rightValue !== 'auto') {
// If right panel would be 0px, preserve left side instead
if (rightPixels === 0 && leftValue !== 'auto') {
effectivePreserveSide = 'left';
}
}
// Apply the preservation logic with the effective side
if (effectivePreserveSide === 'left' && leftValue !== 'auto') {
setSavedLayout(leftPixels);
} else if (effectivePreserveSide === 'right' && rightValue !== 'auto') {
setSavedLayout(rightPixels);
}
setState((prev) => ({ ...prev, sizeSetByAnimation: false }));
});
// Check if side is closed
const isSideClosed = useCallback(
(side: 'left' | 'right') => {
if (side === 'left') {
return leftHidden || leftSize === 0;
} else {
return rightHidden || rightSize === 0;
}
},
[leftHidden, rightHidden, leftSize, rightSize]
);
// Get sizes in pixels
const getSizesInPixels = useCallback((): [number, number] => {
return [leftSize, rightSize];
}, [leftSize, rightSize]);
// ================================
// MOUSE EVENT HANDLERS
// ================================
const handleMouseDown = useMemoizedFn((e: React.MouseEvent) => {
if (!allowResize) return;
setState((prev) => ({
...prev,
isDragging: true,
hasUserInteracted: true,
sizeSetByAnimation: false,
}));
startPosRef.current = isVertical ? e.clientX : e.clientY;
startSizeRef.current = savedLayout ?? 0;
e.preventDefault();
});
const handleMouseMove = useMemoizedFn((e: MouseEvent) => {
if (!state.isDragging || !state.containerSize) return;
const currentPos = isVertical ? e.clientX : e.clientY;
const delta = currentPos - startPosRef.current;
let newSize: number;
if (preserveSide === 'left') {
newSize = startSizeRef.current + delta;
} else {
newSize = startSizeRef.current - delta;
}
const constrainedSize = applyConstraints(newSize);
setSavedLayout(constrainedSize);
});
const handleMouseUp = useMemoizedFn(() => {
setState((prev) => ({ ...prev, isDragging: false }));
});
// ================================
// EFFECTS AND LIFECYCLE
// ================================
// Container resize monitoring
useEffect(() => {
updateContainerSize();
// If container is still 0 after layout, try again with animation frame
if (containerRef.current?.offsetWidth === 0 || containerRef.current?.offsetHeight === 0) {
requestAnimationFrame(updateContainerSize);
}
const resizeObserver = new ResizeObserver(updateContainerSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
window.addEventListener('resize', updateContainerSize);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updateContainerSize);
};
}, [updateContainerSize]);
// Mouse event handling during drag
useEffect(() => {
if (state.isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = isVertical ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}
}, [state.isDragging, handleMouseMove, handleMouseUp, isVertical]);
// Expose imperative API via ref
useImperativeHandle(
ref,
() => ({
animateWidth,
setSplitSizes,
isSideClosed,
getSizesInPixels,
}),
[animateWidth, setSplitSizes, isSideClosed, getSizesInPixels]
);
// ================================
// RENDER LOGIC
// ================================
// Determine if splitter should be hidden
const shouldHideSplitter =
hideSplitterProp || (leftHidden && rightHidden) || leftSize === 0 || rightSize === 0;
const showSplitter = !leftHidden && !rightHidden;
const sizes = useMemo<[string | number, string | number]>(
() => [`${leftSize}px`, `${rightSize}px`],
[leftSize, rightSize]
);
const content = (
<>
<Panel
className={leftPanelClassName}
width={isVertical ? leftSize : 'auto'}
height={!isVertical ? leftSize : 'auto'}
hidden={leftHidden}
>
{renderLeftPanel && leftChildren}
</Panel>
{showSplitter && (
<Splitter
onMouseDown={handleMouseDown}
isDragging={state.isDragging}
split={split}
className={splitterClassName}
disabled={!allowResize}
hidden={shouldHideSplitter}
/>
)}
<Panel
className={rightPanelClassName}
width={isVertical ? rightSize : 'auto'}
height={!isVertical ? rightSize : 'auto'}
hidden={rightHidden}
>
{renderRightPanel && rightChildren}
</Panel>
</>
);
return (
<AppSplitterProvider
animateWidth={animateWidth}
setSplitSizes={setSplitSizes}
isSideClosed={isSideClosed}
getSizesInPixels={getSizesInPixels}
sizes={sizes}
>
{content}
</AppSplitterProvider>
);
}
);
export const AppSplitter = React.memo(AppSplitterWrapper);
AppSplitter.displayName = 'AppSplitter';
AppSplitterBase.displayName = 'AppSplitterBase';

View File

@ -30,6 +30,7 @@ interface Options<T> {
bustStorageOnInit?: boolean | ((layout: T) => boolean);
expirationTime?: number;
cookieOptions?: CookieOptions;
initialValue?: T;
}
// Helper function to parse cookies
@ -98,7 +99,8 @@ export function useCookieState<T>(
onError,
bustStorageOnInit = false,
expirationTime = DEFAULT_EXPIRATION_TIME,
cookieOptions = {}
cookieOptions = {},
initialValue
} = options || {};
const executeBustStorage = useMemoizedFn(() => {
@ -113,6 +115,14 @@ export function useCookieState<T>(
return executeBustStorage();
}
// If an explicit initialValue is provided, prefer it (subject to bust logic)
if (initialValue !== undefined) {
if (typeof bustStorageOnInit === 'function' && bustStorageOnInit(initialValue)) {
return executeBustStorage();
}
return initialValue;
}
try {
const cookies = parseCookies();
const cookieValue = cookies[key];

File diff suppressed because one or more lines are too long