From 9e0b975999d01b7714d3d51a922f850639fcdeee Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 13 Aug 2025 11:35:31 -0600 Subject: [PATCH] Better and more stable splitter --- .../src/components/ui/layouts/AppLayout.tsx | 5 +- .../AppSplitter/AppSplitter.stories.tsx | 3 +- .../ui/layouts/AppSplitter/AppSplitter.tsx | 250 ++---------------- .../layouts/AppSplitter/AppSplitter.types.ts | 165 ++++++++++++ .../ui/layouts/AppSplitter/index.ts | 3 +- .../layouts/AppSplitter/useInitialValue.tsx | 82 ++++++ 6 files changed, 282 insertions(+), 226 deletions(-) create mode 100644 apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.types.ts create mode 100644 apps/web-tss/src/components/ui/layouts/AppSplitter/useInitialValue.tsx diff --git a/apps/web-tss/src/components/ui/layouts/AppLayout.tsx b/apps/web-tss/src/components/ui/layouts/AppLayout.tsx index 1799d1819..b1c8c4840 100644 --- a/apps/web-tss/src/components/ui/layouts/AppLayout.tsx +++ b/apps/web-tss/src/components/ui/layouts/AppLayout.tsx @@ -1,8 +1,9 @@ import type React from 'react'; import { cn } from '@/lib/utils'; +import type { LayoutSize } from './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) @@ -17,7 +18,7 @@ export const AppLayout: React.FC< floating?: boolean; className?: string; sidebar?: React.ReactNode; - defaultLayout?: [string, string]; + defaultLayout?: LayoutSize; leftHidden?: boolean; autoSaveId?: string; }> diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.stories.tsx b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.stories.tsx index 2a23050e7..5c099f990 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.stories.tsx +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.stories.tsx @@ -3,7 +3,8 @@ import React, { useRef } from 'react'; import { Button } from '@/components/ui/buttons/Button'; import { Text } from '@/components/ui/typography/Text'; 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'; const meta: Meta = { diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.tsx b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.tsx index f90506e50..cb8628c27 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.tsx +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.tsx @@ -15,177 +15,17 @@ import { useCookieState } from '@/hooks/useCookieState'; import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { useMount } from '@/hooks/useMount'; import { cn } from '@/lib/classMerge'; +import type { AppSplitterRef, IAppSplitterProps, SplitterState } from './AppSplitter.types'; import { AppSplitterProvider } from './AppSplitterProvider'; import { createAutoSaveId, easeInOutCubic, sizeToPixels } from './helpers'; import { Panel } from './Panel'; import { Splitter } from './Splitter'; +import { useInitialValue } from './useInitialValue'; // ================================ // 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; - - /** - * 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; @@ -227,6 +67,26 @@ const AppSplitterWrapper = forwardRef( const splitterAutoSaveId = createAutoSaveId(autoSaveId); 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 () => { //we need to wait for the parent to be mounted and the container to be sized @@ -255,6 +115,7 @@ const AppSplitterWrapper = forwardRef( containerRef={containerRef} splitterAutoSaveId={splitterAutoSaveId} split={split} + calculatedInitialValue={initialValue} /> )} @@ -271,17 +132,17 @@ AppSplitterWrapper.displayName = 'AppSplitterWrapper'; const AppSplitterBase = forwardRef< AppSplitterRef, - Omit & { + Omit & { isVertical: boolean; containerRef: React.RefObject; splitterAutoSaveId: string; + calculatedInitialValue: number | null; } >( ( { leftChildren, rightChildren, - initialLayout, defaultLayout, leftPanelMinSize = 0, rightPanelMinSize = 0, @@ -299,6 +160,7 @@ const AppSplitterBase = forwardRef< splitterAutoSaveId, containerRef, split = 'vertical', + calculatedInitialValue, }, ref ) => { @@ -344,63 +206,10 @@ const AppSplitterBase = forwardRef< return result; }, [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 - const [savedLayout, setSavedLayout] = useCookieState(splitterAutoSaveId, { defaultValue, - initialValue, + initialValue: () => calculatedInitialValue ?? defaultValue(), }); // ================================ @@ -814,10 +623,7 @@ const AppSplitterBase = forwardRef< const showSplitter = !leftHidden && !rightHidden; - const sizes = useMemo<[string | number, string | number]>( - () => [`${leftSize}px`, `${rightSize}px`], - [leftSize, rightSize] - ); + const sizes: [string | number, string | number] = [`${leftSize}px`, `${rightSize}px`]; const content = ( <> diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.types.ts b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.types.ts new file mode 100644 index 000000000..a8b273f59 --- /dev/null +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.types.ts @@ -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; + + /** + * 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; +} diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/index.ts b/apps/web-tss/src/components/ui/layouts/AppSplitter/index.ts index fb24ec1d3..fdaba85ce 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/index.ts +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/index.ts @@ -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 { createAutoSaveId } from './helpers'; export { Panel } from './Panel'; diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/useInitialValue.tsx b/apps/web-tss/src/components/ui/layouts/AppSplitter/useInitialValue.tsx new file mode 100644 index 000000000..6fed46536 --- /dev/null +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/useInitialValue.tsx @@ -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; + 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, + ]); +};