├── .prettierignore ├── src ├── vite-env.d.ts ├── index.css ├── main.tsx ├── App.tsx ├── favicon.svg ├── examples │ ├── Context.tsx │ ├── ImageCompare.tsx │ └── CodeEditor.tsx └── logo.svg ├── .eslintignore ├── postcss.config.cjs ├── settings.json ├── .prettierrc.cjs ├── tsconfig.node.json ├── lib ├── types.ts ├── const.ts ├── index.ts ├── RootContext.tsx ├── utils.ts ├── ResplitContext.tsx ├── Splitter.tsx ├── Pane.tsx └── Root.tsx ├── tailwind.config.cjs ├── .gitignore ├── .github └── workflows │ └── size-limit.yml ├── index.html ├── tsconfig.json ├── vite.config.ts ├── LICENSE ├── .eslintrc.cjs ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .prettierrc.js 4 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .prettierrc.js 4 | .eslintrc.js 5 | env.d.ts 6 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | --sp-colors-surface1: @apply bg-zinc-900; 7 | } 8 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.configPath": ".prettierrc.js", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 100, 7 | bracketSpacing: true, 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Direction = 'horizontal' | 'vertical'; 2 | 3 | export type Order = number; 4 | 5 | export type PxValue = `${number}px`; 6 | 7 | export type FrValue = `${number}fr`; 8 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.github/workflows/size-limit.yml: -------------------------------------------------------------------------------- 1 | name: 'size' 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | size: 8 | runs-on: ubuntu-latest 9 | env: 10 | CI_JOB_NUMBER: 1 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: andresz1/size-limit-action@v1 14 | with: 15 | github_token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Resplit 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /lib/const.ts: -------------------------------------------------------------------------------- 1 | import type { ResplitOptions } from './Root'; 2 | 3 | export const DEFAULT_OPTIONS: ResplitOptions = { 4 | direction: 'horizontal', 5 | }; 6 | 7 | export const GRID_TEMPLATE_BY_DIRECTION = { 8 | horizontal: 'gridTemplateColumns', 9 | vertical: 'gridTemplateRows', 10 | }; 11 | 12 | export const CURSOR_BY_DIRECTION = { 13 | horizontal: 'col-resize', 14 | vertical: 'row-resize', 15 | }; 16 | 17 | export const SPLITTER_DEFAULT_SIZE = '10px'; 18 | 19 | export const PANE_DEFAULT_MIN_SIZE = '0fr'; 20 | 21 | export const PANE_DEFAULT_COLLAPSED_SIZE = '0fr'; 22 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import { ResplitRoot } from './Root'; 2 | import { ResplitPane } from './Pane'; 3 | import { ResplitSplitter } from './Splitter'; 4 | 5 | export const Resplit = { 6 | Root: ResplitRoot, 7 | Pane: ResplitPane, 8 | Splitter: ResplitSplitter, 9 | }; 10 | 11 | export { ResplitRoot } from './Root'; 12 | export { ResplitPane } from './Pane'; 13 | export { ResplitSplitter } from './Splitter'; 14 | export { useResplitContext } from './ResplitContext'; 15 | 16 | export * from './types'; 17 | export type { ResplitRootProps } from './Root'; 18 | export type { ResplitPaneProps } from './Pane'; 19 | export type { ResplitSplitterProps } from './Splitter'; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "resplit": ["./lib"] 20 | } 21 | }, 22 | "include": ["src", "lib"], 23 | "references": [{ "path": "./tsconfig.node.json" }] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import dts from 'vite-plugin-dts'; 3 | import react from '@vitejs/plugin-react'; 4 | import * as path from 'path'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | resolve: { 9 | alias: { 10 | resplit: path.resolve(__dirname, './lib'), 11 | }, 12 | }, 13 | build: { 14 | lib: { 15 | entry: path.resolve(__dirname, 'lib/index.ts'), 16 | name: 'resplit', 17 | formats: ['es', 'umd'], 18 | fileName: (format) => (format === 'umd' ? 'resplit.umd.cjs' : 'resplit.es.js'), 19 | }, 20 | rollupOptions: { 21 | external: ['react', 'react-dom'], 22 | output: { 23 | globals: { 24 | react: 'React', 25 | 'react-dom': 'ReactDOM', 26 | }, 27 | }, 28 | }, 29 | }, 30 | plugins: [ 31 | react(), 32 | dts({ 33 | insertTypesEntry: true, 34 | entryRoot: path.resolve(__dirname, './lib'), 35 | }), 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /lib/RootContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, MutableRefObject, useContext } from 'react'; 2 | 3 | import type { Direction } from './types'; 4 | import type { ResplitPaneOptions } from './Pane'; 5 | import type { ResplitSplitterOptions } from './Splitter'; 6 | 7 | export const RootContext = createContext< 8 | | { 9 | id: string; 10 | direction: Direction; 11 | registerPane: (order: string, options: MutableRefObject) => void; 12 | registerSplitter: (order: string, options: MutableRefObject) => void; 13 | handleSplitterMouseDown: (order: number) => () => void; 14 | handleSplitterKeyDown: ( 15 | splitterOrder: number, 16 | ) => (e: React.KeyboardEvent) => void; 17 | } 18 | | undefined 19 | >(undefined); 20 | 21 | export const useRootContext = () => { 22 | const context = useContext(RootContext); 23 | 24 | if (!context) { 25 | throw new Error('useRootContext must be used within an RootContext.Provider'); 26 | } 27 | 28 | return context; 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kenan Yusuf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react'; 2 | import { FrValue, PxValue } from './types'; 3 | 4 | export const convertFrToNumber = (val: FrValue) => Number(val.replace('fr', '')); 5 | 6 | export const convertPxToNumber = (val: PxValue) => Number(val.replace('px', '')); 7 | 8 | export const convertPxToFr = (px: number, containerSize: number): FrValue => 9 | `${px / containerSize}fr`; 10 | 11 | export const isPx = (val: FrValue | PxValue): val is PxValue => val.includes('px'); 12 | 13 | export const convertSizeToFr = (size: FrValue | PxValue, rootSize: number) => 14 | isPx(size) ? convertPxToFr(convertPxToNumber(size), rootSize) : size; 15 | 16 | export const useIsomorphicLayoutEffect = 17 | typeof window === 'undefined' ? useEffect : useLayoutEffect; 18 | 19 | export function mergeRefs( 20 | refs: Array | React.LegacyRef | undefined | null>, 21 | ): React.RefCallback { 22 | return (value) => { 23 | refs.forEach((ref) => { 24 | if (typeof ref === 'function') { 25 | ref(value); 26 | } else if (ref != null) { 27 | (ref as React.MutableRefObject).current = value; 28 | } 29 | }); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | // By extending from a plugin config, we can get recommended rules without having to add them manually. 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react-hooks/recommended', 8 | 'plugin:import/recommended', 9 | 'plugin:jsx-a11y/recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | // This disables the formatting rules in ESLint that Prettier is going to be responsible for handling. 12 | // Make sure it's always the last config, so it gets the chance to override other configs. 13 | 'eslint-config-prettier', 14 | ], 15 | plugins: ['@typescript-eslint', 'eslint-plugin-tsdoc'], 16 | settings: { 17 | react: { 18 | // Tells eslint-plugin-react to automatically detect the version of React to use. 19 | version: 'detect', 20 | }, 21 | 'import/resolver': { 22 | node: { 23 | paths: ['src', 'lib'], 24 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 25 | }, 26 | typescript: {}, 27 | }, 28 | }, 29 | rules: { 30 | 'react/react-in-jsx-scope': 'off', 31 | 'tsdoc/syntax': 'warn', 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { CodeEditorExample } from './examples/CodeEditor'; 3 | import { ImageCompareExample } from './examples/ImageCompare'; 4 | import { ContextExample } from './examples/Context'; 5 | 6 | const examples = [ 7 | { 8 | name: 'Code Editor', 9 | component: CodeEditorExample, 10 | }, 11 | { 12 | name: 'Image Compare', 13 | component: ImageCompareExample, 14 | }, 15 | { 16 | name: 'Context', 17 | component: ContextExample, 18 | }, 19 | ]; 20 | 21 | function App() { 22 | const [example, setExample] = useState(examples[0]); 23 | 24 | return ( 25 |
26 |
27 |

Resplit Playground

28 | 42 |
43 | 44 |
45 | ); 46 | } 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/ResplitContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | import type { FrValue } from './types'; 4 | 5 | export type ResplitContextValue = { 6 | /** 7 | * Specify the size of each pane as a fractional unit (fr). 8 | * The number of values should match the number of panes. 9 | * 10 | * @param paneSizes - An array of fractional unit (fr) values. {@link FrValue} 11 | * 12 | * @example ['0.25fr', '0.25fr', '0.5fr'] 13 | */ 14 | setPaneSizes: (paneSizes: FrValue[]) => void; 15 | /** 16 | * Get the min size state of a pane. 17 | * 18 | * @param order - The order of the pane. {@link Order} 19 | * 20 | * @returns A boolean indicating if the pane is at its min size or not. 21 | */ 22 | isPaneMinSize: (order: number) => boolean; 23 | /** 24 | * Get the collapsed state of a pane. 25 | * 26 | * @param order - The order of the pane. {@link Order} 27 | * 28 | * @returns A boolean indicating if the pane is collapsed or not. 29 | */ 30 | isPaneCollapsed: (order: number) => boolean; 31 | }; 32 | 33 | export const ResplitContext = createContext(undefined); 34 | 35 | /** 36 | * Get the value from ResplitContext, set in the ResplitRoot component. 37 | * 38 | * @returns The ResplitContext value. {@link ResplitContextValue} 39 | */ 40 | export const useResplitContext = () => { 41 | const context = useContext(ResplitContext); 42 | 43 | if (!context) { 44 | throw new Error('useResplitContext must be used within a ResplitRoot'); 45 | } 46 | 47 | return context; 48 | }; 49 | -------------------------------------------------------------------------------- /src/examples/Context.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { FrValue, Resplit, ResplitPaneProps, useResplitContext } from 'resplit'; 3 | 4 | export const SizeAwarePane = (props: ResplitPaneProps) => { 5 | const { isPaneCollapsed, isPaneMinSize, setPaneSizes } = useResplitContext(); 6 | const [size, setSize] = useState('0.5fr'); 7 | const [isCollapsed, setIsCollapsed] = useState(false); 8 | const [isMinSize, setIsMinSize] = useState(false); 9 | 10 | const handleResize = (newSize: FrValue) => { 11 | setSize(newSize); 12 | setIsCollapsed(isPaneCollapsed(props.order)); 13 | setIsMinSize(isPaneMinSize(props.order)); 14 | }; 15 | 16 | const resetPanes = () => { 17 | setPaneSizes(['0.5fr', '0.5fr']); 18 | }; 19 | 20 | return ( 21 | 30 |
    31 |
  • size: {size}
  • 32 |
  • isMinSize: {isMinSize.toString()}
  • 33 |
  • isCollapsed: {isCollapsed.toString()}
  • 34 |
  • 35 | 41 |
  • 42 |
43 |
44 | ); 45 | }; 46 | 47 | export const ContextExample = () => { 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-resplit", 3 | "description": "Resizable split pane layouts for React applications", 4 | "keywords": [ 5 | "react", 6 | "split", 7 | "pane", 8 | "panels", 9 | "resize", 10 | "resizable", 11 | "grid" 12 | ], 13 | "author": "Kenan Yusuf ", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/KenanYusuf/react-resplit.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/KenanYusuf/react-resplit/issues" 20 | }, 21 | "license": "MIT", 22 | "type": "module", 23 | "version": "1.3.2", 24 | "main": "./dist/resplit.umd.cjs", 25 | "module": "./dist/resplit.es.js", 26 | "types": "./dist/resplit.d.ts", 27 | "exports": { 28 | ".": { 29 | "import": "./dist/resplit.es.js", 30 | "require": "./dist/resplit.umd.cjs", 31 | "types": "./dist/resplit.d.ts" 32 | } 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "size-limit": [ 38 | { 39 | "limit": "5 kB", 40 | "path": "dist/resplit.es.js" 41 | } 42 | ], 43 | "scripts": { 44 | "dev": "vite", 45 | "build": "tsc && vite build", 46 | "preview": "vite preview", 47 | "lint": "eslint . --ext .ts,.tsx", 48 | "size": "yarn build && size-limit" 49 | }, 50 | "peerDependencies": { 51 | "react": ">=16", 52 | "react-dom": ">=16" 53 | }, 54 | "devDependencies": { 55 | "@codesandbox/sandpack-react": "^1.18.2", 56 | "@size-limit/preset-small-lib": "^8.1.2", 57 | "@types/node": "^18.11.17", 58 | "@types/react": "^18.0.0", 59 | "@types/react-dom": "^18.0.0", 60 | "@typescript-eslint/eslint-plugin": "^5.46.0", 61 | "@typescript-eslint/parser": "^5.46.0", 62 | "@vitejs/plugin-react": "^3.0.0", 63 | "autoprefixer": "^10.4.13", 64 | "eslint": "^8.29.0", 65 | "eslint-config-prettier": "^8.5.0", 66 | "eslint-import-resolver-typescript": "^3.5.2", 67 | "eslint-plugin-import": "^2.26.0", 68 | "eslint-plugin-jsx-a11y": "^6.6.1", 69 | "eslint-plugin-react": "^7.31.11", 70 | "eslint-plugin-react-hooks": "^4.6.0", 71 | "eslint-plugin-tsdoc": "^0.2.17", 72 | "postcss": "^8.4.19", 73 | "prettier": "^2.8.1", 74 | "react": "^18.2.0", 75 | "react-dom": "^18.2.0", 76 | "size-limit": "^8.1.2", 77 | "tailwindcss": "^3.2.4", 78 | "typescript": "^4.6.3", 79 | "vite": "^4.0.5", 80 | "vite-plugin-dts": "^1.7.1" 81 | }, 82 | "dependencies": { 83 | "@radix-ui/react-slot": "^1.0.2" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/examples/ImageCompare.tsx: -------------------------------------------------------------------------------- 1 | import { Resplit } from 'resplit'; 2 | 3 | const PANE_CLASSES = 'relative w-full h-full'; 4 | 5 | const IMG_CLASSES = 'absolute w-full h-full object-cover pointer-events-none select-none'; 6 | 7 | const IMG_URL = 8 | 'https://images.unsplash.com/photo-1669837238989-fe14dd4eb7a3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8fHx8fHx8MTY3MTAyODg3Mg&ixlib=rb-4.0.3&q=80&w=1080'; 9 | 10 | const ChevronLeft = () => ( 11 | 12 | 13 | 14 | ); 15 | 16 | const ChevronRight = () => ( 17 | 18 | 19 | 20 | ); 21 | 22 | export const ImageCompareExample = () => { 23 | return ( 24 |
25 | 26 | 27 | Sunflowers 28 | 29 | 30 |
31 | 32 | 33 |
34 |
35 | 36 | Sunflowers with grayscale filter applied 41 | 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/Splitter.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, HTMLAttributes, forwardRef, useRef, useEffect } from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | 4 | import { useRootContext } from './RootContext'; 5 | import { CURSOR_BY_DIRECTION, SPLITTER_DEFAULT_SIZE } from './const'; 6 | import { useIsomorphicLayoutEffect } from './utils'; 7 | 8 | import type { PxValue, Order } from './types'; 9 | 10 | export type ResplitSplitterOptions = { 11 | /** 12 | * Set the size of the splitter as a pixel unit. 13 | * 14 | * @example '4px' 15 | * 16 | * @defaultValue '10px' 17 | */ 18 | size?: PxValue; 19 | }; 20 | 21 | export type ResplitSplitterProps = HTMLAttributes & 22 | ResplitSplitterOptions & { 23 | /** 24 | * The order of the splitter in the layout. {@link Order} 25 | */ 26 | order: Order; 27 | /** 28 | * The content of the splitter. 29 | */ 30 | children?: ReactNode; 31 | /** 32 | * Merges props onto the immediate child. 33 | * 34 | * @defaultValue false 35 | * 36 | * @example 37 | * 38 | * ```tsx 39 | * 40 | *
41 | * ... 42 | *
43 | *
44 | * ``` 45 | */ 46 | asChild?: boolean; 47 | }; 48 | 49 | /** 50 | * A splitter is a draggable element that can be used to resize panes. 51 | * 52 | * It must be a direct child of a {@link ResplitRoot} component. 53 | * 54 | * @example 55 | * ```tsx 56 | * 57 | * ``` 58 | */ 59 | export const ResplitSplitter = forwardRef(function Splitter( 60 | { children, order, size = SPLITTER_DEFAULT_SIZE, asChild = false, ...rest }, 61 | ref, 62 | ) { 63 | const Comp = asChild ? Slot : 'div'; 64 | const { id, direction, registerSplitter, handleSplitterMouseDown, handleSplitterKeyDown } = 65 | useRootContext(); 66 | 67 | const splitterOptionsRef = useRef({ size }); 68 | 69 | useIsomorphicLayoutEffect(() => { 70 | registerSplitter(String(order), splitterOptionsRef); 71 | }, []); 72 | 73 | useEffect(() => { 74 | splitterOptionsRef.current = { size }; 75 | }, [size]); 76 | 77 | return ( 78 | // eslint-disable-next-line jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-element-interactions 79 | 95 | {children} 96 | 97 | ); 98 | }); 99 | -------------------------------------------------------------------------------- /lib/Pane.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, HTMLAttributes, forwardRef, useRef, useEffect } from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | 4 | import { useRootContext } from './RootContext'; 5 | import { PANE_DEFAULT_COLLAPSED_SIZE, PANE_DEFAULT_MIN_SIZE } from './const'; 6 | import { useIsomorphicLayoutEffect } from './utils'; 7 | 8 | import type { FrValue, Order, PxValue } from './types'; 9 | 10 | export type ResplitPaneOptions = { 11 | /** 12 | * Set the initial size of the pane as a fractional unit (fr). 13 | * 14 | * @example '0.5fr' 15 | * 16 | * @defaultValue By default, the initial size is calculated as the available space divided by the number of panes. 17 | */ 18 | initialSize?: FrValue; 19 | /** 20 | * Set the minimum size of the pane as a pixel unit (px) or fractional unit (fr). 21 | * 22 | * @example '0.1fr' 23 | * 24 | * @defaultValue '0fr' 25 | */ 26 | minSize?: PxValue | FrValue; 27 | /** 28 | * Whether the pane can be collapsed below its minimum size. 29 | * 30 | * The pane will be collapsed if the user drags the splitter past 50% of the minimum size. 31 | * 32 | * @defaultValue false 33 | */ 34 | collapsible?: boolean; 35 | /** 36 | * Set the collapsed size of the pane as a pixel unit (px) or fractional unit (fr). 37 | * 38 | * @example '50px' 39 | * 40 | * @defaultValue '0fr' 41 | */ 42 | collapsedSize?: PxValue | FrValue; 43 | /** 44 | * Callback function that is called when the pane starts being resized. 45 | */ 46 | onResizeStart?: () => void; 47 | /** 48 | * Callback function that is called when the pane is finished being resized. 49 | * 50 | * @param size - The new size of the pane. {@link FrValue} 51 | */ 52 | onResizeEnd?: (size: FrValue) => void; 53 | /** 54 | * Callback function that is called when the pane is actively being resized. 55 | * 56 | * @param size - The new size of the pane. {@link FrValue} 57 | */ 58 | onResize?: (size: FrValue) => void; 59 | }; 60 | 61 | export type ResplitPaneProps = Omit< 62 | HTMLAttributes, 63 | 'onResize' | 'onResizeEnd' | 'onResizeStart' 64 | > & 65 | ResplitPaneOptions & { 66 | /** 67 | * The order of the pane in the layout. {@link Order} 68 | */ 69 | order: Order; 70 | /** 71 | * The content of the pane. 72 | */ 73 | children?: ReactNode; 74 | /** 75 | * Merges props onto the immediate child. 76 | * 77 | * @defaultValue false 78 | * 79 | * @example 80 | * 81 | * ```tsx 82 | * 83 | * 86 | * 87 | * ``` 88 | */ 89 | asChild?: boolean; 90 | }; 91 | 92 | /** 93 | * A pane is a container that can be resized. 94 | * 95 | * It must be a direct child of a {@link ResplitRoot} component. 96 | * 97 | * @example 98 | * ```tsx 99 | * 100 | *
Pane 1
101 | *
102 | * ``` 103 | */ 104 | export const ResplitPane = forwardRef(function Pane( 105 | { 106 | children, 107 | order, 108 | minSize = PANE_DEFAULT_MIN_SIZE, 109 | collapsible = false, 110 | collapsedSize = PANE_DEFAULT_COLLAPSED_SIZE, 111 | initialSize, 112 | asChild = false, 113 | onResize, 114 | onResizeStart, 115 | onResizeEnd, 116 | ...rest 117 | }, 118 | ref, 119 | ) { 120 | const Comp = asChild ? Slot : 'div'; 121 | const { id, registerPane } = useRootContext(); 122 | 123 | const paneOptionsRef = useRef({ 124 | minSize, 125 | initialSize, 126 | collapsedSize, 127 | collapsible, 128 | onResize, 129 | onResizeStart, 130 | onResizeEnd, 131 | }); 132 | 133 | useIsomorphicLayoutEffect(() => { 134 | registerPane(String(order), paneOptionsRef); 135 | }, []); 136 | 137 | useEffect(() => { 138 | paneOptionsRef.current = { 139 | minSize, 140 | initialSize, 141 | collapsedSize, 142 | collapsible, 143 | onResize, 144 | onResizeStart, 145 | onResizeEnd, 146 | }; 147 | }, [minSize, initialSize, collapsedSize, collapsible, onResize, onResizeStart, onResizeEnd]); 148 | 149 | return ( 150 | 158 | {children} 159 | 160 | ); 161 | }); 162 | -------------------------------------------------------------------------------- /src/examples/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Resplit, useResplitContext } from 'resplit'; 3 | import { 4 | SandpackProvider, 5 | SandpackCodeEditor, 6 | SandpackPreview, 7 | SandpackConsole, 8 | SandpackFileExplorer, 9 | } from '@codesandbox/sandpack-react'; 10 | 11 | const SPLITTER_CLASSES = 12 | 'relative w-full h-full bg-zinc-600 before:absolute before:inset-0 before:bg-blue-600 before:z-10 before:bg-blue-700 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-100 before:ease-in-out data-[resplit-active=true]:before:opacity-100 focus-visible:before:opacity-100 outline-none'; 13 | 14 | const HORIZONTAL_SPLITTER_CLASSES = 'before:w-[7px] before:-left-[3px]'; 15 | 16 | // const VERTICAL_SPLITTER_CLASSES = 'before:h-[7px] before:-top-[3px]'; 17 | 18 | const appCode = `import { Resplit } from 'react-resplit'; 19 | import './style.css'; 20 | 21 | function App() { 22 | return ( 23 | 24 | 29 | Pane 1 30 | 31 | 36 | 41 | Pane 2 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default App; 48 | `; 49 | 50 | const styleCode = `body { 51 | padding: 40px; 52 | } 53 | 54 | .pane { 55 | background: #eee; 56 | padding: 20px; 57 | height: 200px; 58 | } 59 | 60 | .splitter { 61 | background: #ccc; 62 | width: 10px; 63 | height: 100%; 64 | } 65 | `; 66 | 67 | const PaneHeader = ({ children, id }: { children: React.ReactNode; id?: string }) => ( 68 |

69 | {children} 70 |

71 | ); 72 | 73 | const ProblemsPane = () => { 74 | const { isPaneMinSize, setPaneSizes } = useResplitContext(); 75 | const [tab, setTab] = React.useState<'console' | 'problems'>('console'); 76 | const [isMinSize, setIsMinSize] = useState(false); 77 | 78 | const handleResize = () => { 79 | if (isPaneMinSize(2)) { 80 | setIsMinSize(true); 81 | } else { 82 | setIsMinSize(false); 83 | } 84 | }; 85 | 86 | return ( 87 | <> 88 | 89 | 90 | 97 | 104 | 129 | 130 | 131 | 132 | 138 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | const PreviewPane = () => { 145 | return ( 146 | 147 | 148 | Preview 149 | 150 | 151 | 152 | 153 | 154 | ); 155 | }; 156 | 157 | export const CodeEditorExample = () => { 158 | return ( 159 | 177 |
178 |
179 | 192 |
193 | 194 | 202 | Files 203 | 204 | 205 | 211 | 217 | Code 218 |
219 | 220 |
221 |
222 | 228 | 229 | 230 | 231 |
232 |
233 |
234 | Demonstration of how an editor can be built with{' '} 235 | 236 | Resplit 237 | {' '} 238 | and{' '} 239 | 240 | Sandpack 241 | 242 |
243 |
244 | ); 245 | }; 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-resplit 2 | 3 | Resizable split pane layouts for React applications 🖖 4 | 5 | - Compound component API that works with any styling method 6 | - Built with modern CSS, a grid-based layout and custom properties 7 | - Works with any amount of panes in a vertical or horizontal layout 8 | - Built following the [Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) pattern for accessibility and keyboard controls 9 | 10 | https://github.com/KenanYusuf/react-resplit/assets/9557798/d47ef278-bcb1-4c2b-99e6-7a9f99943f96 11 | 12 | _Example of a code editor built with `react-resplit`_ 13 | 14 | ## Development 15 | 16 | Run the development server: 17 | 18 | ``` 19 | yarn dev 20 | ``` 21 | 22 | The files for the development app can be found in `src`, and the library files in `lib`. 23 | 24 | --- 25 | 26 | ## Usage 27 | 28 | Install the package using your package manager of choice. 29 | 30 | ``` 31 | npm install react-resplit 32 | ``` 33 | 34 | Import `Resplit` from `react-resplit` and render the Root, Pane(s) and Splitter(s). 35 | 36 | ```tsx 37 | import { Resplit } from 'react-resplit'; 38 | 39 | function App() { 40 | return ( 41 | 42 | 43 | Pane 1 44 | 45 | 46 | 47 | Pane 2 48 | 49 | 50 | ); 51 | } 52 | ``` 53 | 54 | ### Styling 55 | 56 | The Root, Splitter and Pane elements are all unstyled by default apart from a few styles that are necessary for the layout - this is intentional so that the library remains flexible. 57 | 58 | Resplit will apply the correct cursor based on the `direction` provided to the hook. 59 | 60 | As a basic example, you could provide a `className` prop to the Splitter elements and style them as a solid 10px divider. 61 | 62 | ```tsx 63 | 64 | ``` 65 | 66 | ```css 67 | .splitter { 68 | width: 100%; 69 | height: 100%; 70 | background: #ccc; 71 | } 72 | ``` 73 | 74 | ### Accessibility 75 | 76 | Resplit has been implemented using guidence from the [Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) pattern. 77 | 78 | In addition to built-in accessibility considerations, you should also ensure that splitters are correctly labelled. 79 | 80 | If the primary pane has a visible label, the `aria-labelledby` attribute can be used. 81 | 82 | ```tsx 83 | 84 |

Pane 1

85 |
86 | 87 | ``` 88 | 89 | Alternatively, if the pane does not have a visible label, the `aria-label` attribute can be used on the Splitter instead. 90 | 91 | ```tsx 92 | 93 | ``` 94 | 95 | ## API 96 | 97 | All of the resplit components extend the `React.HTMLAttributes` interface, so you can pass any valid HTML attribute to them. 98 | 99 | ### Root `(ResplitRootProps)` 100 | 101 | The root component of a resplit layout. Provides context to all child components. 102 | 103 | | Prop | Type | Default | Description | 104 | | ----------- | ---------------------------- | -------------- | ------------------------------------- | 105 | | `direction` | `"horizontal" \| "vertical"` | `"horizontal"` | Direction of the panes | 106 | | `asChild` | `boolean` | `false` | Merges props onto the immediate child | 107 | | `children` | `ReactNode` | | Child elements | 108 | | `className` | `string` | | Class name | 109 | | `style` | `CSSProperties` | | Style object | 110 | 111 | ### Pane `(ResplitPaneProps)` 112 | 113 | A pane is a container that can be resized. 114 | 115 | | Prop | Type | Default | Description | 116 | | --------------- | ------------------------------ | ------------------------------------- | -------------------------------------------------------------------------- | 117 | | `order` | `number` | | Specifies the order of the resplit child (pane or splitter) in the DOM | 118 | | `initialSize` | `${number}fr` | `[available space]/[number of panes]` | Set the initial size of the pane as a fractional unit (fr) | 119 | | `minSize` | `${number}fr` \| `${number}px` | `"0fr"` | Set the minimum size of the pane as a fractional (fr) or pixel (px) unit | 120 | | `collapsible` | `boolean` | `false` | Whether the pane can be collapsed below its minimum size | 121 | | `collapsedSize` | `${number}fr` \| `${number}px` | `"0fr"` | Set the collapsed size of the pane as a fractional (fr) or pixel (px) unit | 122 | | `onResizeStart` | `() => void` | | Callback function that is called when the pane starts being resized. | 123 | | `onResize` | `(size: FrValue) => void` | | Callback function that is called when the pane is actively being resized. | 124 | | `onResizeEnd` | `(size: FrValue) => void` | | Callback function that is called when the pane is actively being resized. | 125 | | `asChild` | `boolean` | `false` | Merges props onto the immediate child | 126 | | `children` | `ReactNode` | | Child elements | 127 | | `className` | `string` | | Class name | 128 | | `style` | `CSSProperties` | | Style object | 129 | 130 | ### Splitter `(ResplitSplitterProps)` 131 | 132 | A splitter is a draggable element that can be used to resize panes. 133 | 134 | | Name | Type | Default | Description | 135 | | ----------- | --------------- | -------- | ---------------------------------------------------------------------- | 136 | | `order` | `number` | | Specifies the order of the resplit child (pane or splitter) in the DOM | 137 | | `size` | `${number}px` | `"10px"` | Set the size of the splitter as a pixel unit | 138 | | `asChild` | `boolean` | `false` | Merges props onto the immediate child | 139 | | `children` | `ReactNode` | | Child elements | 140 | | `className` | `string` | | Class name | 141 | | `style` | `CSSProperties` | | Style object | 142 | 143 | ### useResplitContext `() => ResplitContextValue` 144 | 145 | The `useResplitContext` hook provides access to the context of the nearest `Resplit.Root` component. 146 | 147 | See the methods below for more information on what is available. 148 | 149 | #### setPaneSizes `(paneSizes: FrValue[]) => void` 150 | 151 | Get the collapsed state of a pane. 152 | 153 | Specify the size of each pane as a fractional unit (fr). The number of values should match the number of panes. 154 | 155 | ```tsx 156 | setPaneSizes(['0.6fr', '0.4fr']); 157 | ``` 158 | 159 | If your pane has an `onResize` callback, it will be called with the new size. 160 | 161 | #### isPaneCollapsed `(order: number) => boolean` 162 | 163 | Get the collapsed state of a pane. 164 | 165 | **Note**: The returned value will not update on every render and should be used in a callback e.g. used in combination with a pane's `onResize` callback. 166 | 167 | ```tsx 168 | import { Resplit, useResplitContext, ResplitPaneProps, FrValue } from 'react-resplit'; 169 | 170 | function CustomPane(props: ResplitPaneProps) { 171 | const { isPaneCollapsed } = useResplitContext(); 172 | 173 | const handleResize = (size: FrValue) => { 174 | if (isPaneCollapsed(props.order)) { 175 | // Do something 176 | } 177 | }; 178 | 179 | return ( 180 | 187 | ); 188 | } 189 | 190 | function App() { 191 | return ( 192 | 193 | 194 | 195 | 196 | 197 | ); 198 | } 199 | ``` 200 | 201 | #### isPaneMinSize `(order: number) => boolean` 202 | 203 | Get the min size state of a pane. 204 | 205 | **Note**: The returned value will not update on every render and should be used in a callback e.g. used in combination with a pane's `onResize` callback. 206 | 207 | ```tsx 208 | import { Resplit, useResplitContext, ResplitPaneProps, FrValue } from 'react-resplit'; 209 | 210 | function CustomPane(props: ResplitPaneProps) { 211 | const { isPaneMinSize } = useResplitContext(); 212 | 213 | const handleResize = (size: FrValue) => { 214 | if (isPaneMinSize(props.order)) { 215 | // Do something 216 | } 217 | }; 218 | 219 | return ; 220 | } 221 | 222 | function App() { 223 | return ( 224 | 225 | 226 | 227 | 228 | 229 | ); 230 | } 231 | ``` 232 | -------------------------------------------------------------------------------- /lib/Root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MutableRefObject, 3 | HTMLAttributes, 4 | ReactNode, 5 | forwardRef, 6 | useId, 7 | useRef, 8 | useState, 9 | useCallback, 10 | useMemo, 11 | } from 'react'; 12 | import { Slot } from '@radix-ui/react-slot'; 13 | 14 | import { ResplitContext } from './ResplitContext'; 15 | import { RootContext } from './RootContext'; 16 | import { CURSOR_BY_DIRECTION, GRID_TEMPLATE_BY_DIRECTION } from './const'; 17 | import { 18 | convertFrToNumber, 19 | convertPxToNumber, 20 | convertSizeToFr, 21 | isPx, 22 | mergeRefs, 23 | useIsomorphicLayoutEffect, 24 | } from './utils'; 25 | 26 | import type { FrValue, Order, PxValue, Direction } from './types'; 27 | import type { ResplitPaneOptions } from './Pane'; 28 | import type { ResplitSplitterOptions } from './Splitter'; 29 | 30 | /** 31 | * The state of an individual pane. 32 | * 33 | * @internal For internal use only. 34 | * 35 | * @see {@link PaneOptions} for the public API. 36 | */ 37 | export interface PaneChild { 38 | type: 'pane'; 39 | options: MutableRefObject< 40 | ResplitPaneOptions & { 41 | minSize: PxValue | FrValue; 42 | collapsedSize: PxValue | FrValue; 43 | } 44 | >; 45 | } 46 | 47 | /** 48 | * The state of an individual splitter. 49 | * 50 | * @internal For internal use only. 51 | * 52 | * @see {@link SplitterOptions} for the public API. 53 | */ 54 | export interface SplitterChild { 55 | type: 'splitter'; 56 | options: MutableRefObject< 57 | ResplitSplitterOptions & { 58 | size: PxValue; 59 | } 60 | >; 61 | } 62 | 63 | /** 64 | * An object containing panes and splitters. Indexed by order. 65 | * 66 | * @internal For internal use only. 67 | */ 68 | export interface ChildrenState { 69 | [order: Order]: PaneChild | SplitterChild; 70 | } 71 | 72 | export interface ResplitOptions { 73 | /** 74 | * Direction of the panes. 75 | * 76 | * @defaultValue 'horizontal' 77 | * 78 | */ 79 | direction?: Direction; 80 | } 81 | 82 | export type ResplitRootProps = ResplitOptions & 83 | HTMLAttributes & { 84 | /** 85 | * The children of the ResplitRoot component. 86 | */ 87 | children: ReactNode; 88 | /** 89 | * Merges props onto the immediate child. 90 | * 91 | * @defaultValue false 92 | * 93 | * @example 94 | * 95 | * ```tsx 96 | * 97 | *
98 | * ... 99 | *
100 | *
101 | * ``` 102 | */ 103 | asChild?: boolean; 104 | }; 105 | 106 | /** 107 | * The root component of a resplit layout. Provides context to all child components. 108 | * 109 | * @example 110 | * ```tsx 111 | * 112 | * 113 | * 114 | * 115 | * 116 | * ``` 117 | */ 118 | export const ResplitRoot = forwardRef(function Root( 119 | { direction = 'horizontal', children: reactChildren, style, asChild = false, ...rest }, 120 | forwardedRef, 121 | ) { 122 | const id = useId(); 123 | const Comp = asChild ? Slot : 'div'; 124 | const activeSplitterOrder = useRef(null); 125 | const rootRef = useRef(null); 126 | const [children, setChildren] = useState({}); 127 | 128 | const getChildElement = useCallback( 129 | (order: Order) => rootRef.current?.querySelector(`:scope > [data-resplit-order="${order}"]`), 130 | [], 131 | ); 132 | 133 | const getChildSize = useCallback( 134 | (order: Order) => rootRef.current?.style.getPropertyValue(`--resplit-${order}`), 135 | [], 136 | ); 137 | 138 | const getChildSizeAsNumber = useCallback( 139 | (order: Order) => { 140 | const childSize = getChildSize(order); 141 | if (!childSize) return 0; 142 | return isPx(childSize as PxValue | FrValue) 143 | ? convertPxToNumber(childSize as PxValue) 144 | : convertFrToNumber(childSize as FrValue); 145 | }, 146 | [getChildSize], 147 | ); 148 | 149 | const setChildSize = useCallback( 150 | (order: Order, size: FrValue | PxValue) => { 151 | rootRef.current?.style.setProperty(`--resplit-${order}`, size); 152 | const child = children[order]; 153 | 154 | if (child.type === 'pane') { 155 | const paneSplitter = getChildElement(order + 1); 156 | paneSplitter?.setAttribute( 157 | 'aria-valuenow', 158 | String(convertFrToNumber(size as FrValue).toFixed(2)), 159 | ); 160 | } 161 | }, 162 | [children, getChildElement], 163 | ); 164 | 165 | const isPaneMinSize = useCallback( 166 | (order: Order) => getChildElement(order)?.getAttribute('data-resplit-is-min') === 'true', 167 | [getChildElement], 168 | ); 169 | 170 | const setIsPaneMinSize = useCallback( 171 | (order: Order, value: boolean) => 172 | getChildElement(order)?.setAttribute('data-resplit-is-min', String(value)), 173 | [getChildElement], 174 | ); 175 | 176 | const isPaneCollapsed = useCallback( 177 | (order: Order) => getChildElement(order)?.getAttribute('data-resplit-is-collapsed') === 'true', 178 | [getChildElement], 179 | ); 180 | 181 | const setIsPaneCollapsed = useCallback( 182 | (order: Order, value: boolean) => 183 | getChildElement(order)?.setAttribute('data-resplit-is-collapsed', String(value)), 184 | [getChildElement], 185 | ); 186 | 187 | const getRootSize = useCallback( 188 | () => 189 | (direction === 'horizontal' ? rootRef.current?.offsetWidth : rootRef.current?.offsetHeight) || 190 | 0, 191 | [direction], 192 | ); 193 | 194 | const findResizablePane = useCallback( 195 | (start: number, direction: number) => { 196 | let index = start; 197 | let pane: PaneChild | null = children[index] as PaneChild; 198 | 199 | while (index >= 0 && index < Object.values(children).length) { 200 | const child = children[index]; 201 | 202 | if ( 203 | child.type === 'splitter' || 204 | (isPaneMinSize(index) && !child.options.current.collapsible) || 205 | (isPaneMinSize(index) && child.options.current.collapsible && isPaneCollapsed(index)) 206 | ) { 207 | index += direction; 208 | pane = null; 209 | } else { 210 | pane = child; 211 | break; 212 | } 213 | } 214 | 215 | return { index, pane }; 216 | }, 217 | [children, isPaneCollapsed, isPaneMinSize], 218 | ); 219 | 220 | const resizeByDelta = useCallback( 221 | (splitterOrder: Order, delta: number) => { 222 | const isGrowing = delta > 0; 223 | const isShrinking = delta < 0; 224 | 225 | // Find the previous and next resizable panes 226 | const { index: prevPaneIndex, pane: prevPane } = isShrinking 227 | ? findResizablePane(splitterOrder - 1, -1) 228 | : { index: splitterOrder - 1, pane: children[splitterOrder - 1] as PaneChild }; 229 | 230 | const { index: nextPaneIndex, pane: nextPane } = isGrowing 231 | ? findResizablePane(splitterOrder + 1, 1) 232 | : { index: splitterOrder + 1, pane: children[splitterOrder + 1] as PaneChild }; 233 | 234 | // Return if no panes are resizable 235 | if (!prevPane || !nextPane) return; 236 | 237 | const rootSize = getRootSize(); 238 | 239 | const prevPaneOptions = prevPane.options.current; 240 | let prevPaneSize = getChildSizeAsNumber(prevPaneIndex) + delta; 241 | const prevPaneMinSize = convertFrToNumber(convertSizeToFr(prevPaneOptions.minSize, rootSize)); 242 | const prevPaneisPaneMinSize = prevPaneSize <= prevPaneMinSize; 243 | const prevPaneisPaneCollapsed = 244 | !!prevPaneOptions.collapsible && prevPaneSize <= prevPaneMinSize / 2; 245 | 246 | const nextPaneOptions = nextPane.options.current; 247 | let nextPaneSize = getChildSizeAsNumber(nextPaneIndex) - delta; 248 | const nextPaneMinSize = convertFrToNumber(convertSizeToFr(nextPaneOptions.minSize, rootSize)); 249 | const nextPaneisPaneMinSize = nextPaneSize <= nextPaneMinSize; 250 | const nextPaneisPaneCollapsed = 251 | !!nextPaneOptions.collapsible && nextPaneSize <= nextPaneMinSize / 2; 252 | 253 | if (prevPaneisPaneCollapsed || nextPaneisPaneCollapsed) { 254 | if (prevPaneisPaneCollapsed) { 255 | const prevPaneCollapsedSize = convertFrToNumber( 256 | convertSizeToFr(prevPaneOptions.collapsedSize, rootSize), 257 | ); 258 | nextPaneSize = nextPaneSize + prevPaneSize - prevPaneCollapsedSize; 259 | prevPaneSize = prevPaneCollapsedSize; 260 | } 261 | 262 | if (nextPaneisPaneCollapsed) { 263 | const nextPaneCollapsedSize = convertFrToNumber( 264 | convertSizeToFr(nextPaneOptions.collapsedSize, rootSize), 265 | ); 266 | prevPaneSize = prevPaneSize + nextPaneSize - nextPaneCollapsedSize; 267 | nextPaneSize = nextPaneCollapsedSize; 268 | } 269 | } else { 270 | if (prevPaneisPaneMinSize) { 271 | nextPaneSize = nextPaneSize + (prevPaneSize - prevPaneMinSize); 272 | prevPaneSize = prevPaneMinSize; 273 | } 274 | 275 | if (nextPaneisPaneMinSize) { 276 | prevPaneSize = prevPaneSize + (nextPaneSize - nextPaneMinSize); 277 | nextPaneSize = nextPaneMinSize; 278 | } 279 | } 280 | 281 | setChildSize(prevPaneIndex, `${prevPaneSize}fr`); 282 | setIsPaneMinSize(prevPaneIndex, prevPaneisPaneMinSize); 283 | setIsPaneCollapsed(prevPaneIndex, prevPaneisPaneCollapsed); 284 | prevPaneOptions.onResize?.(`${prevPaneSize}fr`); 285 | 286 | setChildSize(nextPaneIndex, `${nextPaneSize}fr`); 287 | setIsPaneMinSize(nextPaneIndex, nextPaneisPaneMinSize); 288 | setIsPaneCollapsed(nextPaneIndex, nextPaneisPaneCollapsed); 289 | nextPaneOptions.onResize?.(`${nextPaneSize}fr`); 290 | }, 291 | [ 292 | children, 293 | findResizablePane, 294 | getChildSizeAsNumber, 295 | getRootSize, 296 | setChildSize, 297 | setIsPaneMinSize, 298 | setIsPaneCollapsed, 299 | ], 300 | ); 301 | 302 | /** 303 | * Mouse move handler 304 | * - Fire when user is interacting with splitter 305 | * - Handle resizing of panes 306 | */ 307 | const handleMouseMove = useCallback( 308 | (e: MouseEvent) => { 309 | // Return if no active splitter 310 | if (activeSplitterOrder.current === null) return; 311 | 312 | // Get the splitter element 313 | const splitter = getChildElement(activeSplitterOrder.current); 314 | 315 | // Return if no splitter element could be found 316 | if (!splitter) return; 317 | 318 | // Calculate available space 319 | const combinedSplitterSize = Object.entries(children).reduce( 320 | (total, [order, child]) => 321 | total + (child.type === 'splitter' ? getChildSizeAsNumber(Number(order)) : 0), 322 | 0, 323 | ); 324 | 325 | const availableSpace = getRootSize() - combinedSplitterSize; 326 | 327 | // Calculate delta 328 | const splitterRect = splitter.getBoundingClientRect(); 329 | const movement = 330 | direction === 'horizontal' ? e.clientX - splitterRect.left : e.clientY - splitterRect.top; 331 | const delta = movement / availableSpace; 332 | 333 | // Return if no change in the direction of movement 334 | if (!delta) return; 335 | 336 | resizeByDelta(activeSplitterOrder.current, delta); 337 | }, 338 | [children, direction, getChildElement, getChildSizeAsNumber, getRootSize, resizeByDelta], 339 | ); 340 | 341 | /** 342 | * Mouse up handler 343 | * - Fire when user stops interacting with splitter 344 | */ 345 | const handleMouseUp = useCallback(() => { 346 | const order = activeSplitterOrder.current; 347 | 348 | if (order === null) return; 349 | 350 | // Set data attributes 351 | rootRef.current?.setAttribute('data-resplit-resizing', 'false'); 352 | 353 | if (order !== null) { 354 | getChildElement(order)?.setAttribute('data-resplit-active', 'false'); 355 | } 356 | 357 | const prevPane = children[order - 1]; 358 | if (prevPane.type === 'pane') 359 | prevPane.options.current.onResizeEnd?.(getChildSize(order - 1) as FrValue); 360 | 361 | const nextPane = children[order + 1]; 362 | if (nextPane.type === 'pane') 363 | nextPane.options.current.onResizeEnd?.(getChildSize(order + 1) as FrValue); 364 | 365 | // Unset refs 366 | activeSplitterOrder.current = null; 367 | 368 | // Re-enable text selection and cursor 369 | document.documentElement.style.cursor = ''; 370 | document.documentElement.style.pointerEvents = ''; 371 | document.documentElement.style.userSelect = ''; 372 | 373 | // Remove mouse event listeners 374 | window.removeEventListener('mouseup', handleMouseUp); 375 | window.removeEventListener('mousemove', handleMouseMove); 376 | }, [children, getChildElement, getChildSize, handleMouseMove]); 377 | 378 | /** 379 | * Mouse down handler 380 | * - Fire when user begins interacting with splitter 381 | * - Handle resizing of panes using cursor 382 | */ 383 | const handleSplitterMouseDown = useCallback( 384 | (order: number) => () => { 385 | // Set active splitter 386 | activeSplitterOrder.current = order; 387 | 388 | // Set data attributes 389 | rootRef.current?.setAttribute('data-resplit-resizing', 'true'); 390 | 391 | if (activeSplitterOrder.current !== null) { 392 | getChildElement(activeSplitterOrder.current)?.setAttribute('data-resplit-active', 'true'); 393 | } 394 | 395 | const prevPane = children[order - 1]; 396 | if (prevPane.type === 'pane') prevPane.options.current.onResizeStart?.(); 397 | 398 | const nextPane = children[order + 1]; 399 | if (nextPane.type === 'pane') nextPane.options.current.onResizeStart?.(); 400 | 401 | // Disable text selection and cursor 402 | document.documentElement.style.cursor = CURSOR_BY_DIRECTION[direction]; 403 | document.documentElement.style.pointerEvents = 'none'; 404 | document.documentElement.style.userSelect = 'none'; 405 | 406 | // Add mouse event listeners 407 | window.addEventListener('mouseup', handleMouseUp); 408 | window.addEventListener('mousemove', handleMouseMove); 409 | }, 410 | [direction, children, getChildElement, handleMouseUp, handleMouseMove], 411 | ); 412 | 413 | /** 414 | * Key down handler 415 | * - Fire when user presses a key whilst focused on a splitter 416 | * - Handle resizing of panes using keyboard 417 | * - Refer to: https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/ 418 | */ 419 | const handleSplitterKeyDown = useCallback( 420 | (splitterOrder: number) => (e: React.KeyboardEvent) => { 421 | const isHorizontal = direction === 'horizontal'; 422 | const isVertical = direction === 'vertical'; 423 | 424 | if ((e.key === 'ArrowLeft' && isHorizontal) || (e.key === 'ArrowUp' && isVertical)) { 425 | resizeByDelta(splitterOrder, -0.01); 426 | } else if ( 427 | (e.key === 'ArrowRight' && isHorizontal) || 428 | (e.key === 'ArrowDown' && isVertical) 429 | ) { 430 | resizeByDelta(splitterOrder, 0.01); 431 | } else if (e.key === 'Home') { 432 | resizeByDelta(splitterOrder, -1); 433 | } else if (e.key === 'End') { 434 | resizeByDelta(splitterOrder, 1); 435 | } else if (e.key === 'Enter') { 436 | if (isPaneMinSize(splitterOrder - 1)) { 437 | const initialSize = 438 | (children[splitterOrder - 1] as PaneChild).options.current.initialSize || '1fr'; 439 | resizeByDelta(splitterOrder, convertFrToNumber(initialSize)); 440 | } else { 441 | resizeByDelta(splitterOrder, -1); 442 | } 443 | } 444 | }, 445 | [direction, children, resizeByDelta, isPaneMinSize], 446 | ); 447 | 448 | const registerPane = useCallback( 449 | (order: string, options: MutableRefObject) => { 450 | setChildren((children) => ({ 451 | ...children, 452 | [order]: { 453 | type: 'pane', 454 | options, 455 | }, 456 | })); 457 | }, 458 | [], 459 | ); 460 | 461 | const registerSplitter = useCallback( 462 | (order: string, options: MutableRefObject) => { 463 | setChildren((children) => ({ 464 | ...children, 465 | [order]: { 466 | type: 'splitter', 467 | options, 468 | }, 469 | })); 470 | }, 471 | [], 472 | ); 473 | 474 | const setPaneSizes = useCallback( 475 | (paneSizes: FrValue[]) => { 476 | paneSizes.forEach((paneSize, index) => { 477 | const order = index * 2; 478 | setChildSize(order, paneSize); 479 | setIsPaneMinSize( 480 | order, 481 | (children[order] as PaneChild).options.current.minSize === paneSize, 482 | ); 483 | setIsPaneCollapsed( 484 | order, 485 | (children[order] as PaneChild).options.current.collapsedSize === paneSize, 486 | ); 487 | 488 | const pane = children[order] as PaneChild; 489 | if (pane.type === 'pane') { 490 | pane.options.current.onResize?.(paneSize); 491 | } 492 | }); 493 | }, 494 | [children, setChildSize, setIsPaneMinSize, setIsPaneCollapsed], 495 | ); 496 | 497 | /** 498 | * Recalculate pane sizes when children are added or removed 499 | */ 500 | const childrenLength = Object.keys(children).length; 501 | 502 | useIsomorphicLayoutEffect(() => { 503 | const paneCount = Object.values(children).filter((child) => child.type === 'pane').length; 504 | 505 | Object.keys(children).forEach((key) => { 506 | const order = Number(key); 507 | const child = children[order]; 508 | 509 | if (child.type === 'pane') { 510 | const paneSize = isPaneMinSize(order) 511 | ? '0fr' 512 | : child.options.current.initialSize || `${1 / paneCount}fr`; 513 | setChildSize(order, paneSize); 514 | } else { 515 | setChildSize(order, child.options.current.size); 516 | } 517 | }); 518 | // eslint-disable-next-line react-hooks/exhaustive-deps 519 | }, [childrenLength]); 520 | 521 | const rootContextValue = useMemo( 522 | () => ({ 523 | id, 524 | direction, 525 | registerPane, 526 | registerSplitter, 527 | handleSplitterMouseDown, 528 | handleSplitterKeyDown, 529 | }), 530 | [id, direction, registerPane, registerSplitter, handleSplitterMouseDown, handleSplitterKeyDown], 531 | ); 532 | 533 | const resplitContextValue = useMemo( 534 | () => ({ 535 | isPaneMinSize, 536 | isPaneCollapsed, 537 | setPaneSizes, 538 | }), 539 | [isPaneMinSize, isPaneCollapsed, setPaneSizes], 540 | ); 541 | 542 | return ( 543 | 544 | 545 | { 554 | const childVar = `minmax(0, var(--resplit-${order}))`; 555 | return value ? `${value} ${childVar}` : `${childVar}`; 556 | }, 557 | '', 558 | ), 559 | ...style, 560 | }} 561 | {...rest} 562 | > 563 | {reactChildren} 564 | 565 | 566 | 567 | ); 568 | }); 569 | --------------------------------------------------------------------------------