From 23472b86c3fb1baca7abf07f00e016c8ff1515db Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 13 Aug 2025 16:08:21 -0600 Subject: [PATCH] pass cookies to layout components --- apps/web-tss/biome.json | 3 + .../src/components/ui/layouts/AppLayout.tsx | 12 +- .../AppSplitter/AppSplitter.stories.tsx | 4 +- .../ui/layouts/AppSplitter/AppSplitter.tsx | 106 ++++++++++-------- .../layouts/AppSplitter/AppSplitter.types.ts | 9 +- .../AppSplitter/create-auto-save-id.ts | 3 + .../ui/layouts/AppSplitter/helpers.ts | 2 - .../ui/layouts/AppSplitter/index.ts | 2 +- apps/web-tss/src/hooks/useCookieState.tsx | 1 - apps/web-tss/src/layouts/PrimaryAppLayout.tsx | 24 ++++ apps/web-tss/src/routes/app.home.tsx | 22 +++- apps/web-tss/src/routes/app.tsx | 14 ++- apps/web-tss/src/serverFns/getAppLayout.ts | 38 +++++++ apps/web-tss/vite.config.ts | 18 +-- 14 files changed, 175 insertions(+), 83 deletions(-) create mode 100644 apps/web-tss/src/components/ui/layouts/AppSplitter/create-auto-save-id.ts create mode 100644 apps/web-tss/src/layouts/PrimaryAppLayout.tsx create mode 100644 apps/web-tss/src/serverFns/getAppLayout.ts diff --git a/apps/web-tss/biome.json b/apps/web-tss/biome.json index 9d5589c91..a39f797c6 100644 --- a/apps/web-tss/biome.json +++ b/apps/web-tss/biome.json @@ -45,6 +45,9 @@ "noExcessiveCognitiveComplexity": "off", "noForEach": "off" }, + "nursery": { + "useSortedClasses": "off" + }, "performance": { "noDelete": "error" }, diff --git a/apps/web-tss/src/components/ui/layouts/AppLayout.tsx b/apps/web-tss/src/components/ui/layouts/AppLayout.tsx index 1369b124f..4f5b7ee97 100644 --- a/apps/web-tss/src/components/ui/layouts/AppLayout.tsx +++ b/apps/web-tss/src/components/ui/layouts/AppLayout.tsx @@ -1,9 +1,8 @@ import type React from 'react'; import { cn } from '@/lib/utils'; -import type { LayoutSize } from './AppSplitter'; import { AppSplitter } from './AppSplitter/AppSplitter'; - -const DEFAULT_LAYOUT: LayoutSize = ['230px', 'auto']; +import type { LayoutSize } from './AppSplitter/AppSplitter.types'; +import { createAutoSaveId } from './AppSplitter/create-auto-save-id'; /** * @param floating - Applies floating styles with padding and border (default: true) @@ -21,7 +20,7 @@ export const AppLayout: React.FC< defaultLayout: LayoutSize; initialLayout: LayoutSize | null; leftHidden?: boolean; - autoSaveId?: string; + autoSaveId: string; }> > = ({ children, @@ -44,7 +43,7 @@ export const AppLayout: React.FC< return ( ); }; + +export type { LayoutSize }; +export { createAutoSaveId }; 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 c69a27272..e9c1f8883 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 @@ -39,7 +39,7 @@ type Story = StoryObj; // Helper components for demo content const LeftContent = ({ title = 'Left Panel' }: { title?: string }) => ( -
+
{title} This is the left panel content. Try resizing the panels by dragging the splitter. @@ -836,7 +836,7 @@ export const CollapsedLeftPanel: Story = {
), autoSaveId: 'collapsed-left-panel', - defaultLayout: ['0%', '100%'], + defaultLayout: ['auto', '100%'], leftPanelMinSize: '200px', preserveSide: 'right', }, 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 07dbc1646..fdbeae42e 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.tsx +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.tsx @@ -17,7 +17,8 @@ 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 { createAutoSaveId } from './create-auto-save-id'; +import { easeInOutCubic, sizeToPixels } from './helpers'; import { Panel } from './Panel'; import { Splitter } from './Splitter'; import { useDefaultValue } from './useDefaultValue'; @@ -109,17 +110,15 @@ const AppSplitterWrapper = forwardRef( className={cn('flex h-full w-full', isVertical ? 'flex-row' : 'flex-col', className)} style={style} > - {mounted && ( - - )} +
); @@ -285,33 +284,19 @@ const AppSplitterBase = forwardRef< return constrainedSize; }); - // Calculate panel sizes with simplified logic - const { leftSize, rightSize } = useMemo(() => { + // Calculate preserved panel size - non-preserved panel will use flex-1 + const preservedPanelSize = 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 }; + if (leftHidden || rightHidden) return 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 }; - } + // Check if the preserved panel is at 0px + const isPanelZero = currentSize === 0; + if (isPanelZero) return 0; // During animation or when size was set by animation (and not currently dragging), // don't apply constraints to allow smooth animations @@ -319,17 +304,17 @@ const AppSplitterBase = forwardRef< !isAnimating && !sizeSetByAnimation && hasUserInteracted && !isDragging; const finalSize = shouldApplyConstraints ? applyConstraints(currentSize) : currentSize; + return Math.max(0, finalSize); + }, [state, savedLayout, leftHidden, rightHidden, applyConstraints]); + // Determine panel sizes based on preserve side + const { leftSize, rightSize } = useMemo(() => { if (preserveSide === 'left') { - const left = Math.max(0, finalSize); - const right = Math.max(0, containerSize - left); - return { leftSize: left, rightSize: right }; + return { leftSize: preservedPanelSize, rightSize: 'auto' as const }; } else { - const right = Math.max(0, finalSize); - const left = Math.max(0, containerSize - right); - return { leftSize: left, rightSize: right }; + return { leftSize: 'auto' as const, rightSize: preservedPanelSize }; } - }, [state, savedLayout, leftHidden, rightHidden, preserveSide, applyConstraints]); + }, [preservedPanelSize, preserveSide]); // ================================ // CONTAINER RESIZE HANDLING @@ -507,18 +492,44 @@ const AppSplitterBase = forwardRef< const isSideClosed = useCallback( (side: 'left' | 'right') => { if (side === 'left') { - return leftHidden || leftSize === 0; + return ( + leftHidden || + leftSize === 0 || + (preserveSide === 'right' && preservedPanelSize === state.containerSize) + ); } else { - return rightHidden || rightSize === 0; + return ( + rightHidden || + rightSize === 0 || + (preserveSide === 'left' && preservedPanelSize === state.containerSize) + ); } }, - [leftHidden, rightHidden, leftSize, rightSize] + [ + leftHidden, + rightHidden, + leftSize, + rightSize, + preserveSide, + preservedPanelSize, + state.containerSize, + ] ); // Get sizes in pixels const getSizesInPixels = useCallback((): [number, number] => { - return [leftSize, rightSize]; - }, [leftSize, rightSize]); + const containerSize = state.containerSize; + + if (preserveSide === 'left') { + const left = typeof leftSize === 'number' ? leftSize : 0; + const right = containerSize - left; + return [left, right]; + } else { + const right = typeof rightSize === 'number' ? rightSize : 0; + const left = containerSize - right; + return [left, right]; + } + }, [leftSize, rightSize, preserveSide, state.containerSize]); // ================================ // MOUSE EVENT HANDLERS @@ -622,11 +633,14 @@ const AppSplitterBase = forwardRef< // Determine if splitter should be hidden const shouldHideSplitter = - hideSplitterProp || (leftHidden && rightHidden) || leftSize === 0 || rightSize === 0; + hideSplitterProp || (leftHidden && rightHidden) || preservedPanelSize === 0; const showSplitter = !leftHidden && !rightHidden; - const sizes: [string | number, string | number] = [`${leftSize}px`, `${rightSize}px`]; + const sizes: [string | number, string | number] = + preserveSide === 'left' + ? [`${preservedPanelSize}px`, 'auto'] + : ['auto', `${preservedPanelSize}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 index 6cd789a1c..7610b66c0 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.types.ts +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/AppSplitter.types.ts @@ -1,5 +1,6 @@ export type PanelSize = `${number}px` | `${number}%` | 'auto' | number; -export type LayoutSize = [PanelSize, PanelSize]; +type PanelSizeWithAuto = Exclude; +export type LayoutSize = ['auto', PanelSizeWithAuto] | [PanelSizeWithAuto, 'auto']; export interface IAppSplitterProps { /** Content to display in the left panel */ @@ -101,12 +102,6 @@ export interface IAppSplitterProps { /** 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); } /** diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/create-auto-save-id.ts b/apps/web-tss/src/components/ui/layouts/AppSplitter/create-auto-save-id.ts new file mode 100644 index 000000000..2f4daa12a --- /dev/null +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/create-auto-save-id.ts @@ -0,0 +1,3 @@ +const PREFIX = 'app-splitter'; + +export const createAutoSaveId = (id: string) => `${PREFIX}-${id}`; diff --git a/apps/web-tss/src/components/ui/layouts/AppSplitter/helpers.ts b/apps/web-tss/src/components/ui/layouts/AppSplitter/helpers.ts index ddee26087..810e119c5 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/helpers.ts +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/helpers.ts @@ -1,5 +1,3 @@ -export const createAutoSaveId = (id: string) => `app-splitter-${id}`; - // Helper function to convert size values to pixels export const sizeToPixels = (size: string | number, containerSize: number): number => { if (typeof size === 'number') { 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 fdaba85ce..34d59e53d 100644 --- a/apps/web-tss/src/components/ui/layouts/AppSplitter/index.ts +++ b/apps/web-tss/src/components/ui/layouts/AppSplitter/index.ts @@ -1,6 +1,6 @@ export { AppSplitter } from './AppSplitter'; export type { AppSplitterRef, IAppSplitterProps, LayoutSize, PanelSize } from './AppSplitter.types'; export { AppSplitterProvider, useAppSplitterContext } from './AppSplitterProvider'; -export { createAutoSaveId } from './helpers'; +export { createAutoSaveId } from './create-auto-save-id'; export { Panel } from './Panel'; export { Splitter } from './Splitter'; diff --git a/apps/web-tss/src/hooks/useCookieState.tsx b/apps/web-tss/src/hooks/useCookieState.tsx index 36fdd61ef..cd9d19e4a 100644 --- a/apps/web-tss/src/hooks/useCookieState.tsx +++ b/apps/web-tss/src/hooks/useCookieState.tsx @@ -152,7 +152,6 @@ export function useCookieState( value: JSON.parse(serializer(newState)), timestamp: Date.now(), }; - console.log(key, storageData); setCookie(key, JSON.stringify(storageData), expirationTime, cookieOptions); } diff --git a/apps/web-tss/src/layouts/PrimaryAppLayout.tsx b/apps/web-tss/src/layouts/PrimaryAppLayout.tsx new file mode 100644 index 000000000..382d5dd59 --- /dev/null +++ b/apps/web-tss/src/layouts/PrimaryAppLayout.tsx @@ -0,0 +1,24 @@ +import type React from 'react'; +import { AppLayout, type LayoutSize } from '@/components/ui/layouts/AppLayout'; + +export const PRIMARY_APP_LAYOUT_ID = 'app-layout'; + +const DEFAULT_LAYOUT: LayoutSize = ['230px', 'auto']; + +interface IPrimaryAppLayoutProps { + children: React.ReactNode; + initialLayout: LayoutSize | null; +} + +export const PrimaryAppLayout: React.FC = ({ children, initialLayout }) => { + return ( + Sidebar} + > + {children} + + ); +}; diff --git a/apps/web-tss/src/routes/app.home.tsx b/apps/web-tss/src/routes/app.home.tsx index b3ebf6746..302ba6a0f 100644 --- a/apps/web-tss/src/routes/app.home.tsx +++ b/apps/web-tss/src/routes/app.home.tsx @@ -1,11 +1,31 @@ import { createFileRoute } from '@tanstack/react-router'; +import { AppSplitter } from '@/components/ui/layouts/AppSplitter/AppSplitter'; +import { createAutoSaveId } from '../components/ui/layouts/AppLayout'; +import { getAppLayout } from '../serverFns/getAppLayout'; export const Route = createFileRoute('/app/home')({ component: RouteComponent, + loader: async () => { + const id = 'test0'; + const initialLayout = await getAppLayout({ data: { id } }); + return { + initialLayout, + }; + }, }); function RouteComponent() { + const { initialLayout } = Route.useLoaderData(); return ( -
Hello "/app/home"!
+
+ Left
} + rightChildren={
Right!!!!
} + initialLayout={initialLayout} + /> + ); } diff --git a/apps/web-tss/src/routes/app.tsx b/apps/web-tss/src/routes/app.tsx index 3b4e1412e..06ea5abc3 100644 --- a/apps/web-tss/src/routes/app.tsx +++ b/apps/web-tss/src/routes/app.tsx @@ -1,5 +1,7 @@ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { AppProviders } from '../context/Providers'; +import { PRIMARY_APP_LAYOUT_ID, PrimaryAppLayout } from '../layouts/PrimaryAppLayout'; +import { getAppLayout } from '../serverFns/getAppLayout'; export const Route = createFileRoute('/app')({ beforeLoad: async ({ context, location }) => { @@ -14,10 +16,20 @@ export const Route = createFileRoute('/app')({ throw redirect({ to: '/app/home' }); } }, + + loader: async () => { + const initialLayout = await getAppLayout({ data: { id: PRIMARY_APP_LAYOUT_ID } }); + return { + initialLayout, + }; + }, component: () => { + const { initialLayout } = Route.useLoaderData(); return ( - + + + ); }, diff --git a/apps/web-tss/src/serverFns/getAppLayout.ts b/apps/web-tss/src/serverFns/getAppLayout.ts new file mode 100644 index 000000000..971aa9aba --- /dev/null +++ b/apps/web-tss/src/serverFns/getAppLayout.ts @@ -0,0 +1,38 @@ +import { createServerFn } from '@tanstack/react-start'; +import { getCookie } from '@tanstack/react-start/server'; +import { z } from 'zod'; +import type { LayoutSize } from '../components/ui/layouts/AppLayout'; +import { createAutoSaveId } from '../components/ui/layouts/AppSplitter/create-auto-save-id'; + +export const getAppLayout = createServerFn({ method: 'GET' }) + .validator( + z.object({ + // The id must not be prefixed with "app-splitter" + id: z + .string() + .min(1, 'id is required') + .refine((val) => !val.startsWith('app-splitter'), { + message: 'id cannot be prefixed with "app-splitter"', + }), + preservedSide: z.enum(['left', 'right']).optional(), + }) + ) + .handler(async ({ data: { id, preservedSide } }) => { + const cookieName = createAutoSaveId(id); + const cookieValue = getCookie(cookieName); + + if (!cookieValue) { + return null; + } + + try { + const { value } = JSON.parse(cookieValue) as { value: number }; + const isLeft = preservedSide !== 'right'; + const layout: LayoutSize = isLeft ? [`${value}px`, 'auto'] : ['auto', `${value}px`]; + + return layout; + } catch (error) { + console.error('Error parsing cookie value', error); + return null; + } + }); diff --git a/apps/web-tss/vite.config.ts b/apps/web-tss/vite.config.ts index 052d6d52a..1ab1d6eea 100644 --- a/apps/web-tss/vite.config.ts +++ b/apps/web-tss/vite.config.ts @@ -13,18 +13,7 @@ const config = defineConfig({ tailwindcss(), tanstackStart({ customViteReactPlugin: true }), viteReact(), - // Custom plugin to exclude test and stories files in dev mode - { - name: 'exclude-test-stories', - resolveId(id) { - // Exclude .test and .stories files from being resolved - if (/\.(test|stories)\.(js|ts|jsx|tsx)$/.test(id)) { - return { id, external: true }; - } - return null; - }, - }, - !process.env.VITEST + !process.env.VITEST && process.env.NODE_ENV !== 'development' ? checker({ typescript: true, biome: true, @@ -41,11 +30,6 @@ const config = defineConfig({ output: { // Force lodash and lodash-es into a dedicated vendor chunk manualChunks(id) { - // Skip chunking for test and stories files (they should be excluded anyway) - if (/\.(test|stories)\.(js|ts|jsx|tsx)$/.test(id)) { - return; - } - if (id.includes('node_modules/lodash')) { return 'vendor-lodash'; }