pass cookies to layout components

This commit is contained in:
Nate Kelley 2025-08-13 16:08:21 -06:00
parent 2d48de17ee
commit 23472b86c3
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
14 changed files with 175 additions and 83 deletions

View File

@ -45,6 +45,9 @@
"noExcessiveCognitiveComplexity": "off",
"noForEach": "off"
},
"nursery": {
"useSortedClasses": "off"
},
"performance": {
"noDelete": "error"
},

View File

@ -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 (
<AppSplitter
defaultLayout={defaultLayout ?? DEFAULT_LAYOUT}
defaultLayout={defaultLayout}
className="max-h-screen min-h-screen overflow-hidden"
autoSaveId={autoSaveId}
preserveSide="left"
@ -81,3 +80,6 @@ const PageLayout: React.FC<
</div>
);
};
export type { LayoutSize };
export { createAutoSaveId };

View File

@ -39,7 +39,7 @@ type Story = StoryObj<typeof AppSplitter>;
// Helper components for demo content
const LeftContent = ({ title = 'Left Panel' }: { title?: string }) => (
<div className="bg-muted/20 h-full bg-blue-100/10 p-6">
<div className="bg-muted/20 h-full p-6">
<Title as="h3">{title}</Title>
<Text className="text-muted-foreground mt-2">
This is the left panel content. Try resizing the panels by dragging the splitter.
@ -836,7 +836,7 @@ export const CollapsedLeftPanel: Story = {
</div>
),
autoSaveId: 'collapsed-left-panel',
defaultLayout: ['0%', '100%'],
defaultLayout: ['auto', '100%'],
leftPanelMinSize: '200px',
preserveSide: 'right',
},

View File

@ -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<AppSplitterRef, IAppSplitterProps>(
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}
calculatedInitialValue={initialValue}
/>
)}
<AppSplitterBase
{...props}
ref={componentRef}
isVertical={isVertical}
containerRef={containerRef}
splitterAutoSaveId={splitterAutoSaveId}
split={split}
calculatedInitialValue={initialValue}
/>
</div>
</AppSplitterContext.Provider>
);
@ -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 = (
<>

View File

@ -1,5 +1,6 @@
export type PanelSize = `${number}px` | `${number}%` | 'auto' | number;
export type LayoutSize = [PanelSize, PanelSize];
type PanelSizeWithAuto = Exclude<PanelSize, 'auto'>;
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);
}
/**

View File

@ -0,0 +1,3 @@
const PREFIX = 'app-splitter';
export const createAutoSaveId = (id: string) => `${PREFIX}-${id}`;

View File

@ -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') {

View File

@ -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';

View File

@ -152,7 +152,6 @@ export function useCookieState<T>(
value: JSON.parse(serializer(newState)),
timestamp: Date.now(),
};
console.log(key, storageData);
setCookie(key, JSON.stringify(storageData), expirationTime, cookieOptions);
}

View File

@ -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<IPrimaryAppLayoutProps> = ({ children, initialLayout }) => {
return (
<AppLayout
autoSaveId={PRIMARY_APP_LAYOUT_ID}
defaultLayout={DEFAULT_LAYOUT}
initialLayout={initialLayout}
sidebar={<div>Sidebar</div>}
>
{children}
</AppLayout>
);
};

View File

@ -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 (
<div className="bg-red-500 p-10 border border-purple-500 border-4">Hello "/app/home"!</div>
<div className=" h-full">
<AppSplitter
preserveSide="left"
defaultLayout={['230px', 'auto']}
autoSaveId="test0"
leftChildren={<div>Left</div>}
rightChildren={<div>Right!!!!</div>}
initialLayout={initialLayout}
/>
</div>
);
}

View File

@ -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 (
<AppProviders>
<Outlet />
<PrimaryAppLayout initialLayout={initialLayout}>
<Outlet />
</PrimaryAppLayout>
</AppProviders>
);
},

View File

@ -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<LayoutSize | null>(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;
}
});

View File

@ -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';
}