diff --git a/README.md b/README.md index 68aae4d6..bccac9a5 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ The divider is fully keyboard accessible: - **Shift + Arrow**: Resize by larger step (default: 50px) - **Home**: Minimize left/top pane - **End**: Maximize left/top pane +- **Escape**: Restore pane sizes to initial state - **Tab**: Navigate between dividers ## API Reference @@ -342,112 +343,7 @@ A subtle single-pixel divider: ## Tailwind CSS & shadcn/ui -React Split Pane works seamlessly with Tailwind CSS and shadcn/ui. The component uses plain CSS and inline styles (no CSS-in-JS), so there are no conflicts with utility-first frameworks. - -### Using Tailwind Classes - -Apply Tailwind classes directly via `className` props. Skip importing the default stylesheet for full Tailwind control: - -```tsx -import { SplitPane, Pane } from 'react-split-pane'; -// Don't import 'react-split-pane/styles.css' if using Tailwind - - - - - - - - - -``` - -### shadcn/ui Integration - -Use shadcn's CSS variables and utilities for consistent theming: - -```tsx -import { SplitPane, Pane } from 'react-split-pane'; - - - - - - - - - -``` - -### Custom Divider with shadcn - -Create a themed divider component using shadcn's `cn` utility: - -```tsx -import { cn } from '@/lib/utils'; -import type { DividerProps } from 'react-split-pane'; - -function ThemedDivider({ direction, isDragging, disabled, ...props }: DividerProps) { - return ( -
- ); -} - - - Left - Right - -``` - -### CSS Variables with Tailwind - -Override the default CSS variables in your `globals.css` to match your Tailwind theme: - -```css -/* globals.css */ -@layer base { - :root { - --split-pane-divider-size: 4px; - --split-pane-divider-color: theme('colors.gray.200'); - --split-pane-divider-color-hover: theme('colors.gray.300'); - --split-pane-focus-color: theme('colors.blue.500'); - } - - .dark { - --split-pane-divider-color: theme('colors.gray.700'); - --split-pane-divider-color-hover: theme('colors.gray.600'); - } -} -``` - -Or with shadcn/ui CSS variables: - -```css -@layer base { - :root { - --split-pane-divider-color: hsl(var(--border)); - --split-pane-divider-color-hover: hsl(var(--accent)); - --split-pane-focus-color: hsl(var(--ring)); - } -} -``` +React Split Pane works seamlessly with Tailwind CSS and shadcn/ui. See [TAILWIND.md](./TAILWIND.md) for detailed integration examples including custom dividers and CSS variable overrides. ## Migration from v0.1.x diff --git a/TAILWIND.md b/TAILWIND.md new file mode 100644 index 00000000..e937bed7 --- /dev/null +++ b/TAILWIND.md @@ -0,0 +1,108 @@ +# Tailwind CSS & shadcn/ui Integration + +React Split Pane works seamlessly with Tailwind CSS and shadcn/ui. The component uses plain CSS and inline styles (no CSS-in-JS), so there are no conflicts with utility-first frameworks. + +## Using Tailwind Classes + +Apply Tailwind classes directly via `className` props. Skip importing the default stylesheet for full Tailwind control: + +```tsx +import { SplitPane, Pane } from 'react-split-pane'; +// Don't import 'react-split-pane/styles.css' if using Tailwind + + + + + + + + + +``` + +## shadcn/ui Integration + +Use shadcn's CSS variables and utilities for consistent theming: + +```tsx +import { SplitPane, Pane } from 'react-split-pane'; + + + + + + + + + +``` + +## Custom Divider with shadcn + +Create a themed divider component using shadcn's `cn` utility: + +```tsx +import { cn } from '@/lib/utils'; +import type { DividerProps } from 'react-split-pane'; + +function ThemedDivider({ direction, isDragging, disabled, ...props }: DividerProps) { + return ( +
+ ); +} + + + Left + Right + +``` + +## CSS Variables with Tailwind + +Override the default CSS variables in your `globals.css` to match your Tailwind theme: + +```css +/* globals.css */ +@layer base { + :root { + --split-pane-divider-size: 4px; + --split-pane-divider-color: theme('colors.gray.200'); + --split-pane-divider-color-hover: theme('colors.gray.300'); + --split-pane-focus-color: theme('colors.blue.500'); + } + + .dark { + --split-pane-divider-color: theme('colors.gray.700'); + --split-pane-divider-color-hover: theme('colors.gray.600'); + } +} +``` + +Or with shadcn/ui CSS variables: + +```css +@layer base { + :root { + --split-pane-divider-color: hsl(var(--border)); + --split-pane-divider-color-hover: hsl(var(--accent)); + --split-pane-focus-color: hsl(var(--ring)); + } +} +``` diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx index dee87b03..2bc35cbd 100644 --- a/src/components/Divider.tsx +++ b/src/components/Divider.tsx @@ -4,6 +4,7 @@ import { getDividerLabel, getKeyboardInstructions, } from '../utils/accessibility'; +import { cn } from '../utils/classNames'; const DEFAULT_CLASSNAME = 'split-pane-divider'; @@ -79,14 +80,12 @@ export function Divider(props: DividerProps) { ...style, }; - const combinedClassName = [ + const combinedClassName = cn( DEFAULT_CLASSNAME, direction, isDragging && 'dragging', - className, - ] - .filter(Boolean) - .join(' '); + className + ); const label = getDividerLabel(index, direction); const instructions = getKeyboardInstructions(direction); diff --git a/src/components/Pane.tsx b/src/components/Pane.tsx index 60f87aae..fb6ddbda 100644 --- a/src/components/Pane.tsx +++ b/src/components/Pane.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from 'react'; import { forwardRef } from 'react'; import type { PaneProps } from '../types'; +import { cn } from '../utils/classNames'; const DEFAULT_CLASSNAME = 'split-pane-pane'; @@ -54,9 +55,7 @@ export const Pane = forwardRef( ...style, }; - const combinedClassName = [DEFAULT_CLASSNAME, className] - .filter(Boolean) - .join(' '); + const combinedClassName = cn(DEFAULT_CLASSNAME, className); return (
{ diff --git a/src/hooks/useResizer.ts b/src/hooks/useResizer.ts index 23312df4..e11b49e5 100644 --- a/src/hooks/useResizer.ts +++ b/src/hooks/useResizer.ts @@ -83,14 +83,17 @@ export function useResizer(options: UseResizerOptions): UseResizerResult { const onResizeEndRef = useRef(onResizeEnd); onResizeEndRef.current = onResizeEnd; - // Update current sizes when prop sizes change (only when not dragging) - if ( - !isDragging && - sizes !== currentSizes && - JSON.stringify(sizes) !== JSON.stringify(currentSizes) - ) { - setCurrentSizes(sizes); - } + // Sync sizes from props when not dragging (React 19 compatible) + const sizesRef = useRef(sizes); + useEffect(() => { + if ( + !isDragging && + JSON.stringify(sizes) !== JSON.stringify(sizesRef.current) + ) { + sizesRef.current = sizes; + setCurrentSizes(sizes); + } + }, [sizes, isDragging]); // Track mounted state for RAF cleanup useEffect(() => { diff --git a/src/index.ts b/src/index.ts index d154e8c4..d1cce943 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ export type { Direction, Size, ResizeEvent, - PaneState, } from './types'; // Re-export hooks for advanced usage diff --git a/src/persistence.test.ts b/src/persistence.test.ts new file mode 100644 index 00000000..e6778e0d --- /dev/null +++ b/src/persistence.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { usePersistence } from './persistence'; + +describe('usePersistence', () => { + let mockStorage: Storage; + + beforeEach(() => { + mockStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + }; + }); + + describe('initialization', () => { + it('returns empty array when storage is empty', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + expect(result.current[0]).toEqual([]); + }); + + it('parses and returns stored sizes', () => { + (mockStorage.getItem as ReturnType).mockReturnValue( + '[300, 500]' + ); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + expect(result.current[0]).toEqual([300, 500]); + }); + + it('returns empty array on parse error', () => { + (mockStorage.getItem as ReturnType).mockReturnValue( + 'invalid json' + ); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + expect(result.current[0]).toEqual([]); + }); + + it('handles storage access error gracefully', () => { + (mockStorage.getItem as ReturnType).mockImplementation( + () => { + throw new Error('Storage access denied'); + } + ); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + expect(result.current[0]).toEqual([]); + }); + }); + + describe('setSizes', () => { + it('updates sizes state', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + act(() => { + result.current[1]([400, 600]); + }); + + expect(result.current[0]).toEqual([400, 600]); + }); + + it('persists sizes to storage after debounce', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage, debounce: 100 }) + ); + + act(() => { + result.current[1]([400, 600]); + }); + + // Before debounce + expect(mockStorage.setItem).not.toHaveBeenCalled(); + + // After debounce + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(mockStorage.setItem).toHaveBeenCalledWith('test-key', '[400,600]'); + }); + + it('uses default debounce of 300ms', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + act(() => { + result.current[1]([400, 600]); + }); + + act(() => { + vi.advanceTimersByTime(299); + }); + expect(mockStorage.setItem).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(mockStorage.setItem).toHaveBeenCalled(); + }); + + it('does not persist empty sizes array', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(mockStorage.setItem).not.toHaveBeenCalled(); + }); + + it('handles storage write error gracefully', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + (mockStorage.getItem as ReturnType).mockReturnValue(null); + (mockStorage.setItem as ReturnType).mockImplementation( + () => { + throw new Error('Storage quota exceeded'); + } + ); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage }) + ); + + act(() => { + result.current[1]([400, 600]); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to persist pane sizes:', + expect.any(Error) + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('debounce behavior', () => { + it('cancels pending save on new update', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage, debounce: 100 }) + ); + + act(() => { + result.current[1]([400, 600]); + }); + + act(() => { + vi.advanceTimersByTime(50); + }); + + act(() => { + result.current[1]([500, 500]); + }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(mockStorage.setItem).toHaveBeenCalledTimes(1); + expect(mockStorage.setItem).toHaveBeenCalledWith('test-key', '[500,500]'); + }); + + it('cleans up timeout on unmount', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result, unmount } = renderHook(() => + usePersistence({ key: 'test-key', storage: mockStorage, debounce: 100 }) + ); + + act(() => { + result.current[1]([400, 600]); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(mockStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('key changes', () => { + it('uses new key for storage operations', () => { + (mockStorage.getItem as ReturnType).mockReturnValue(null); + + const { result, rerender } = renderHook( + ({ key }) => usePersistence({ key, storage: mockStorage }), + { initialProps: { key: 'key-1' } } + ); + + act(() => { + result.current[1]([400, 600]); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(mockStorage.setItem).toHaveBeenCalledWith('key-1', '[400,600]'); + + rerender({ key: 'key-2' }); + + act(() => { + result.current[1]([200, 800]); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(mockStorage.setItem).toHaveBeenCalledWith('key-2', '[200,800]'); + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 20732836..bfd9f0ca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -116,10 +116,3 @@ export interface DividerProps { /** Custom content */ children?: ReactNode | undefined; } - -export interface PaneState { - size: number; - minSize: number; - maxSize: number; - defaultSize: number; -} diff --git a/src/utils/accessibility.test.ts b/src/utils/accessibility.test.ts index 4a0d61f2..ac33b816 100644 --- a/src/utils/accessibility.test.ts +++ b/src/utils/accessibility.test.ts @@ -17,6 +17,20 @@ describe('announce', () => { document.body.innerHTML = ''; }); + it('is SSR-safe and does nothing when document is undefined', () => { + const originalDocument = globalThis.document; + + // Simulate SSR environment + // @ts-expect-error - Intentionally setting document to undefined for SSR test + delete globalThis.document; + + // Should not throw + expect(() => announce('Test message')).not.toThrow(); + + // Restore document + globalThis.document = originalDocument; + }); + it('creates an announcement element with correct attributes', () => { announce('Test message'); diff --git a/src/utils/accessibility.ts b/src/utils/accessibility.ts index 28ce5ae7..ef11f3f2 100644 --- a/src/utils/accessibility.ts +++ b/src/utils/accessibility.ts @@ -1,10 +1,16 @@ /** - * Announce a message to screen readers + * Announce a message to screen readers. + * SSR-safe: no-op when document is not available. */ export function announce( message: string, priority: 'polite' | 'assertive' = 'polite' ): void { + // SSR safety check + if (typeof document === 'undefined') { + return; + } + const announcement = document.createElement('div'); announcement.setAttribute('role', 'status'); announcement.setAttribute('aria-live', priority); diff --git a/src/utils/classNames.test.ts b/src/utils/classNames.test.ts new file mode 100644 index 00000000..1e743e1f --- /dev/null +++ b/src/utils/classNames.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { cn } from './classNames'; + +describe('cn', () => { + it('combines multiple class names', () => { + expect(cn('foo', 'bar')).toBe('foo bar'); + }); + + it('filters out false values', () => { + const shouldInclude = false; + expect(cn('foo', shouldInclude && 'bar', 'baz')).toBe('foo baz'); + }); + + it('filters out undefined values', () => { + expect(cn('foo', undefined, 'bar')).toBe('foo bar'); + }); + + it('filters out null values', () => { + expect(cn('foo', null, 'bar')).toBe('foo bar'); + }); + + it('filters out empty strings', () => { + expect(cn('foo', '', 'bar')).toBe('foo bar'); + }); + + it('returns empty string when no valid classes', () => { + expect(cn(false, undefined, null)).toBe(''); + }); + + it('handles single class name', () => { + expect(cn('foo')).toBe('foo'); + }); + + it('handles conditional class names', () => { + const isActive = true; + const isDisabled = false; + expect(cn('base', isActive && 'active', isDisabled && 'disabled')).toBe( + 'base active' + ); + }); +}); diff --git a/src/utils/classNames.ts b/src/utils/classNames.ts new file mode 100644 index 00000000..8d013b93 --- /dev/null +++ b/src/utils/classNames.ts @@ -0,0 +1,19 @@ +/** + * Combines class names, filtering out falsy values. + * Similar to the popular `clsx` or `classnames` packages. + * + * @param classes - Class names to combine (strings, undefined, null, false) + * @returns Combined class name string + * + * @example + * ```ts + * cn('foo', 'bar') // 'foo bar' + * cn('foo', false && 'bar', 'baz') // 'foo baz' + * cn('foo', undefined, 'bar') // 'foo bar' + * ``` + */ +export function cn( + ...classes: (string | boolean | undefined | null)[] +): string { + return classes.filter(Boolean).join(' '); +}