├── .github └── workflows │ ├── e2e-ci.yml │ ├── eslint.yml │ ├── prettier.yml │ ├── typescript.yml │ └── vitest.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .parcelrc ├── .prettierignore ├── .prettierrc.json ├── .proxyrc ├── LICENSE ├── README.md ├── babel.config.js ├── declaration.d.ts ├── package.json ├── packages ├── react-resizable-panels-website │ ├── CodeMirror.css │ ├── index.html │ ├── index.tsx │ ├── package.json │ ├── playwright.config.ts │ ├── root.css │ ├── src │ │ ├── code.ts │ │ ├── components │ │ │ ├── Code.module.css │ │ │ ├── Code.tsx │ │ │ ├── Container.module.css │ │ │ ├── Container.tsx │ │ │ ├── Icon.module.css │ │ │ ├── Icon.tsx │ │ │ ├── Logo.module.css │ │ │ ├── Logo.tsx │ │ │ ├── LogoAnimation.ts │ │ │ ├── ResizeHandle.module.css │ │ │ ├── ResizeHandle.tsx │ │ │ ├── VisibleCursor.module.css │ │ │ ├── VisibleCursor.tsx │ │ │ └── useLogoAnimation.ts │ │ ├── hooks │ │ │ ├── useDebouncedCallback.ts │ │ │ └── useWindowSize.ts │ │ ├── routes │ │ │ ├── EndToEndTesting │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.css │ │ │ │ └── styles.module.css │ │ │ ├── Home │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── examples │ │ │ │ ├── Collapsible.module.css │ │ │ │ ├── Collapsible.tsx │ │ │ │ ├── Conditional.tsx │ │ │ │ ├── DebugLog.tsx │ │ │ │ ├── Example.module.css │ │ │ │ ├── Example.tsx │ │ │ │ ├── ExternalPersistence.tsx │ │ │ │ ├── Horizontal.tsx │ │ │ │ ├── ImperativePanelApi.module.css │ │ │ │ ├── ImperativePanelApi.tsx │ │ │ │ ├── ImperativePanelGroupApi.module.css │ │ │ │ ├── ImperativePanelGroupApi.tsx │ │ │ │ ├── Nested.tsx │ │ │ │ ├── Overflow.tsx │ │ │ │ ├── Persistence.tsx │ │ │ │ ├── Vertical.tsx │ │ │ │ ├── shared.module.css │ │ │ │ └── types.ts │ │ │ └── iframe │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ ├── suspense │ │ │ ├── ImportCache.ts │ │ │ └── SyntaxParsingCache.ts │ │ └── utils │ │ │ ├── UrlData.ts │ │ │ └── withAutoSizer.ts │ └── tests │ │ ├── Collapsing.spec.ts │ │ ├── CursorStyle.spec.ts │ │ ├── NestedGroups.spec.ts │ │ ├── ResizeHandle.spec.ts │ │ ├── Springy.spec.ts │ │ ├── StackingOrder.spec.ts │ │ ├── Storage.spec.ts │ │ ├── WindowSplitter.spec.ts │ │ └── utils │ │ ├── aria.ts │ │ ├── assert.ts │ │ ├── cursor.ts │ │ ├── debug.ts │ │ ├── panels.ts │ │ ├── url.ts │ │ └── verify.ts └── react-resizable-panels │ ├── .eslintrc.cjs │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── Panel.node.test.tsx │ ├── Panel.test.tsx │ ├── Panel.ts │ ├── PanelGroup.test.tsx │ ├── PanelGroup.ts │ ├── PanelGroupContext.ts │ ├── PanelResizeHandle.test.tsx │ ├── PanelResizeHandle.ts │ ├── PanelResizeHandleRegistry.ts │ ├── constants.ts │ ├── env-conditions │ │ ├── browser.ts │ │ ├── check-is-browser.ts │ │ ├── development.ts │ │ ├── production.ts │ │ └── server.ts │ ├── hooks │ │ ├── useForceUpdate.ts │ │ ├── useIsomorphicEffect.ts │ │ ├── usePanelGroupContext.ts │ │ ├── useUniqueId.ts │ │ ├── useWindowSplitterBehavior.ts │ │ └── useWindowSplitterPanelGroupBehavior.ts │ ├── index.ts │ ├── types.ts │ ├── utils │ │ ├── adjustLayoutByDelta.test.ts │ │ ├── adjustLayoutByDelta.ts │ │ ├── arrays.ts │ │ ├── assert.ts │ │ ├── calculateAriaValues.test.ts │ │ ├── calculateAriaValues.ts │ │ ├── calculateDeltaPercentage.ts │ │ ├── calculateDragOffsetPercentage.ts │ │ ├── calculateUnsafeDefaultLayout.test.ts │ │ ├── calculateUnsafeDefaultLayout.ts │ │ ├── callPanelCallbacks.ts │ │ ├── compareLayouts.test.ts │ │ ├── compareLayouts.ts │ │ ├── computePanelFlexBoxStyle.test.ts │ │ ├── computePanelFlexBoxStyle.ts │ │ ├── csp.ts │ │ ├── cursor.ts │ │ ├── debounce.ts │ │ ├── determinePivotIndices.ts │ │ ├── dom │ │ │ ├── getPanelElement.ts │ │ │ ├── getPanelElementsForGroup.ts │ │ │ ├── getPanelGroupElement.ts │ │ │ ├── getResizeHandleElement.ts │ │ │ ├── getResizeHandleElementIndex.ts │ │ │ ├── getResizeHandleElementsForGroup.ts │ │ │ ├── getResizeHandlePanelIds.ts │ │ │ └── isHTMLElement.ts │ │ ├── events │ │ │ ├── getResizeEventCoordinates.ts │ │ │ ├── getResizeEventCursorPosition.ts │ │ │ └── index.ts │ │ ├── getInputType.ts │ │ ├── initializeDefaultStorage.ts │ │ ├── numbers │ │ │ ├── fuzzyCompareNumbers.test.ts │ │ │ ├── fuzzyCompareNumbers.ts │ │ │ ├── fuzzyLayoutsEqual.ts │ │ │ └── fuzzyNumbersEqual.ts │ │ ├── rects │ │ │ ├── getIntersectingRectangle.test.ts │ │ │ ├── getIntersectingRectangle.ts │ │ │ ├── intersects.test.ts │ │ │ ├── intersects.ts │ │ │ └── types.ts │ │ ├── resizePanel.test.ts │ │ ├── resizePanel.ts │ │ ├── serialization.ts │ │ ├── test-utils.ts │ │ ├── validatePanelConstraints.test.ts │ │ ├── validatePanelConstraints.ts │ │ ├── validatePanelGroupLayout.test.ts │ │ └── validatePanelGroupLayout.ts │ └── vendor │ │ └── stacking-order.ts │ ├── vitest.config.ts │ └── vitest.node.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── vercel.json /.github/workflows/e2e-ci.yml: -------------------------------------------------------------------------------- 1 | name: "Tests: E2E" 2 | on: [pull_request] 3 | jobs: 4 | tests_e2e: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | - uses: pnpm/action-setup@v2 10 | with: 11 | version: 8 12 | - name: Install dependencies 13 | run: pnpm install --frozen-lockfile --recursive 14 | - name: Install Playwright dependencies 15 | run: npx playwright install 16 | - name: Build NPM package 17 | run: pnpm prerelease 18 | - name: Run Playwright tests 19 | run: cd packages/react-resizable-panels-website && pnpm test:e2e 20 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | name: "ESLint" 2 | on: [pull_request] 3 | jobs: 4 | tests-e2e: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | - uses: pnpm/action-setup@v2 10 | with: 11 | version: 8 12 | - name: Install dependencies 13 | run: pnpm install --frozen-lockfile --recursive 14 | - name: Run ESLint 15 | run: pnpm lint 16 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: "Prettier" 2 | on: [pull_request] 3 | jobs: 4 | tests-e2e: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | - uses: pnpm/action-setup@v2 10 | with: 11 | version: 8 12 | - name: Install dependencies 13 | run: pnpm install --frozen-lockfile --recursive 14 | - name: Run Prettier 15 | run: pnpm run prettier:ci 16 | -------------------------------------------------------------------------------- /.github/workflows/typescript.yml: -------------------------------------------------------------------------------- 1 | name: "TypeScript" 2 | on: [pull_request] 3 | jobs: 4 | tests-e2e: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | - uses: pnpm/action-setup@v2 10 | with: 11 | version: 8 12 | - name: Install dependencies 13 | run: pnpm install --frozen-lockfile --recursive 14 | - name: Build NPM package 15 | run: pnpm prerelease 16 | - name: Run TypeScript 17 | run: pnpm typescript 18 | -------------------------------------------------------------------------------- /.github/workflows/vitest.yml: -------------------------------------------------------------------------------- 1 | name: "Vitest" 2 | on: [pull_request] 3 | jobs: 4 | tests-e2e: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | - uses: pnpm/action-setup@v2 10 | with: 11 | version: 8 12 | - name: Install dependencies 13 | run: pnpm install --frozen-lockfile --recursive 14 | - name: Build NPM packages 15 | run: pnpm run prerelease 16 | - name: Run browser tests 17 | run: cd packages/react-resizable-panels && pnpm run test:browser 18 | - name: Run node tests 19 | run: cd packages/react-resizable-panels && pnpm run test:node 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | example/bundle.js 4 | example/.nojekyll 5 | dist 6 | website/dist 7 | node_modules 8 | .cache 9 | .parcel-cache 10 | .pnp.* 11 | .yarn 12 | .vscode -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{js,mjs,jsx,cjs,ts,tsx}": [ 5 | "@parcel/transformer-js", 6 | "@parcel/transformer-react-refresh-wrap" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .parcel-cache 2 | node_modules 3 | packages/react-resizable-panels/dist 4 | packages/react-resizable-panels-website/.cache 5 | packages/react-resizable-panels-website/dist 6 | packages/react-resizable-panels/src/vendor/stacking-order.ts -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /.proxyrc: -------------------------------------------------------------------------------- 1 | { 2 | "/demo": { 3 | "target": "http://localhost:8100/", 4 | "pathRewrite": { 5 | "^/demo": "" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Brian Vaughn 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 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@babel/preset-typescript")], 3 | plugins: [ 4 | "@babel/plugin-proposal-nullish-coalescing-operator", 5 | "@babel/plugin-proposal-optional-chaining", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const content: Record; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-resizable-panels-repo", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "packageManager": "pnpm@8.15.9", 8 | "scripts": { 9 | "clear": "pnpm run clear:git & pnpm run clear:library & pnpm run clear:node_modules & pnpm run clear:website", 10 | "clear:git": "git clean -dfx", 11 | "clear:library": "cd packages/react-resizable-panels && pnpm run clear", 12 | "clear:node_modules": "rm -rf ./node_modules", 13 | "clear:website": "cd packages/react-resizable-panels-website && pnpm run clear", 14 | "dev": "pnpm run /^dev:.*/", 15 | "dev:core": "cd packages/react-resizable-panels && pnpm watch", 16 | "dev:website": "cd packages/react-resizable-panels-website && pnpm watch", 17 | "docs": "cd packages/react-resizable-panels-website && pnpm build", 18 | "lint": "cd packages/react-resizable-panels && pnpm lint", 19 | "prerelease": "preconstruct build", 20 | "prettier": "prettier --write \"**/*.{css,html,js,json,jsx,ts,tsx}\"", 21 | "prettier:ci": "prettier --check \"**/*.{css,html,js,json,jsx,ts,tsx}\"", 22 | "typescript": "tsc --noEmit", 23 | "typescript:watch": "tsc --noEmit --watch" 24 | }, 25 | "devDependencies": { 26 | "@babel/preset-typescript": "^7.22.5", 27 | "@playwright/test": "^1.37.0", 28 | "@types/node": "^22.15.3", 29 | "@types/react": "latest", 30 | "@types/react-dom": "latest", 31 | "@typescript-eslint/eslint-plugin": "^5.62.0", 32 | "@typescript-eslint/parser": "^5.62.0", 33 | "@typescript-eslint/type-utils": "^5.62.0", 34 | "eslint": "^8.47.0", 35 | "parcel": "^2.9.3", 36 | "prettier": "latest", 37 | "process": "^0.11.10", 38 | "typescript": "^5.8.3" 39 | }, 40 | "dependencies": { 41 | "@parcel/config-default": "^2.9.3", 42 | "@parcel/core": "^2.9.3", 43 | "@parcel/packager-ts": "^2.9.3", 44 | "@parcel/transformer-js": "^2.9.3", 45 | "@parcel/transformer-react-refresh-wrap": "^2.9.3", 46 | "@parcel/transformer-typescript-types": "^2.9.3", 47 | "@preconstruct/cli": "^2.8.12" 48 | }, 49 | "preconstruct": { 50 | "packages": [ 51 | "packages/!(react-resizable-panels-website)" 52 | ], 53 | "exports": { 54 | "importConditionDefaultExport": "default" 55 | }, 56 | "___experimentalFlags_WILL_CHANGE_IN_PATCH": { 57 | "distInRoot": true, 58 | "importsConditions": true, 59 | "typeModule": true 60 | } 61 | }, 62 | "@parcel/resolver-default": { 63 | "packageExports": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/CodeMirror.css: -------------------------------------------------------------------------------- 1 | .tok-comment { 2 | color: var(--token-comment-color); 3 | } 4 | .tok-definition { 5 | color: var(--token-definition-color); 6 | } 7 | .tok-local { 8 | color: var(--token-local-color); 9 | } 10 | .tok-keyword { 11 | color: var(--token-keyword-color); 12 | } 13 | .tok-meta { 14 | color: var(--token-meta-color); 15 | } 16 | .tok-number { 17 | color: var(--token-number-color); 18 | } 19 | .tok-operator { 20 | color: var(--token-operator-color); 21 | } 22 | .tok-propertyName { 23 | color: var(--token-propertyName-color); 24 | } 25 | .tok-punctuation { 26 | color: var(--token-punctuation-color); 27 | } 28 | .tok-string { 29 | color: var(--token-string-color); 30 | } 31 | .tok-string2 { 32 | color: var(--token-string2-color); 33 | } 34 | .tok-typeName { 35 | color: var(--token-typeName-color); 36 | } 37 | .tok-variableName:not(.tok-definition) { 38 | color: var(--token-variableName-color); 39 | } 40 | .tok-variableName2 { 41 | color: var(--token-variableName2-color); 42 | } 43 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React components for resizable panels 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode, useEffect } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 4 | 5 | import HomeRoute from "./src/routes/Home"; 6 | import ConditionalExampleRoute from "./src/routes/examples/Conditional"; 7 | import ExternalPersistenceExampleRoute from "./src/routes/examples/ExternalPersistence"; 8 | import HorizontalExampleRoute from "./src/routes/examples/Horizontal"; 9 | import ImperativePanelApiExampleRoute from "./src/routes/examples/ImperativePanelApi"; 10 | import ImperativePanelGroupApiExampleRoute from "./src/routes/examples/ImperativePanelGroupApi"; 11 | import NestedExampleRoute from "./src/routes/examples/Nested"; 12 | import OverflowExampleRoute from "./src/routes/examples/Overflow"; 13 | import PersistenceExampleRoute from "./src/routes/examples/Persistence"; 14 | import CollapsibleExampleRoute from "./src/routes/examples/Collapsible"; 15 | import VerticalExampleRoute from "./src/routes/examples/Vertical"; 16 | import EndToEndTestingRoute from "./src/routes/EndToEndTesting"; 17 | import IframeRoute from "./src/routes/iframe"; 18 | 19 | const router = createBrowserRouter([ 20 | { 21 | path: "/", 22 | element: , 23 | }, 24 | { 25 | path: "/examples/conditional", 26 | element: , 27 | }, 28 | { 29 | path: "/examples/external-persistence", 30 | element: , 31 | }, 32 | { 33 | path: "/examples/horizontal", 34 | element: , 35 | }, 36 | { 37 | path: "/examples/imperative-panel-api", 38 | element: , 39 | }, 40 | { 41 | path: "/examples/imperative-panel-group-api", 42 | element: , 43 | }, 44 | { 45 | path: "/examples/nested", 46 | element: , 47 | }, 48 | { 49 | path: "/examples/overflow", 50 | element: , 51 | }, 52 | { 53 | path: "/examples/persistence", 54 | element: , 55 | }, 56 | { 57 | path: "/examples/collapsible", 58 | element: , 59 | }, 60 | { 61 | path: "/examples/vertical", 62 | element: , 63 | }, 64 | 65 | // Special route used by e2e tests 66 | { 67 | path: "/__e2e", 68 | element: , 69 | }, 70 | { 71 | path: "/__e2e/iframe", 72 | element: , 73 | }, 74 | ]); 75 | 76 | const rootElement = document.getElementById("root")!; 77 | const root = createRoot(rootElement); 78 | root.render( 79 | 80 | 81 | 82 | ); 83 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-resizable-panels-website", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "build": "parcel build \"index.html\"", 7 | "clear": "pnpm run clear:parcel-cache & pnpm run clear:node_modules", 8 | "clear:node_modules": "rm -rf ./node_modules", 9 | "clear:parcel-cache": "rm -rf ./.parcel-cache", 10 | "kill-port": "kill-port ${PORT:-1234}", 11 | "test:e2e": "playwright test", 12 | "test:e2e:debug": "DEBUG=true playwright test", 13 | "watch": "parcel \"index.html\"" 14 | }, 15 | "dependencies": { 16 | "@codemirror/lang-css": "latest", 17 | "@codemirror/lang-html": "latest", 18 | "@codemirror/lang-javascript": "latest", 19 | "@codemirror/lang-markdown": "latest", 20 | "@codemirror/language": "latest", 21 | "@codemirror/state": "latest", 22 | "@lezer/highlight": "latest", 23 | "kill-port": "latest", 24 | "localforage": "latest", 25 | "match-sorter": "latest", 26 | "react": "experimental", 27 | "react-dom": "experimental", 28 | "react-resizable-panels": "*", 29 | "react-router-dom": "latest", 30 | "react-virtualized-auto-sizer": "^1.0.10", 31 | "sort-by": "latest", 32 | "suspense": "^0.0.38" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | 3 | const { DEBUG } = process.env; 4 | 5 | const config: PlaywrightTestConfig = { 6 | use: { 7 | browserName: "chromium", 8 | headless: true, 9 | viewport: { width: 400, height: 300 }, 10 | ignoreHTTPSErrors: true, 11 | video: "on-first-retry", 12 | }, 13 | webServer: { 14 | command: "npm run watch", 15 | reuseExistingServer: true, 16 | url: "http://localhost:1234", 17 | }, 18 | timeout: 60_000, 19 | }; 20 | 21 | if (process.env.DEBUG) { 22 | config.use = { 23 | ...config.use, 24 | headless: false, 25 | 26 | launchOptions: { 27 | // slowMo: DEBUG ? 250 : undefined, 28 | }, 29 | }; 30 | } 31 | 32 | export default config; 33 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/root.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-background-code: #050a15; 3 | --color-background-default: #081120; 4 | --color-brand: #dcadff; 5 | --color-button-background: #2a3343; 6 | --color-button-background-hover: #39414d; 7 | --color-button-border: #18181a; 8 | --color-code: #dcadff; 9 | --color-default: #ffffff; 10 | --color-dim: #91a0ba; 11 | --color-horizontal-rule: #39414d; 12 | --color-input: #ffffff; 13 | --color-input-background: #18181a; 14 | --color-input-border: #39414d; 15 | --color-input-border-focused: #dcadff; 16 | --color-link: #dcadff; 17 | --color-panel-background: #192230; 18 | --color-panel-background-alternate: #202124; 19 | --color-resize-bar: #515b6a; 20 | --color-resize-bar-active: #b1bdd0; 21 | --color-resize-bar-hover: #515b6a; 22 | --color-warning-background: #7400cc; 23 | 24 | --color-logo-background: #2a3343; 25 | --color-logo-chip-1: #dcadff; 26 | --color-logo-chip-2: #ff79ad; 27 | --color-logo-chip-3: #ffdc7a; 28 | --color-logo-chip-4: #7ac1ff; 29 | 30 | --token-comment-color: #666; 31 | --token-definition-color: #fefefe; 32 | --token-local-color: #fefefe; 33 | --token-keyword-color: #ff7aad; 34 | --token-meta-color: #8237bb; 35 | --token-number-color: #ffdc7a; 36 | --token-operator-color: #fefefe; 37 | --token-propertyName-color: #ff98eb; 38 | --token-punctuation-color: #dcadff; 39 | --token-string-color: #7ac1ff; 40 | --token-string2-color: #7ac1ff; 41 | --token-typeName-color: #c67bff; 42 | --token-variableName-color: #c67bff; 43 | --token-variableName2-color: #c67bff; 44 | } 45 | 46 | :root, 47 | html, 48 | body, 49 | #root { 50 | padding: 0; 51 | margin: 0; 52 | 53 | background-color: var(--color-background-default); 54 | color: var(--color-default); 55 | 56 | font-family: Arial, Helvetica, sans-serif; 57 | font-size: 12px; 58 | } 59 | 60 | * { 61 | box-sizing: border-box; 62 | line-height: 1.5em; 63 | } 64 | 65 | p { 66 | margin: 0.5rem 0; 67 | } 68 | 69 | code { 70 | color: var(--color-code); 71 | background-color: var(--color-background-code); 72 | font-family: monospace; 73 | padding: 0 0.25em; 74 | border-radius: 0.25em; 75 | } 76 | 77 | h2 { 78 | font-weight: normal; 79 | margin: 0.5rem 0; 80 | } 81 | 82 | a { 83 | color: var(--color-link); 84 | } 85 | 86 | ::-webkit-scrollbar { 87 | width: 0.75rem; 88 | height: 0.75rem; 89 | background: transparent; 90 | } 91 | 92 | ::-webkit-scrollbar-corner { 93 | background: transparent; 94 | } 95 | 96 | ::-webkit-scrollbar-track { 97 | border-radius: 0.75rem; 98 | background: transparent; 99 | } 100 | 101 | ::-webkit-scrollbar-thumb { 102 | border-radius: 0.75rem; 103 | background: var(--color-scroll-thumb); 104 | } 105 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Code.module.css: -------------------------------------------------------------------------------- 1 | .Code { 2 | color: var(--color-default); 3 | white-space: pre; 4 | } 5 | 6 | .LineNumber { 7 | color: var(--token-comment-color); 8 | border-right: 1px solid var(--color-panel-background); 9 | display: inline-block; 10 | padding-right: 1ch; 11 | margin-right: 1ch; 12 | min-width: calc(var(--max-line-number-length) + 1ch + 1px); 13 | user-select: none; 14 | } 15 | 16 | .Loader { 17 | background-color: var(--color-background-code); 18 | color: var(--color-default); 19 | display: inline-flex; 20 | align-items: center; 21 | gap: 1ch; 22 | border-radius: 0.5rem; 23 | padding: 0.25rem 0.5rem; 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Code.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, useMemo } from "react"; 2 | 3 | import { 4 | Language, 5 | ParsedTokens, 6 | escapeHtmlEntities, 7 | parsedTokensToHtml, 8 | syntaxParsingCache, 9 | } from "../suspense/SyntaxParsingCache"; 10 | 11 | import styles from "./Code.module.css"; 12 | 13 | export default function Code({ 14 | className = "", 15 | code, 16 | language = "jsx", 17 | showLineNumbers = false, 18 | }: { 19 | className?: string; 20 | code: string; 21 | language: Language; 22 | showLineNumbers?: boolean; 23 | }) { 24 | return ( 25 | 32 | } 33 | > 34 | 40 | 41 | ); 42 | } 43 | 44 | function Fallback({ 45 | className, 46 | code, 47 | showLineNumbers, 48 | }: { 49 | className: string; 50 | code: string; 51 | showLineNumbers: boolean; 52 | }) { 53 | const htmlLines = useMemo(() => { 54 | return code.split("\n").map((line, index) => { 55 | const escaped = escapeHtmlEntities(line); 56 | 57 | if (showLineNumbers) { 58 | return `${ 59 | index + 1 60 | } ${escaped}`; 61 | } 62 | 63 | return escaped; 64 | }); 65 | }, [showLineNumbers, code]); 66 | 67 | const maxLineNumberLength = `${htmlLines.length + 1}`.length; 68 | 69 | return ( 70 | ") }} 73 | style={{ 74 | // @ts-ignore 75 | "--max-line-number-length": `${maxLineNumberLength}ch`, 76 | }} 77 | /> 78 | ); 79 | } 80 | 81 | function Parser({ 82 | className, 83 | code, 84 | language, 85 | showLineNumbers, 86 | }: { 87 | className: string; 88 | code: string; 89 | language: Language; 90 | showLineNumbers: boolean; 91 | }) { 92 | const tokens = syntaxParsingCache.read(code, language); 93 | return ( 94 | 99 | ); 100 | } 101 | 102 | function TokenRenderer({ 103 | className, 104 | showLineNumbers, 105 | tokens, 106 | }: { 107 | className: string; 108 | showLineNumbers: boolean; 109 | tokens: ParsedTokens[]; 110 | }) { 111 | const maxLineNumberLength = `${tokens.length + 1}`.length; 112 | 113 | const html = useMemo(() => { 114 | return tokens 115 | .map((lineTokens, index) => { 116 | const html = parsedTokensToHtml(lineTokens); 117 | 118 | if (showLineNumbers) { 119 | return `${ 120 | index + 1 121 | } ${html}`; 122 | } 123 | 124 | return html; 125 | }) 126 | .join("
"); 127 | }, [showLineNumbers, tokens]); 128 | 129 | return ( 130 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Container.module.css: -------------------------------------------------------------------------------- 1 | .Container { 2 | padding: 2rem; 3 | overflow-x: hidden; 4 | max-width: 1024px; 5 | margin: 0 auto; 6 | } 7 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | 3 | import styles from "./Container.module.css"; 4 | 5 | export default function Container({ 6 | children, 7 | className = "", 8 | }: PropsWithChildren & { className?: string }) { 9 | return
{children}
; 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Icon.module.css: -------------------------------------------------------------------------------- 1 | .Icon { 2 | flex: 0 0 1rem; 3 | width: 1rem; 4 | height: 1rem; 5 | fill: currentColor; 6 | } 7 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react"; 2 | import styles from "./Icon.module.css"; 3 | 4 | export type IconType = 5 | | "chevron-down" 6 | | "close" 7 | | "collapse" 8 | | "css" 9 | | "dialog" 10 | | "drag" 11 | | "expand" 12 | | "files" 13 | | "horizontal-collapse" 14 | | "horizontal-expand" 15 | | "html" 16 | | "loading" 17 | | "markdown" 18 | | "resize" 19 | | "resize-horizontal" 20 | | "resize-vertical" 21 | | "search" 22 | | "typescript" 23 | | "warning"; 24 | 25 | export default function Icon({ 26 | className = "", 27 | type, 28 | ...rest 29 | }: SVGAttributes & { 30 | className?: string; 31 | type: IconType; 32 | }) { 33 | let path = ""; 34 | switch (type) { 35 | case "chevron-down": 36 | path = "M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"; 37 | break; 38 | case "close": 39 | path = 40 | "M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z"; 41 | break; 42 | case "collapse": 43 | path = 44 | "M19.5,3.09L15,7.59V4H13V11H20V9H16.41L20.91,4.5L19.5,3.09M4,13V15H7.59L3.09,19.5L4.5,20.91L9,16.41V20H11V13H4Z"; 45 | break; 46 | case "css": 47 | path = 48 | "M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z"; 49 | break; 50 | case "dialog": 51 | path = 52 | "M18 18V20H4A2 2 0 0 1 2 18V8H4V18M22 6V14A2 2 0 0 1 20 16H8A2 2 0 0 1 6 14V6A2 2 0 0 1 8 4H20A2 2 0 0 1 22 6M20 6H8V14H20Z"; 53 | break; 54 | case "drag": 55 | path = 56 | "M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"; 57 | break; 58 | case "expand": 59 | path = 60 | "M10,21V19H6.41L10.91,14.5L9.5,13.09L5,17.59V14H3V21H10M14.5,10.91L19,6.41V10H21V3H14V5H17.59L13.09,9.5L14.5,10.91Z"; 61 | break; 62 | case "files": 63 | path = 64 | "M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"; 65 | break; 66 | case "horizontal-collapse": 67 | path = 68 | "M13,20V4H15.03V20H13M10,20V4H12.03V20H10M5,8L9.03,12L5,16V13H2V11H5V8M20,16L16,12L20,8V11H23V13H20V16Z"; 69 | break; 70 | case "horizontal-expand": 71 | path = 72 | "M9,11H15V8L19,12L15,16V13H9V16L5,12L9,8V11M2,20V4H4V20H2M20,20V4H22V20H20Z"; 73 | break; 74 | case "html": 75 | path = 76 | "M12,17.56L16.07,16.43L16.62,10.33H9.38L9.2,8.3H16.8L17,6.31H7L7.56,12.32H14.45L14.22,14.9L12,15.5L9.78,14.9L9.64,13.24H7.64L7.93,16.43L12,17.56M4.07,3H19.93L18.5,19.2L12,21L5.5,19.2L4.07,3Z"; 77 | break; 78 | case "loading": 79 | path = 80 | "M13,2.03V2.05L13,4.05C17.39,4.59 20.5,8.58 19.96,12.97C19.5,16.61 16.64,19.5 13,19.93V21.93C18.5,21.38 22.5,16.5 21.95,11C21.5,6.25 17.73,2.5 13,2.03M11,2.06C9.05,2.25 7.19,3 5.67,4.26L7.1,5.74C8.22,4.84 9.57,4.26 11,4.06V2.06M4.26,5.67C3,7.19 2.25,9.04 2.05,11H4.05C4.24,9.58 4.8,8.23 5.69,7.1L4.26,5.67M2.06,13C2.26,14.96 3.03,16.81 4.27,18.33L5.69,16.9C4.81,15.77 4.24,14.42 4.06,13H2.06M7.1,18.37L5.67,19.74C7.18,21 9.04,21.79 11,22V20C9.58,19.82 8.23,19.25 7.1,18.37M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z"; 81 | break; 82 | case "markdown": 83 | path = 84 | "M20.56 18H3.44C2.65 18 2 17.37 2 16.59V7.41C2 6.63 2.65 6 3.44 6H20.56C21.35 6 22 6.63 22 7.41V16.59C22 17.37 21.35 18 20.56 18M6.81 15.19V11.53L8.73 13.88L10.65 11.53V15.19H12.58V8.81H10.65L8.73 11.16L6.81 8.81H4.89V15.19H6.81M19.69 12H17.77V8.81H15.85V12H13.92L16.81 15.28L19.69 12Z"; 85 | break; 86 | case "resize": 87 | path = 88 | "M10.59,12L14.59,8H11V6H18V13H16V9.41L12,13.41V16H20V4H8V12H10.59M22,2V18H12V22H2V12H6V2H22M10,14H4V20H10V14Z"; 89 | break; 90 | case "resize-horizontal": 91 | path = 92 | "M18,16V13H15V22H13V2H15V11H18V8L22,12L18,16M2,12L6,16V13H9V22H11V2H9V11H6V8L2,12Z"; 93 | break; 94 | case "resize-vertical": 95 | path = 96 | "M8,18H11V15H2V13H22V15H13V18H16L12,22L8,18M12,2L8,6H11V9H2V11H22V9H13V6H16L12,2Z"; 97 | break; 98 | case "search": 99 | path = 100 | "M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"; 101 | break; 102 | case "typescript": 103 | path = 104 | "M3,3H21V21H3V3M13.71,17.86C14.21,18.84 15.22,19.59 16.8,19.59C18.4,19.59 19.6,18.76 19.6,17.23C19.6,15.82 18.79,15.19 17.35,14.57L16.93,14.39C16.2,14.08 15.89,13.87 15.89,13.37C15.89,12.96 16.2,12.64 16.7,12.64C17.18,12.64 17.5,12.85 17.79,13.37L19.1,12.5C18.55,11.54 17.77,11.17 16.7,11.17C15.19,11.17 14.22,12.13 14.22,13.4C14.22,14.78 15.03,15.43 16.25,15.95L16.67,16.13C17.45,16.47 17.91,16.68 17.91,17.26C17.91,17.74 17.46,18.09 16.76,18.09C15.93,18.09 15.45,17.66 15.09,17.06L13.71,17.86M13,11.25H8V12.75H9.5V20H11.25V12.75H13V11.25Z"; 105 | break; 106 | case "warning": 107 | path = 108 | "M5,3H19A2,2 0 0,1 21,5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3M13,13V7H11V13H13M13,17V15H11V17H13Z"; 109 | break; 110 | } 111 | 112 | return ( 113 | 118 | 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Logo.module.css: -------------------------------------------------------------------------------- 1 | .Svg { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | import { sequence } from "./LogoAnimation"; 4 | import { useLogoAnimation } from "./useLogoAnimation"; 5 | 6 | import styles from "./Logo.module.css"; 7 | 8 | export default function Logo({ className = "" }: { className?: string }) { 9 | const svgRef = useRef(null); 10 | 11 | useLogoAnimation(svgRef, sequence); 12 | 13 | return ( 14 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/LogoAnimation.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "react-resizable-panels"; 2 | 3 | export const Targets = { 4 | bottomLeft: "bottomLeft", 5 | bottomRight: "bottomRight", 6 | topLeft: "topLeft", 7 | topRight: "topRight", 8 | }; 9 | 10 | export type Target = keyof typeof Targets; 11 | 12 | const targets = Object.keys(Targets) as Array; 13 | 14 | const Properties = { 15 | height: "height", 16 | x: "x", 17 | y: "y", 18 | width: "width", 19 | }; 20 | 21 | type Property = keyof typeof Properties; 22 | 23 | const properties = Object.keys(Properties) as Array; 24 | 25 | type Rectangle = { 26 | x: number; 27 | y: number; 28 | width: number; 29 | height: number; 30 | }; 31 | 32 | type Pause = { 33 | type: "pause"; 34 | duration: number; 35 | }; 36 | 37 | type Stage = { 38 | type: "stage"; 39 | duration: number; 40 | paths: { 41 | [key in Target]: Rectangle; 42 | }; 43 | }; 44 | 45 | const PAUSE_DURATION = 500; 46 | const STAGE_DURATION = 1_000; 47 | 48 | const pause: Pause = { 49 | type: "pause", 50 | duration: PAUSE_DURATION, 51 | }; 52 | 53 | type Frames = Array; 54 | 55 | const frames: Frames = [ 56 | pause, 57 | { 58 | type: "stage", 59 | duration: STAGE_DURATION, 60 | paths: { 61 | bottomLeft: { x: 10, y: 165, width: 72, height: 72 }, 62 | bottomRight: { x: 92, y: 93, width: 146, height: 144 }, 63 | topLeft: { x: 10, y: 10, width: 72, height: 145 }, 64 | topRight: { x: 92, y: 10, width: 146, height: 73 }, 65 | }, 66 | }, 67 | pause, 68 | { 69 | type: "stage", 70 | duration: STAGE_DURATION, 71 | paths: { 72 | bottomLeft: { x: 10, y: 165, width: 146, height: 72 }, 73 | bottomRight: { x: 166, y: 93, width: 72, height: 144 }, 74 | topLeft: { x: 10, y: 10, width: 146, height: 145 }, 75 | topRight: { x: 166, y: 10, width: 72, height: 73 }, 76 | }, 77 | }, 78 | pause, 79 | { 80 | type: "stage", 81 | duration: STAGE_DURATION, 82 | paths: { 83 | bottomLeft: { x: 10, y: 78, width: 146, height: 159 }, 84 | bottomRight: { x: 166, y: 204, width: 72, height: 31 }, 85 | topLeft: { x: 10, y: 10, width: 146, height: 58 }, 86 | topRight: { x: 166, y: 10, width: 72, height: 184 }, 87 | }, 88 | }, 89 | ]; 90 | 91 | type AnimatedProperty = { 92 | target: Target; 93 | property: Property; 94 | from: number; 95 | to: number; 96 | }; 97 | 98 | type Animation = { 99 | type: "animation"; 100 | duration: number; 101 | properties: AnimatedProperty[]; 102 | }; 103 | 104 | export type Sequence = Array; 105 | 106 | export const sequence: Sequence = []; 107 | 108 | const stages = frames.filter((step): step is Stage => step.type === "stage"); 109 | 110 | for (let index = 0; index < frames.length; index++) { 111 | const frame = frames[index]; 112 | assert(frame, `No frame found for index ${index}`); 113 | 114 | switch (frame.type) { 115 | case "pause": 116 | sequence.push(frame); 117 | break; 118 | case "stage": 119 | const fromStage = frame; 120 | 121 | const index = stages.indexOf(fromStage); 122 | const toStage = index + 1 < stages.length ? stages[index + 1] : stages[0]; 123 | assert(toStage != null, "Stage not found"); 124 | 125 | const changedProperties: AnimatedProperty[] = []; 126 | 127 | targets.forEach((target) => { 128 | properties.forEach((property) => { 129 | const from = fromStage.paths[target][property]; 130 | const to = toStage.paths[target][property]; 131 | 132 | if (from !== to) { 133 | changedProperties.push({ 134 | target, 135 | property, 136 | from, 137 | to, 138 | }); 139 | } 140 | }); 141 | }, []); 142 | 143 | sequence.push({ 144 | type: "animation", 145 | duration: frame.duration, 146 | properties: changedProperties, 147 | }); 148 | break; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/ResizeHandle.module.css: -------------------------------------------------------------------------------- 1 | .ResizeHandle { 2 | flex: 0 0 0.5rem; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | outline: none; 7 | color: var(--color-resize-bar); 8 | } 9 | .ResizeHandle:hover { 10 | color: var(--color-resize-bar-hover); 11 | } 12 | .ResizeHandle[data-resize-handle-active] { 13 | color: var(--color-resize-bar-active); 14 | } 15 | 16 | .ResizeHandle .ResizeHandleThumb[data-direction="vertical"] { 17 | rotate: 90deg; 18 | } 19 | 20 | @media (pointer: coarse) { 21 | .ResizeHandle { 22 | flex: 0 0 2rem; 23 | } 24 | 25 | .ResizeHandle .ResizeHandleThumb { 26 | flex: 0 0 1.25rem; 27 | height: 1.25rem; 28 | width: 1.25rem; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/ResizeHandle.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PanelResizeHandle, 3 | PanelResizeHandleProps, 4 | usePanelGroupContext, 5 | } from "react-resizable-panels"; 6 | 7 | import styles from "./ResizeHandle.module.css"; 8 | import Icon from "./Icon"; 9 | 10 | export function ResizeHandle({ 11 | className = "", 12 | id, 13 | ...rest 14 | }: PanelResizeHandleProps & { 15 | className?: string; 16 | id?: string; 17 | }) { 18 | const { direction } = usePanelGroupContext(); 19 | 20 | return ( 21 | 26 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/VisibleCursor.module.css: -------------------------------------------------------------------------------- 1 | .VisibleCursor { 2 | pointer-events: none; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | width: 2rem; 7 | height: 2rem; 8 | border: 2px dashed #ffffffaa; 9 | border-radius: 1rem; 10 | margin-left: -1rem; 11 | margin-top: -1rem; 12 | transition: border 250ms; 13 | background-color: #00000044; 14 | } 15 | .VisibleCursor[data-state="down"] { 16 | border: 2px solid #ff0000; 17 | } 18 | .VisibleCursor[data-state="up"] { 19 | } 20 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/VisibleCursor.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from "react"; 2 | import { getResizeEventCoordinates } from "../../../react-resizable-panels/src/utils/events/getResizeEventCoordinates"; 3 | import styles from "./VisibleCursor.module.css"; 4 | 5 | export function VisibleCursor() { 6 | useLayoutEffect(() => { 7 | const element = document.createElement("div"); 8 | element.classList.add(styles.VisibleCursor!); 9 | element.setAttribute("data-state", "up"); 10 | 11 | document.body.appendChild(element); 12 | 13 | const onMouseDown = () => { 14 | element.setAttribute("data-state", "down"); 15 | }; 16 | 17 | const onMouseMove = (event: PointerEvent) => { 18 | const { x, y } = getResizeEventCoordinates(event); 19 | element.style.left = x + "px"; 20 | element.style.top = y + "px"; 21 | }; 22 | 23 | const onMouseUp = () => { 24 | element.setAttribute("data-state", "up"); 25 | }; 26 | 27 | document.addEventListener("pointerdown", onMouseDown, true); 28 | document.addEventListener("pointermove", onMouseMove, true); 29 | document.addEventListener("pointerup", onMouseUp, true); 30 | 31 | return () => { 32 | document.body.removeChild(element); 33 | 34 | document.removeEventListener("pointerdown", onMouseDown, true); 35 | document.removeEventListener("pointermove", onMouseMove, true); 36 | document.removeEventListener("pointerup", onMouseUp, true); 37 | }; 38 | }); 39 | 40 | return null; 41 | } 42 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/components/useLogoAnimation.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from "react"; 2 | import { assert } from "react-resizable-panels"; 3 | import { Sequence, Target } from "./LogoAnimation"; 4 | 5 | export function useLogoAnimation( 6 | svgRef: RefObject, 7 | sequence: Sequence 8 | ) { 9 | useEffect(() => { 10 | const svg = svgRef.current!; 11 | 12 | const targetToPathElements: { [key in Target]: SVGPathElement } = { 13 | bottomLeft: svg.children[1] as SVGPathElement, 14 | bottomRight: svg.children[2] as SVGPathElement, 15 | topLeft: svg.children[0] as SVGPathElement, 16 | topRight: svg.children[3] as SVGPathElement, 17 | }; 18 | 19 | const startTime = performance.now(); 20 | const duration = sequence.reduce( 21 | (total, animation) => total + animation.duration, 22 | 0 23 | ); 24 | 25 | let animationFrameId: number | null = null; 26 | 27 | function animate() { 28 | const currentTime = performance.now(); 29 | const elapsed = (currentTime - startTime) % duration; 30 | 31 | let segment; 32 | let accumulatedDuration = 0; 33 | for (let index = 0; index < sequence.length; index++) { 34 | segment = sequence[index]; 35 | assert(segment, `No segment found for index "${index}"`); 36 | 37 | if ( 38 | elapsed >= accumulatedDuration && 39 | elapsed <= accumulatedDuration + segment.duration 40 | ) { 41 | break; 42 | } else { 43 | accumulatedDuration += segment.duration; 44 | } 45 | } 46 | 47 | if (segment?.type === "animation") { 48 | const value = easing( 49 | (elapsed - accumulatedDuration) / segment.duration 50 | ); 51 | 52 | segment.properties.forEach(({ from, property, target, to }) => { 53 | const pathElement = targetToPathElements[target]; 54 | pathElement.setAttribute(property, `${from + value * (to - from)}`); 55 | }); 56 | } 57 | 58 | animationFrameId = requestAnimationFrame(animate); 59 | } 60 | 61 | animationFrameId = requestAnimationFrame(animate); 62 | 63 | return () => { 64 | if (animationFrameId) { 65 | cancelAnimationFrame(animationFrameId); 66 | } 67 | }; 68 | }, []); 69 | } 70 | 71 | // https://easings.net/#easeInOutBack 72 | function easing(value: number): number { 73 | const c1 = 1.70158; 74 | const c2 = c1 * 1.525; 75 | 76 | return value < 0.5 77 | ? (Math.pow(2 * value, 2) * ((c2 + 1) * 2 * value - c2)) / 2 78 | : (Math.pow(2 * value - 2, 2) * ((c2 + 1) * (value * 2 - 2) + c2) + 2) / 2; 79 | } 80 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/hooks/useDebouncedCallback.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useMemo, useRef } from "react"; 2 | 3 | export default function useDebouncedCallback( 4 | callback: (...args: A) => void, 5 | delayMs: number = 100 6 | ) { 7 | // Track latest inline callback function. 8 | const callbackRef = useRef<(...args: A) => void>(callback); 9 | useLayoutEffect(() => { 10 | callbackRef.current = callback; 11 | }); 12 | 13 | // Cancel any pending callbacks when unmounting. 14 | const timeoutIdRef = useRef(null); 15 | useLayoutEffect(() => { 16 | return () => { 17 | const timeoutId = timeoutIdRef.current; 18 | if (timeoutId != null) { 19 | clearTimeout(timeoutId); 20 | } 21 | }; 22 | }, []); 23 | 24 | const memoizedCallback = useMemo(() => { 25 | return (...args: any) => { 26 | const timeoutId = timeoutIdRef.current; 27 | if (timeoutId != null) { 28 | clearTimeout(timeoutId); 29 | } 30 | 31 | timeoutIdRef.current = setTimeout(() => { 32 | const callback = callbackRef.current; 33 | callback(...args); 34 | }, delayMs); 35 | }; 36 | }, [delayMs]); 37 | 38 | return memoizedCallback; 39 | } 40 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | 3 | export default function useWindowSize() { 4 | const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); 5 | 6 | const handleSize = () => { 7 | setWindowSize({ 8 | width: window.innerWidth, 9 | height: window.innerHeight, 10 | }); 11 | }; 12 | 13 | useLayoutEffect(() => { 14 | handleSize(); 15 | 16 | window.addEventListener("resize", handleSize); 17 | 18 | return () => window.removeEventListener("resize", handleSize); 19 | }, []); 20 | 21 | return windowSize; 22 | } 23 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.css: -------------------------------------------------------------------------------- 1 | .Panel { 2 | background-color: rgba(0, 0, 0, 0.25); 3 | width: 100%; 4 | height: 100%; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | white-space: pre-wrap; 9 | } 10 | 11 | .PanelGroup { 12 | align-items: stretch; 13 | } 14 | 15 | .PanelResizeHandle { 16 | flex: 0 0 2px; 17 | background: #ffffff55; 18 | } 19 | .PanelResizeHandle[data-resize-handle-state="drag"] { 20 | background: #ff0000; 21 | } 22 | .PanelResizeHandle[data-resize-handle-state="hover"] { 23 | background: #ffffffaa; 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/EndToEndTesting/styles.module.css: -------------------------------------------------------------------------------- 1 | .Container { 2 | width: 100vw; 3 | height: 100vh; 4 | display: flex; 5 | flex-direction: column; 6 | position: relative; 7 | } 8 | 9 | .FormRow { 10 | flex: 0 0 auto; 11 | display: flex; 12 | flex-direction: row; 13 | justify-content: space-between; 14 | } 15 | 16 | .FormColumn { 17 | width: 100%; 18 | flex: 0 0 auto; 19 | display: flex; 20 | flex-direction: row; 21 | } 22 | 23 | .Children { 24 | flex: 1 1 auto; 25 | } 26 | 27 | .Input { 28 | flex: 1 1 auto; 29 | min-width: 5ch; 30 | max-width: 15ch; 31 | } 32 | 33 | .Spacer { 34 | flex: 1; 35 | } 36 | 37 | .Modal { 38 | position: absolute; 39 | top: 50%; 40 | left: 50%; 41 | padding: 1rem 1.5rem; 42 | transform: translate(-50%, -50%); 43 | background-color: rgba(255, 255, 255, 0.5); 44 | border-radius: 1rem; 45 | border: 2px solid black; 46 | user-select: none; 47 | text-align: center; 48 | } 49 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import Container from "../../components/Container"; 3 | 4 | import Logo from "../../components/Logo"; 5 | 6 | import styles from "./styles.module.css"; 7 | 8 | const LINKS = [ 9 | { path: "horizontal", title: "Horizontal layouts" }, 10 | { path: "vertical", title: "Vertical layouts" }, 11 | { path: "nested", title: "Nested groups" }, 12 | { path: "persistence", title: "Persistent layouts" }, 13 | { path: "overflow", title: "Overflow content" }, 14 | { path: "collapsible", title: "Collapsible panels" }, 15 | { path: "conditional", title: "Conditional panels" }, 16 | { path: "external-persistence", title: "External persistence" }, 17 | { path: "imperative-panel-api", title: "Imperative Panel API" }, 18 | { path: "imperative-panel-group-api", title: "Imperative PanelGroup API" }, 19 | ]; 20 | 21 | export default function HomeRoute() { 22 | return ( 23 | 24 |
25 | 26 |
27 |
28 | 29 | 30 |
31 |
32 | ); 33 | } 34 | 35 | function ExampleLink({ 36 | index, 37 | path, 38 | title, 39 | }: { 40 | index: number; 41 | path: string; 42 | title: string; 43 | }) { 44 | return ( 45 |
  • 46 |
    {index + 1}
    47 | 48 | {title} 49 | 50 |
  • 51 | ); 52 | } 53 | 54 | function ExamplesPanel() { 55 | return ( 56 |
    57 |

    Examples

    58 |
      59 | {LINKS.map((link, index) => ( 60 | 66 | ))} 67 |
    68 |
    69 | ); 70 | } 71 | 72 | function HeaderPanel() { 73 | return ( 74 |
    78 | 79 | 80 | 81 | 82 | react 83 | resizable 84 | panels 85 | 86 | 87 |

    88 | React components for resizable panels 89 |

    90 |
    91 |
    92 | ); 93 | } 94 | 95 | function InstallationPanel() { 96 | return ( 97 |
    98 |

    Installation

    99 |
    100 | # npm 101 |
    102 | npm install 103 | react-resizable-panels 104 |
    105 |
    106 | # yarn 107 |
    108 | yarn add 109 | react-resizable-panels 110 |
    111 |
    112 | # pnpm 113 |
    114 | pnpm add 115 | react-resizable-panels 116 |
    117 |
    118 | # bun 119 |
    120 | bun add 121 | react-resizable-panels 122 |
    123 |
    124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/Home/styles.module.css: -------------------------------------------------------------------------------- 1 | .Container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | } 6 | 7 | .TopRow, 8 | .BottomRow { 9 | display: flex; 10 | flex-direction: row; 11 | align-items: flex-start; 12 | gap: 1rem; 13 | } 14 | 15 | @media (max-width: 1000px) { 16 | .Container { 17 | font-size: 1.2em; 18 | } 19 | 20 | .BottomRow { 21 | flex-direction: column; 22 | } 23 | .ExamplesPanel { 24 | order: 2; 25 | } 26 | .InstallationPanel { 27 | order: 1; 28 | } 29 | } 30 | 31 | @media (max-width: 500px) { 32 | .ExamplesList { 33 | font-size: 1.2em; 34 | } 35 | } 36 | 37 | .SubHeader { 38 | margin: 0 0 0.5rem; 39 | font-size: 1.2em; 40 | } 41 | 42 | .HeaderLink { 43 | text-decoration: none; 44 | background-color: var(--color-logo-background); 45 | border-radius: 0.5em; 46 | padding: 0; 47 | color: inherit; 48 | } 49 | 50 | .Code { 51 | display: inline-block; 52 | background-color: var(--color-background-code); 53 | font-family: monospace; 54 | } 55 | 56 | .ExamplesPanel, 57 | .InstallationPanel { 58 | background-color: var(--color-background-code); 59 | border-radius: 0.5em; 60 | padding: 1em; 61 | } 62 | 63 | .ExamplesList { 64 | margin: 0; 65 | padding: 0; 66 | list-style: none; 67 | } 68 | 69 | .ExamplesListItem { 70 | display: flex; 71 | flex-direction: row; 72 | align-items: stretch; 73 | line-height: 1.75em; 74 | min-height: 1.75em; 75 | } 76 | .ListItemNumber { 77 | width: 3ch; 78 | text-align: right; 79 | display: inline-block; 80 | color: var(--token-comment-color); 81 | font-family: monospace; 82 | display: inline-block; 83 | padding-right: 1ch; 84 | margin-right: 1ch; 85 | border-right: 1px solid var(--color-panel-background); 86 | } 87 | 88 | .ExampleLink:hover { 89 | text-decoration: none; 90 | } 91 | 92 | .HeaderLogo { 93 | width: 12rem; 94 | height: 12rem; 95 | } 96 | 97 | .HeaderRow { 98 | display: inline-flex; 99 | flex-direction: row; 100 | justify-content: center; 101 | gap: 0.5rem; 102 | padding: 0.5em; 103 | padding-right: 1rem; 104 | 105 | border-radius: 1em; 106 | background-color: var(--color-logo-background); 107 | } 108 | 109 | .HeaderTexts { 110 | font-family: sans-serif; 111 | 112 | display: flex; 113 | flex-direction: column; 114 | justify-content: center; 115 | margin-top: -0.75em; 116 | } 117 | .HeaderText { 118 | font-size: 3.25em; 119 | line-height: 1em; 120 | color: #fff; 121 | } 122 | 123 | .HeaderTagLine { 124 | font-size: 1.2em; 125 | text-align: center; 126 | margin: 0 0 0.5rem; 127 | } 128 | 129 | @media (min-width: 301px) and (max-width: 325px) { 130 | .HeaderText { 131 | font-size: 2.5em; 132 | } 133 | .HeaderTagLine { 134 | font-size: 1em; 135 | } 136 | .HeaderLogo { 137 | width: 10em; 138 | height: 10em; 139 | } 140 | } 141 | @media (max-width: 300px) { 142 | .HeaderText { 143 | font-size: 2em; 144 | } 145 | .HeaderTagLine { 146 | font-size: 0.8em; 147 | } 148 | .HeaderLogo { 149 | width: 8em; 150 | height: 8em; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Collapsible.module.css: -------------------------------------------------------------------------------- 1 | .ResizeHandle, 2 | .ResizeHandleCollapsed { 3 | width: 0.25rem; 4 | transition: 250ms linear background-color; 5 | background-color: var(--color-button-background); 6 | outline: none; 7 | } 8 | .ResizeHandle:hover, 9 | .ResizeHandle[data-resize-handle-active], 10 | .ResizeHandleCollapsed:hover, 11 | .ResizeHandleCollapsed[data-resize-handle-active] { 12 | background-color: var(--color-brand); 13 | } 14 | .ResizeHandleCollapsed { 15 | background-color: var(--color-button-background-hover); 16 | } 17 | 18 | @media (pointer: coarse) { 19 | .ResizeHandle, 20 | .ResizeHandleCollapsed { 21 | width: 1rem; 22 | } 23 | } 24 | 25 | .IDE { 26 | background-color: var(--color-panel-background); 27 | border-radius: 0.5rem; 28 | } 29 | 30 | .Toolbar { 31 | width: 3rem; 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | background-color: var(--color-button-background); 36 | padding-top: 0.5rem; 37 | } 38 | 39 | .ToolbarIcon, 40 | .ToolbarIconActive { 41 | flex: 0 0 2.5rem !important; 42 | width: 3rem !important; 43 | height: 2.5rem !important; 44 | padding: 0 0.75rem; 45 | } 46 | .ToolbarIcon { 47 | color: var(--color-dim); 48 | } 49 | .ToolbarIconActive { 50 | color: var(--color-default); 51 | border-left: 2px solid var(--color-default); 52 | } 53 | 54 | .FileList { 55 | flex: 1 1 auto; 56 | 57 | font-size: 1rem; 58 | font-family: monospace; 59 | white-space: pre; 60 | 61 | container-type: inline-size; 62 | container-name: filelist; 63 | } 64 | 65 | .SourceIcon { 66 | } 67 | 68 | .DirectoryEntry, 69 | .FileEntry { 70 | display: flex; 71 | align-items: center; 72 | padding-left: 1ch; 73 | gap: 1ch; 74 | height: 1.5em; 75 | } 76 | 77 | .FileEntry { 78 | padding-left: 3ch; 79 | cursor: pointer; 80 | transition: 250ms linear background-color; 81 | } 82 | .FileEntry:hover, 83 | .FileEntry[data-current] { 84 | background-color: var(--color-button-background); 85 | } 86 | .FileIcon { 87 | flex: 0 0 3ch; 88 | display: inline-flex; 89 | align-items: center; 90 | justify-content: center; 91 | color: var(--color-brand); 92 | font-weight: bold; 93 | font-size: 0.75em; 94 | } 95 | 96 | @container filelist (max-width: 50px) { 97 | .FileList .DirectoryEntry, 98 | .FileList .FileEntry { 99 | padding-left: 0; 100 | justify-content: center; 101 | } 102 | 103 | .FileList .DirectoryName, 104 | .FileList .FileName { 105 | display: none; 106 | } 107 | } 108 | 109 | .SourceTabs { 110 | flex: 0 0 auto; 111 | display: flex; 112 | flex-direction: row; 113 | overflow: auto; 114 | background-color: var(--color-button-background); 115 | } 116 | 117 | .SourceTab { 118 | flex: 0 0 auto; 119 | padding: 0.5rem 1ch; 120 | gap: 1ch; 121 | display: flex; 122 | flex-direction: row; 123 | align-items: center; 124 | white-space: nowrap; 125 | background-color: var(--color-panel-background); 126 | border-right: 1px solid var(--color-background-default); 127 | cursor: pointer; 128 | } 129 | .SourceTab[data-current] { 130 | background-color: var(--color-background-code); 131 | } 132 | 133 | .CloseButton { 134 | cursor: pointer; 135 | background: none; 136 | padding: 0.25em; 137 | border-radius: 0.25em; 138 | outline: none; 139 | border: none; 140 | } 141 | .CloseButton:hover { 142 | background-color: var(--color-button-background); 143 | } 144 | .CloseIcon { 145 | height: 0.75rem !important; 146 | width: 0.75rem !important; 147 | color: var(--color-default); 148 | } 149 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Conditional.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Panel, PanelGroup } from "react-resizable-panels"; 3 | 4 | import { ResizeHandle } from "../../components/ResizeHandle"; 5 | 6 | import Example from "./Example"; 7 | import styles from "./shared.module.css"; 8 | 9 | export default function ConditionalRoute() { 10 | const [showLeftPanel, setShowLeftPanel] = useState(true); 11 | const [showRightPanel, setShowRightPanel] = useState(true); 12 | 13 | return ( 14 | 21 | } 22 | headerNode={ 23 | <> 24 |

    25 | Panels can be conditionally rendered. The order ensures 26 | they are (re)added in the correct order. 27 |

    28 |

    29 | If an autoSaveId is provided, layouts will be stored 30 | separately for each panel combination. 31 |

    32 |

    33 | 39 | 45 |

    46 | 47 | } 48 | title="Conditional panels" 49 | /> 50 | ); 51 | } 52 | 53 | function Content({ 54 | showLeftPanel, 55 | showRightPanel, 56 | }: { 57 | showLeftPanel: boolean; 58 | showRightPanel: boolean; 59 | }) { 60 | return ( 61 |
    62 | 67 | {showLeftPanel && ( 68 | <> 69 | 70 |
    left
    71 |
    72 | 73 | 74 | )} 75 | 76 |
    middle
    77 |
    78 | {showRightPanel && ( 79 | <> 80 | 81 | 82 |
    right
    83 |
    84 | 85 | )} 86 |
    87 |
    88 | ); 89 | } 90 | 91 | const CODE = ` 92 | 93 | {showLeftPanel && ( 94 | <> 95 | 96 | left 97 | 98 | 99 | 100 | )} 101 | 102 | middle 103 | 104 | {showRightPanel && ( 105 | <> 106 | 107 | 108 | right 109 | 110 | 111 | )} 112 | 113 | `; 114 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/DebugLog.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useImperativeHandle, useRef } from "react"; 2 | import { LogEntry } from "./types"; 3 | 4 | export type ImperativeDebugLogHandle = { 5 | log: (logEntry: LogEntry) => void; 6 | }; 7 | 8 | // Used for e2e testing only 9 | export default function DebugLog({ 10 | apiRef, 11 | }: { 12 | apiRef: RefObject; 13 | }) { 14 | const ref = useRef(null); 15 | 16 | useImperativeHandle(apiRef, () => ({ 17 | log: (logEntry: LogEntry) => { 18 | const div = ref.current; 19 | if (div) { 20 | try { 21 | let objectsArray: LogEntry[] = []; 22 | 23 | const textContent = div.textContent!.trim(); 24 | if (textContent !== "") { 25 | objectsArray = JSON.parse(textContent) as LogEntry[]; 26 | } 27 | 28 | objectsArray.push(logEntry); 29 | 30 | div.textContent = JSON.stringify(objectsArray); 31 | } catch (error) {} 32 | } 33 | }, 34 | })); 35 | 36 | return ( 37 |
    38 | [] 39 |
    40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Example.module.css: -------------------------------------------------------------------------------- 1 | .Route { 2 | padding: 2rem; 3 | overflow-x: hidden; 4 | max-width: 1024px; 5 | margin: 0 auto; 6 | } 7 | 8 | .Header { 9 | margin: 0 0 1rem; 10 | font-size: 1.2em; 11 | display: inline-flex; 12 | flex-direction: row; 13 | align-items: center; 14 | gap: 1ch; 15 | 16 | max-width: 100%; 17 | overflow: hidden; 18 | background-color: var(--color-panel-background); 19 | border-radius: 0.5rem; 20 | padding: 0.5rem 1rem; 21 | } 22 | 23 | .Title { 24 | white-space: nowrap; 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | flex: 1 1 auto; 28 | } 29 | 30 | .ExampleContainer { 31 | margin: 2rem 0; 32 | } 33 | 34 | .Code { 35 | display: block; 36 | white-space: pre; 37 | padding: 1rem; 38 | border-radius: 1rem; 39 | overflow-x: auto; 40 | } 41 | 42 | @media (max-width: 500px) { 43 | .Header { 44 | font-size: 1.4em; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Example.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useLayoutEffect } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import Code from "../../components/Code"; 5 | import { Language } from "../../suspense/SyntaxParsingCache"; 6 | 7 | import styles from "./Example.module.css"; 8 | 9 | export default function Example({ 10 | code, 11 | exampleNode, 12 | headerNode, 13 | language = "jsx", 14 | title, 15 | }: { 16 | code: string; 17 | exampleNode: ReactNode; 18 | headerNode: ReactNode; 19 | language?: Language; 20 | title: string; 21 | }) { 22 | useLayoutEffect(() => { 23 | window.scrollTo(0, 0); 24 | }, []); 25 | 26 | return ( 27 |
    28 |

    29 | 30 | Home 31 | 32 | →{title} 33 |

    34 | {headerNode} 35 |
    {exampleNode}
    36 | 42 |
    43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/ExternalPersistence.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Panel, PanelGroup, PanelGroupStorage } from "react-resizable-panels"; 3 | import { useNavigate } from "react-router-dom"; 4 | 5 | import { ResizeHandle } from "../../components/ResizeHandle"; 6 | 7 | import Example from "./Example"; 8 | import styles from "./shared.module.css"; 9 | import Icon from "../../components/Icon"; 10 | 11 | export default function ExternalPersistence() { 12 | return ( 13 | } 16 | headerNode={ 17 | <> 18 |

    19 | By default, a PanelGroup with an{" "} 20 | autoSaveId will store layout information in{" "} 21 | localStorage. This example shows how to use the{" "} 22 | storage prop to override that behavior. For this demo, 23 | layout is saved as part of the URL hash. 24 |

    25 |

    26 | 27 | Note the storage API is synchronous. If an 28 | async source is used (e.g. a database) then values should be 29 | pre-fetched during the initial render (e.g. using Suspense). 30 |

    31 |

    32 | 33 | Note calls to storage.setItem are debounced by{" "} 34 | 100ms. Depending on your implementation, you may 35 | wish to use a larger interval than that. 36 |

    37 | 38 | } 39 | title="External persistence" 40 | /> 41 | ); 42 | } 43 | 44 | function Content() { 45 | const navigate = useNavigate(); 46 | 47 | const urlStorage = useMemo( 48 | () => ({ 49 | getItem(name: string) { 50 | try { 51 | const raw = decodeURI(window.location.hash.substring(1)); 52 | if (raw) { 53 | const parsed = JSON.parse(raw); 54 | return parsed[name] || ""; 55 | } 56 | } catch (error) { 57 | console.error(error); 58 | 59 | return ""; 60 | } 61 | }, 62 | setItem(name: string, value: string) { 63 | const encoded = encodeURI( 64 | JSON.stringify({ 65 | [name]: value, 66 | }) 67 | ); 68 | 69 | // Update the hash without interfering with the browser's Back button. 70 | navigate("#" + encoded, { replace: true }); 71 | }, 72 | }), 73 | [navigate] 74 | ); 75 | 76 | return ( 77 |
    78 | 84 | 85 |
    left
    86 |
    87 | 88 | 89 |
    middle
    90 |
    91 | 92 | 93 |
    right
    94 |
    95 |
    96 |
    97 | ); 98 | } 99 | 100 | const CODE = ` 101 | const navigate = useNavigate(); 102 | 103 | const urlStorage = useMemo(() => ({ 104 | getItem(name) { 105 | try { 106 | const parsed = JSON.parse(decodeURI(window.location.hash.substring(1))); 107 | return parsed[name] || ""; 108 | } catch (error) { 109 | console.error(error); 110 | return ""; 111 | } 112 | }, 113 | setItem(name, value) { 114 | const encoded = encodeURI(JSON.stringify({ 115 | [name]: value 116 | })); 117 | 118 | // Update the hash without interfering with the browser's Back button. 119 | navigate('#' + encoded, { replace: true }); 120 | } 121 | }), [navigate]); 122 | 123 | 124 | left 125 | 126 | middle 127 | 128 | right 129 | 130 | `; 131 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Horizontal.tsx: -------------------------------------------------------------------------------- 1 | import { Panel, PanelGroup } from "react-resizable-panels"; 2 | 3 | import { ResizeHandle } from "../../components/ResizeHandle"; 4 | 5 | import Example from "./Example"; 6 | import styles from "./shared.module.css"; 7 | 8 | export default function HorizontalRoute() { 9 | return ( 10 | } 13 | headerNode={ 14 | <> 15 |

    16 | This example is a 3-column horizontal PanelGroup. 17 | Click/touch the empty space between the panels and drag to resize. 18 | Arrow keys can also be used to resize panels. 19 |

    20 |

    21 | These panels use the minSize property to prevent them 22 | from being resized smaller than a minimal percentage of the overall 23 | group. 24 |

    25 | 26 | } 27 | title="Horizontal layouts" 28 | /> 29 | ); 30 | } 31 | 32 | function Content() { 33 | return ( 34 |
    35 | 36 | 37 |
    left
    38 |
    39 | 40 | 41 |
    middle
    42 |
    43 | 44 | 45 |
    right
    46 |
    47 |
    48 |
    49 | ); 50 | } 51 | 52 | const CODE = ` 53 | 54 | 55 | left 56 | 57 | 58 | 59 | middle 60 | 61 | 62 | 63 | right 64 | 65 | 66 | `; 67 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/ImperativePanelApi.module.css: -------------------------------------------------------------------------------- 1 | .ToggleRow { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | margin-bottom: 0.5rem; 6 | } 7 | 8 | .Toggles { 9 | display: flex; 10 | flex-direction: row; 11 | gap: 1ch; 12 | } 13 | 14 | .SizeInput { 15 | width: 7ch; 16 | padding: 0 0 0 1ch; 17 | background-color: var(--color-input-background); 18 | border: 2px solid var(--color-input-border); 19 | color: var(--color-input); 20 | border-radius: 0.5rem; 21 | outline: none; 22 | } 23 | .SizeInput:focus { 24 | outline: none; 25 | border-color: var(--color-input-border-focused); 26 | } 27 | 28 | .ResizeForm { 29 | display: flex; 30 | flex-direction: row; 31 | align-items: center; 32 | gap: 1ch; 33 | margin: 0; 34 | } 35 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.module.css: -------------------------------------------------------------------------------- 1 | .TopRow { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | margin-bottom: 0.5rem; 6 | } 7 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/ImperativePanelGroupApi.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import type { ImperativePanelGroupHandle } from "react-resizable-panels"; 3 | import { Panel, PanelGroup } from "react-resizable-panels"; 4 | 5 | import { ResizeHandle } from "../../components/ResizeHandle"; 6 | 7 | import Code from "../../components/Code"; 8 | import Example from "./Example"; 9 | import styles from "./ImperativePanelGroupApi.module.css"; 10 | import sharedStyles from "./shared.module.css"; 11 | 12 | export default function ImperativePanelGroupApiRoute() { 13 | return ( 14 | } 17 | headerNode={ 18 | <> 19 |

    20 | PanelGroup provides the following imperative API 21 | methods: 22 |

    23 |
      24 |
    • 25 | 30 | Panel group id 31 |
    • 32 |
    • 33 | 38 | Current size of panels (in both percentage and pixel units) 39 |
    • 40 |
    • 41 | 46 | Resize all panels (using either percentage or pixel units) 47 |
    • 48 |
    49 | 50 | } 51 | language="tsx" 52 | title="Imperative PanelGroup API" 53 | /> 54 | ); 55 | } 56 | 57 | function Content() { 58 | const [sizes, setSizes] = useState([]); 59 | 60 | const panelGroupRef = useRef(null); 61 | 62 | const onLayout = (sizes: number[]) => { 63 | setSizes(sizes); 64 | }; 65 | 66 | const resetLayout = () => { 67 | const panelGroup = panelGroupRef.current; 68 | if (panelGroup) { 69 | panelGroup.setLayout([50, 50]); 70 | } 71 | }; 72 | 73 | const left = sizes[0]; 74 | const right = sizes[1]; 75 | 76 | return ( 77 | <> 78 |
    79 | 82 |
    83 |
    84 | 91 | 92 |
    93 | left: {left ? Math.round(left) : "-"} 94 |
    95 |
    96 | 97 | 98 |
    99 | right: {right ? Math.round(right) : "-"} 100 |
    101 |
    102 |
    103 |
    104 | 105 | ); 106 | } 107 | 108 | const CODE = ` 109 | import { 110 | ImperativePanelGroupHandle, 111 | Panel, 112 | PanelGroup, 113 | PanelResizeHandle, 114 | } from "react-resizable-panels"; 115 | 116 | const ref = useRef(null); 117 | 118 | const resetLayout = () => { 119 | const panelGroup = ref.current; 120 | if (panelGroup) { 121 | // Reset each Panel to 50% of the group's width 122 | panelGroup.setLayout([50, 50]); 123 | } 124 | }; 125 | 126 | 127 | left 128 | 129 | right 130 | 131 | `; 132 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Nested.tsx: -------------------------------------------------------------------------------- 1 | import { Panel, PanelGroup } from "react-resizable-panels"; 2 | 3 | import { ResizeHandle } from "../../components/ResizeHandle"; 4 | 5 | import Example from "./Example"; 6 | import styles from "./shared.module.css"; 7 | 8 | export default function NestedRoute() { 9 | return ( 10 | } 13 | headerNode={ 14 |

    15 | This example shows nested groups. Click near the intersection of two 16 | groups to resize in multiple directions at once. 17 |

    18 | } 19 | title="Nested groups" 20 | /> 21 | ); 22 | } 23 | 24 | function Content() { 25 | return ( 26 |
    27 | 28 | 29 |
    left
    30 |
    31 | 32 | 33 | 34 | 35 |
    top
    36 |
    37 | 38 | 39 | 40 | 41 |
    left
    42 |
    43 | 44 | 45 |
    right
    46 |
    47 |
    48 |
    49 |
    50 |
    51 | 52 | 53 |
    right
    54 |
    55 |
    56 |
    57 | ); 58 | } 59 | 60 | const CODE = ` 61 | 62 | 63 | left 64 | 65 | 66 | 67 | 68 | 69 | top 70 | 71 | 72 | 73 | 74 | 75 | left 76 | 77 | 78 | 79 | right 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | right 88 | 89 | 90 | `; 91 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Persistence.tsx: -------------------------------------------------------------------------------- 1 | import { Panel, PanelGroup } from "react-resizable-panels"; 2 | 3 | import { ResizeHandle } from "../../components/ResizeHandle"; 4 | 5 | import Example from "./Example"; 6 | import styles from "./shared.module.css"; 7 | 8 | export default function NestedRoute() { 9 | return ( 10 | } 13 | headerNode={ 14 |

    15 | Layouts are automatically saved when an autoSaveId prop 16 | is provided. Try this by editing the layout below and then reloading 17 | the page. 18 |

    19 | } 20 | title="Persistent layouts" 21 | /> 22 | ); 23 | } 24 | 25 | function Content() { 26 | return ( 27 |
    28 | 33 | 34 |
    left
    35 |
    36 | 37 | 38 |
    middle
    39 |
    40 | 41 | 42 |
    right
    43 |
    44 |
    45 |
    46 | ); 47 | } 48 | 49 | const CODE = ` 50 | 51 | 52 | left 53 | 54 | 55 | 56 | middle 57 | 58 | 59 | 60 | right 61 | 62 | 63 | `; 64 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/Vertical.tsx: -------------------------------------------------------------------------------- 1 | import { Panel, PanelGroup } from "react-resizable-panels"; 2 | 3 | import { ResizeHandle } from "../../components/ResizeHandle"; 4 | 5 | import Example from "./Example"; 6 | import styles from "./shared.module.css"; 7 | 8 | export default function VerticalRoute() { 9 | return ( 10 | } 13 | headerNode={ 14 | <> 15 |

    16 | This example is a 2-row vertical PanelGroup. 17 | Click/touch the empty space between the panels and drag to resize. 18 | Arrow keys can also be used to resize panels. 19 |

    20 |

    21 | These panels use the maxSize property to prevent them 22 | from being resized larger than a maximal percentage of the overall 23 | group. 24 |

    25 | 26 | } 27 | title="Vertical layouts" 28 | /> 29 | ); 30 | } 31 | 32 | function Content() { 33 | return ( 34 |
    35 | 36 | 42 |
    top
    43 |
    44 | 45 | 46 |
    bottom
    47 |
    48 |
    49 |
    50 | ); 51 | } 52 | 53 | const CODE = ` 54 | 55 | 56 | top 57 | 58 | 59 | 60 | bottom 61 | 62 | 63 | `; 64 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/shared.module.css: -------------------------------------------------------------------------------- 1 | .PanelGroupWrapper { 2 | height: 20rem; 3 | } 4 | .PanelGroupWrapper[data-short] { 5 | height: 10rem; 6 | } 7 | .PanelGroupWrapper[data-tall] { 8 | height: 30rem; 9 | } 10 | 11 | .PanelGroup { 12 | font-size: 2rem; 13 | } 14 | 15 | .Panel { 16 | display: flex; 17 | flex-direction: row; 18 | font-size: 2rem; 19 | } 20 | 21 | .PanelColumn, 22 | .PanelRow { 23 | display: flex; 24 | } 25 | .PanelColumn { 26 | flex-direction: column; 27 | } 28 | .PanelRow { 29 | flex-direction: row; 30 | } 31 | 32 | .Centered { 33 | flex: 1 1 auto; 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | background-color: var(--color-panel-background); 38 | border-radius: 0.5rem; 39 | overflow: hidden; 40 | font-size: 1rem; 41 | padding: 0.5rem; 42 | word-break: break-all; 43 | } 44 | 45 | .ResizeHandle { 46 | } 47 | 48 | .Overflow { 49 | width: 100%; 50 | height: 100%; 51 | overflow: auto; 52 | padding: 1rem; 53 | 54 | /* Firefox fixes */ 55 | scrollbar-width: thin; 56 | scrollbar-color: var(--color-scroll-thumb) transparent; 57 | } 58 | 59 | .Button, 60 | .ButtonDisabled { 61 | background-color: var(--color-button-background); 62 | color: var(--color-default); 63 | border: none; 64 | border-radius: 0.5rem; 65 | padding: 0.25rem 0.5rem; 66 | } 67 | .Button:hover { 68 | background-color: var(--color-button-background-hover); 69 | } 70 | .ButtonDisabled { 71 | opacity: 0.5; 72 | } 73 | 74 | .Buttons { 75 | display: flex; 76 | flex-direction: row; 77 | align-items: center; 78 | gap: 1ch; 79 | margin-bottom: 1rem; 80 | } 81 | 82 | .Capitalize { 83 | text-transform: capitalize; 84 | } 85 | 86 | .WarningBlock { 87 | display: inline-flex; 88 | flex-direction: row; 89 | flex-wrap: wrap; 90 | align-items: center; 91 | gap: 1ch; 92 | background: var(--color-warning-background); 93 | padding: 0.5em; 94 | border-radius: 0.5rem; 95 | } 96 | .WarningIcon { 97 | flex: 0 0 2rem; 98 | width: 2rem; 99 | height: 2rem; 100 | } 101 | 102 | .InlineCode { 103 | margin-right: 1.5ch; 104 | } 105 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/examples/types.ts: -------------------------------------------------------------------------------- 1 | export type PanelCollapseLogEntryType = "onCollapse"; 2 | export type PanelExpandLogEntryType = "onExpand"; 3 | export type PanelGroupLayoutLogEntryType = "onLayout"; 4 | export type PanelResizeHandleDraggingLogEntryType = "onDragging"; 5 | export type PanelResizeLogEntryType = "onResize"; 6 | 7 | export type PanelCollapseLogEntry = { 8 | panelId: string; 9 | type: PanelCollapseLogEntryType; 10 | }; 11 | export type PanelExpandLogEntry = { 12 | panelId: string; 13 | type: PanelExpandLogEntryType; 14 | }; 15 | export type PanelResizeHandleDraggingLogEntry = { 16 | isDragging: boolean; 17 | resizeHandleId: string; 18 | type: PanelResizeHandleDraggingLogEntryType; 19 | }; 20 | export type PanelGroupLayoutLogEntry = { 21 | groupId: string; 22 | layout: number[]; 23 | type: PanelGroupLayoutLogEntryType; 24 | }; 25 | export type PanelResizeLogEntry = { 26 | panelId: string; 27 | size: number; 28 | type: PanelResizeLogEntryType; 29 | }; 30 | 31 | export type LogEntryType = 32 | | PanelCollapseLogEntryType 33 | | PanelExpandLogEntryType 34 | | PanelResizeHandleDraggingLogEntryType 35 | | PanelGroupLayoutLogEntryType 36 | | PanelResizeLogEntryType; 37 | 38 | export type LogEntry = 39 | | PanelCollapseLogEntry 40 | | PanelExpandLogEntry 41 | | PanelResizeHandleDraggingLogEntry 42 | | PanelGroupLayoutLogEntry 43 | | PanelResizeLogEntry; 44 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/iframe/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useSyncExternalStore } from "react"; 2 | import styles from "./styles.module.css"; 3 | 4 | export default function Page() { 5 | const urlString = useSyncExternalStore( 6 | function subscribe(onChange) { 7 | window.addEventListener("navigate", onChange); 8 | return function unsubscribe() { 9 | window.removeEventListener("navigate", onChange); 10 | }; 11 | }, 12 | function read() { 13 | return window.location.href; 14 | } 15 | ); 16 | 17 | const url = useMemo(() => new URL(urlString), [urlString]); 18 | 19 | return ( 20 |
    21 | 31 |
    32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/routes/iframe/styles.module.css: -------------------------------------------------------------------------------- 1 | .Root { 2 | width: 100vw; 3 | height: 100vh; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | background-color: var(--color-logo-background); 8 | } 9 | 10 | .IFrame { 11 | width: 300px; 12 | height: 200px; 13 | border: none; 14 | } 15 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/suspense/ImportCache.ts: -------------------------------------------------------------------------------- 1 | import { createCache } from "suspense"; 2 | 3 | type Module = any; 4 | 5 | export const importCache = createCache<[string], Module>({ 6 | config: { immutable: true }, 7 | debugLabel: "importCache", 8 | getKey: ([path]) => path, 9 | load: async ([path]) => { 10 | switch (path) { 11 | case "@codemirror/lang-css": 12 | return await import("@codemirror/lang-css"); 13 | case "@codemirror/lang-html": 14 | return await import("@codemirror/lang-html"); 15 | case "@codemirror/lang-javascript": 16 | return await import("@codemirror/lang-javascript"); 17 | case "@codemirror/lang-markdown": 18 | return await import("@codemirror/lang-markdown"); 19 | default: 20 | throw Error(`Unknown path: ${path}`); 21 | } 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/src/utils/withAutoSizer.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, createElement } from "react"; 2 | import AutoSizer from "react-virtualized-auto-sizer"; 3 | import type { 4 | Props as AutoSizerProps, 5 | Size, 6 | } from "react-virtualized-auto-sizer"; 7 | 8 | export default function withAutoSizer( 9 | Component: FunctionComponent< 10 | ComponentProps & { 11 | height: number; 12 | width: number; 13 | } 14 | >, 15 | autoSizerProps?: Partial 16 | ): FunctionComponent> { 17 | const AutoSizerWrapper = ( 18 | props: Omit 19 | ) => { 20 | return createElement(AutoSizer, { 21 | ...autoSizerProps, 22 | children: ({ height, width }: Size) => 23 | createElement(Component as any, { ...props, height, width }), 24 | }); 25 | }; 26 | 27 | return AutoSizerWrapper; 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/Collapsing.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | import { createElement } from "react"; 3 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 4 | 5 | import { verifyPanelSizePercentage } from "./utils/panels"; 6 | import { goToUrl } from "./utils/url"; 7 | 8 | test.describe("collapsible prop", () => { 9 | test("should call onResize when panels are resized", async ({ page }) => { 10 | await goToUrl( 11 | page, 12 | createElement( 13 | PanelGroup, 14 | { direction: "horizontal" }, 15 | createElement(Panel, { 16 | collapsible: true, 17 | defaultSize: 35, 18 | minSize: 10, 19 | }), 20 | createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }), 21 | createElement(Panel, { 22 | minSize: 10, 23 | }), 24 | createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }), 25 | createElement(Panel, { 26 | collapsible: true, 27 | defaultSize: 35, 28 | minSize: 20, 29 | }) 30 | ) 31 | ); 32 | 33 | const resizeHandles = page.locator("[data-panel-resize-handle-id]"); 34 | const firstHandle = resizeHandles.first(); 35 | const lastHandle = resizeHandles.last(); 36 | 37 | const panels = page.locator("[data-panel]"); 38 | const firstPanel = panels.first(); 39 | const lastPanel = panels.last(); 40 | 41 | await verifyPanelSizePercentage(firstPanel, 35); 42 | await verifyPanelSizePercentage(lastPanel, 35); 43 | 44 | await firstHandle.focus(); 45 | await page.keyboard.press("ArrowLeft"); 46 | await verifyPanelSizePercentage(firstPanel, 25); 47 | await page.keyboard.press("ArrowLeft"); 48 | await verifyPanelSizePercentage(firstPanel, 15); 49 | await page.keyboard.press("ArrowLeft"); 50 | await verifyPanelSizePercentage(firstPanel, 10); 51 | // Once it drops below the min size, it will collapse 52 | await page.keyboard.press("ArrowLeft"); 53 | await verifyPanelSizePercentage(firstPanel, 0); 54 | await page.keyboard.press("ArrowRight"); 55 | await verifyPanelSizePercentage(firstPanel, 10); 56 | await page.keyboard.press("ArrowLeft"); 57 | await verifyPanelSizePercentage(firstPanel, 0); 58 | await page.keyboard.press("ArrowRight"); 59 | await verifyPanelSizePercentage(firstPanel, 10); 60 | 61 | await lastHandle.focus(); 62 | await page.keyboard.press("ArrowRight"); 63 | await verifyPanelSizePercentage(lastPanel, 25); 64 | await page.keyboard.press("ArrowRight"); 65 | await verifyPanelSizePercentage(lastPanel, 20); 66 | // Once it drops below the min size, it will collapse 67 | await page.keyboard.press("ArrowRight"); 68 | await verifyPanelSizePercentage(lastPanel, 0); 69 | await page.keyboard.press("ArrowLeft"); 70 | await verifyPanelSizePercentage(lastPanel, 20); 71 | await page.keyboard.press("ArrowRight"); 72 | await verifyPanelSizePercentage(lastPanel, 0); 73 | await page.keyboard.press("ArrowLeft"); 74 | await verifyPanelSizePercentage(lastPanel, 20); 75 | }); 76 | 77 | test("should support custom collapsedSize values", async ({ page }) => { 78 | await goToUrl( 79 | page, 80 | createElement( 81 | PanelGroup, 82 | { direction: "horizontal" }, 83 | createElement(Panel, { 84 | collapsedSize: 2, 85 | collapsible: true, 86 | defaultSize: 35, 87 | minSize: 10, 88 | }), 89 | createElement(PanelResizeHandle, { style: { height: 10, width: 10 } }), 90 | createElement(Panel, { minSize: 10 }) 91 | ) 92 | ); 93 | 94 | const resizeHandle = page.locator("[data-panel-resize-handle-id]"); 95 | 96 | const panels = page.locator("[data-panel]"); 97 | const firstPanel = panels.first(); 98 | const lastPanel = panels.last(); 99 | 100 | await verifyPanelSizePercentage(firstPanel, 35); 101 | await verifyPanelSizePercentage(lastPanel, 65); 102 | 103 | await resizeHandle.focus(); 104 | await page.keyboard.press("ArrowLeft"); 105 | await verifyPanelSizePercentage(firstPanel, 25); 106 | await page.keyboard.press("ArrowLeft"); 107 | await verifyPanelSizePercentage(firstPanel, 15); 108 | // Once it drops below min size, it will collapse 109 | await page.keyboard.press("ArrowLeft"); 110 | await verifyPanelSizePercentage(firstPanel, 2); 111 | await page.keyboard.press("ArrowRight"); 112 | await verifyPanelSizePercentage(firstPanel, 12); 113 | await page.keyboard.press("ArrowLeft"); 114 | await verifyPanelSizePercentage(firstPanel, 2); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/NestedGroups.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@playwright/test"; 2 | import { createElement } from "react"; 3 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 4 | 5 | import { verifyAriaValues } from "./utils/aria"; 6 | import { goToUrl } from "./utils/url"; 7 | 8 | test.describe("Nested groups", () => { 9 | test("should resize and maintain layouts independently", async ({ page }) => { 10 | await goToUrl( 11 | page, 12 | createElement( 13 | PanelGroup, 14 | { direction: "horizontal" }, 15 | createElement(Panel, { minSize: 10 }), 16 | createElement(PanelResizeHandle), 17 | createElement( 18 | Panel, 19 | { minSize: 10 }, 20 | createElement( 21 | PanelGroup, 22 | { direction: "vertical" }, 23 | createElement(Panel, { minSize: 10 }), 24 | createElement(PanelResizeHandle), 25 | createElement( 26 | Panel, 27 | { minSize: 10 }, 28 | createElement( 29 | PanelGroup, 30 | { direction: "horizontal" }, 31 | createElement(Panel, { minSize: 10 }), 32 | createElement(PanelResizeHandle), 33 | createElement(Panel, { minSize: 10 }) 34 | ) 35 | ) 36 | ) 37 | ), 38 | createElement(PanelResizeHandle), 39 | createElement(Panel, { minSize: 10 }) 40 | ) 41 | ); 42 | 43 | const resizeHandles = page.locator("[data-panel-resize-handle-id]"); 44 | const outerHorizontalFirstHandle = resizeHandles.nth(0); 45 | const verticalHandle = resizeHandles.nth(1); 46 | const innerHorizontalHandle = resizeHandles.nth(2); 47 | const outerHorizontalLastHandle = resizeHandles.nth(3); 48 | 49 | // Verify initial values 50 | await verifyAriaValues(outerHorizontalFirstHandle, { 51 | min: 10, 52 | max: 80, 53 | now: 33, 54 | }); 55 | await verifyAriaValues(outerHorizontalLastHandle, { 56 | min: 10, 57 | max: 80, 58 | now: 33, 59 | }); 60 | await verifyAriaValues(verticalHandle, { min: 10, max: 90, now: 50 }); 61 | await verifyAriaValues(innerHorizontalHandle, { 62 | min: 10, 63 | max: 90, 64 | now: 50, 65 | }); 66 | 67 | // Resize the inner panels 68 | await verticalHandle.focus(); 69 | await page.keyboard.press("Home"); 70 | await innerHorizontalHandle.focus(); 71 | await page.keyboard.press("End"); 72 | await verifyAriaValues(verticalHandle, { now: 10 }); 73 | await verifyAriaValues(innerHorizontalHandle, { now: 90 }); 74 | 75 | // Verify the outer panels still have the same relative sizes 76 | await verifyAriaValues(outerHorizontalFirstHandle, { 77 | now: 33, 78 | }); 79 | await verifyAriaValues(outerHorizontalLastHandle, { 80 | now: 33, 81 | }); 82 | 83 | // Resize the outer panel 84 | await outerHorizontalFirstHandle.focus(); 85 | await page.keyboard.press("ArrowLeft"); 86 | await verifyAriaValues(outerHorizontalFirstHandle, { now: 23 }); 87 | await outerHorizontalLastHandle.focus(); 88 | await page.keyboard.press("ArrowRight"); 89 | await verifyAriaValues(outerHorizontalLastHandle, { now: 53 }); 90 | 91 | // Verify the inner panels still have the same relative sizes 92 | await verifyAriaValues(verticalHandle, { now: 10 }); 93 | await verifyAriaValues(innerHorizontalHandle, { now: 90 }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/ResizeHandle.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { createElement } from "react"; 3 | import { 4 | DATA_ATTRIBUTES, 5 | Panel, 6 | PanelGroup, 7 | PanelResizeHandle, 8 | } from "react-resizable-panels"; 9 | 10 | import { goToUrl, goToUrlWithIframe } from "./utils/url"; 11 | import assert from "assert"; 12 | 13 | test.describe("Resize handle", () => { 14 | test("should set 'data-resize-handle-active' attribute when active", async ({ 15 | page, 16 | }) => { 17 | await goToUrl( 18 | page, 19 | createElement( 20 | PanelGroup, 21 | { direction: "horizontal" }, 22 | createElement(Panel, { minSize: 10 }), 23 | createElement(PanelResizeHandle), 24 | createElement(Panel, { minSize: 10 }), 25 | createElement(PanelResizeHandle), 26 | createElement(Panel, { minSize: 10 }) 27 | ) 28 | ); 29 | 30 | const resizeHandles = page.locator("[data-panel-resize-handle-id]"); 31 | const first = resizeHandles.first(); 32 | const last = resizeHandles.last(); 33 | 34 | await expect( 35 | await first.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 36 | ).toBeNull(); 37 | await expect( 38 | await last.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 39 | ).toBeNull(); 40 | 41 | await first.focus(); 42 | 43 | await expect( 44 | await first.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 45 | ).toBe("keyboard"); 46 | await expect( 47 | await last.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 48 | ).toBeNull(); 49 | 50 | await first.blur(); 51 | 52 | await expect( 53 | await first.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 54 | ).toBeNull(); 55 | await expect( 56 | await last.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 57 | ).toBeNull(); 58 | 59 | const bounds = (await last.boundingBox())!; 60 | await page.mouse.move(bounds.x, bounds.y); 61 | await page.mouse.down(); 62 | 63 | await expect( 64 | await first.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 65 | ).toBeNull(); 66 | await expect( 67 | await last.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 68 | ).toBe("pointer"); 69 | 70 | await page.mouse.up(); 71 | 72 | await expect( 73 | await first.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 74 | ).toBeNull(); 75 | await expect( 76 | await last.getAttribute(DATA_ATTRIBUTES.resizeHandleActive) 77 | ).toBeNull(); 78 | }); 79 | 80 | test("should stop dragging if the mouse is released outside of the document/owner", async ({ 81 | page, 82 | }) => { 83 | for (let sameOrigin of [true, false]) { 84 | await goToUrlWithIframe( 85 | page, 86 | createElement( 87 | PanelGroup, 88 | { direction: "horizontal" }, 89 | createElement(Panel, { minSize: 10 }), 90 | createElement(PanelResizeHandle), 91 | createElement(Panel, { minSize: 10 }) 92 | ), 93 | sameOrigin 94 | ); 95 | 96 | const iframe = page.locator("iframe").first(); 97 | const iframeBounds = await iframe.boundingBox(); 98 | assert(iframeBounds); 99 | 100 | const panel = page.frameLocator("#frame").locator("[data-panel]").first(); 101 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 102 | "50.0" 103 | ); 104 | 105 | const handle = page 106 | .frameLocator("#frame") 107 | .locator("[data-panel-resize-handle-id]") 108 | .first(); 109 | const handleBounds = await handle.boundingBox(); 110 | assert(handleBounds); 111 | 112 | // Mouse down 113 | await page.mouse.move(handleBounds.x, handleBounds.y); 114 | await page.mouse.down(); 115 | 116 | // Mouse move to iframe edge (and verify resize) 117 | await page.mouse.move(iframeBounds.x, iframeBounds.y); 118 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 119 | "10.0" 120 | ); 121 | 122 | // Mouse move outside of iframe (and verify no resize) 123 | await page.mouse.move(iframeBounds.x - 10, iframeBounds.y - 10); 124 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 125 | "10.0" 126 | ); 127 | 128 | // Mouse move within frame (and verify resize) 129 | await page.mouse.move(iframeBounds.x, iframeBounds.y); 130 | await page.mouse.move(handleBounds.x, handleBounds.y); 131 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 132 | "50.0" 133 | ); 134 | 135 | // Mouse move to iframe edge 136 | await page.mouse.move( 137 | iframeBounds.x + iframeBounds.width, 138 | iframeBounds.y + iframeBounds.height 139 | ); 140 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 141 | "90.0" 142 | ); 143 | 144 | // Mouse move outside of iframe and release 145 | await page.mouse.move( 146 | iframeBounds.x + iframeBounds.width + 10, 147 | iframeBounds.y + iframeBounds.height + 10 148 | ); 149 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 150 | "90.0" 151 | ); 152 | await page.mouse.up(); 153 | 154 | // Mouse move within frame (and verify no resize) 155 | await page.mouse.move(handleBounds.x, handleBounds.y); 156 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 157 | "90.0" 158 | ); 159 | await page.mouse.move(iframeBounds.x, iframeBounds.y); 160 | await expect(await panel.getAttribute(DATA_ATTRIBUTES.panelSize)).toBe( 161 | "90.0" 162 | ); 163 | } 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/Springy.spec.ts: -------------------------------------------------------------------------------- 1 | import { Page, test } from "@playwright/test"; 2 | import { createElement } from "react"; 3 | import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; 4 | 5 | import { dragResizeTo } from "./utils/panels"; 6 | import { goToUrl } from "./utils/url"; 7 | import { verifySizes } from "./utils/verify"; 8 | 9 | async function openPage(page: Page) { 10 | const panelGroup = createElement( 11 | PanelGroup, 12 | { direction: "horizontal", id: "group" }, 13 | createElement(Panel, { 14 | defaultSize: 25, 15 | id: "left-panel", 16 | minSize: 10, 17 | order: 1, 18 | }), 19 | createElement(PanelResizeHandle, { id: "left-handle" }), 20 | createElement(Panel, { 21 | id: "middle-panel", 22 | minSize: 10, 23 | order: 2, 24 | }), 25 | createElement(PanelResizeHandle, { id: "right-handle" }), 26 | createElement(Panel, { 27 | collapsible: true, 28 | defaultSize: 25, 29 | id: "right-panel", 30 | minSize: 10, 31 | order: 4, 32 | }) 33 | ); 34 | 35 | await goToUrl(page, panelGroup); 36 | } 37 | 38 | test.describe("springy panels", () => { 39 | test.beforeEach(async ({ page }) => { 40 | await openPage(page); 41 | }); 42 | 43 | test("later panels should be springy when expanding then collapsing the first panel", async ({ 44 | page, 45 | }) => { 46 | await verifySizes(page, 25, 50, 25); 47 | 48 | // Test expanding the first panel 49 | await dragResizeTo( 50 | page, 51 | "left-panel", 52 | { size: 80, expectedSizes: [80, 10, 10] }, 53 | // Items should re-expand to their initial sizes 54 | { size: 25, expectedSizes: [25, 50, 25] }, 55 | // But they should not expand past those sizes 56 | { size: 10, expectedSizes: [10, 65, 25] } 57 | ); 58 | }); 59 | 60 | test("earlier panels should be springy when expanding then collapsing the last panel", async ({ 61 | page, 62 | }) => { 63 | await verifySizes(page, 25, 50, 25); 64 | 65 | // Test expanding the last panel 66 | await dragResizeTo( 67 | page, 68 | "right-panel", 69 | { size: 80, expectedSizes: [10, 10, 80] }, 70 | // Items should re-expand to their initial sizes 71 | { size: 25, expectedSizes: [25, 50, 25] }, 72 | // But they should not expand past those sizes 73 | { size: 10, expectedSizes: [25, 65, 10] } 74 | ); 75 | }); 76 | 77 | test("panels should remember a max spring point per drag", async ({ 78 | page, 79 | }) => { 80 | await verifySizes(page, 25, 50, 25); 81 | 82 | await dragResizeTo(page, "left-panel", { 83 | size: 70, 84 | expectedSizes: [70, 10, 20], 85 | }); 86 | 87 | await dragResizeTo( 88 | page, 89 | "left-panel", 90 | { size: 80, expectedSizes: [80, 10, 10] }, 91 | { size: 70, expectedSizes: [70, 10, 20] }, 92 | { size: 10, expectedSizes: [10, 70, 20] } 93 | ); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/StackingOrder.spec.ts: -------------------------------------------------------------------------------- 1 | import { Page, expect, test } from "@playwright/test"; 2 | import { createElement } from "react"; 3 | import { 4 | Panel, 5 | PanelGroup, 6 | PanelResizeHandle, 7 | assert, 8 | } from "react-resizable-panels"; 9 | 10 | import { goToUrl } from "./utils/url"; 11 | import { getBodyCursorStyle } from "./utils/cursor"; 12 | 13 | test.describe("stacking order", () => { 14 | async function openPage(page: Page) { 15 | await goToUrl( 16 | page, 17 | createElement( 18 | PanelGroup, 19 | { direction: "horizontal" }, 20 | createElement(Panel, { 21 | defaultSize: 50, 22 | id: "left-panel", 23 | minSize: 10, 24 | }), 25 | createElement(PanelResizeHandle), 26 | createElement(Panel, { 27 | defaultSize: 50, 28 | id: "right-panel", 29 | minSize: 10, 30 | }) 31 | ) 32 | ); 33 | } 34 | 35 | test("should not update cursor or start dragging if a resize handle is underneath another element", async ({ 36 | page, 37 | }) => { 38 | await openPage(page); 39 | 40 | const toggleButton = page.locator("#toggleModalButton"); 41 | const modal = page.locator('[data-test-id="ModalBox"]'); 42 | 43 | // Show modal overlay 44 | await toggleButton.click(); 45 | await expect(await modal.isHidden()).toBe(false); 46 | 47 | const dragHandleRect = await modal.boundingBox(); 48 | assert(dragHandleRect, "No bounding box found for modal"); 49 | 50 | const pageX = dragHandleRect.x + dragHandleRect.width / 2; 51 | const pageY = dragHandleRect.y + dragHandleRect.height / 2; 52 | 53 | page.mouse.down(); 54 | 55 | { 56 | page.mouse.move(pageX, pageY); 57 | 58 | const actualCursor = await getBodyCursorStyle(page); 59 | await expect(actualCursor).toBe("auto"); 60 | } 61 | 62 | // Hide modal overlay 63 | await toggleButton.click(); 64 | await expect(await modal.isHidden()).toBe(true); 65 | 66 | page.mouse.move(0, 0); 67 | 68 | { 69 | page.mouse.move(pageX, pageY); 70 | 71 | const actualCursor = await getBodyCursorStyle(page); 72 | await expect(actualCursor).toBe("ew-resize"); 73 | } 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/utils/aria.ts: -------------------------------------------------------------------------------- 1 | import { Locator, expect } from "@playwright/test"; 2 | 3 | export async function verifyAriaValues( 4 | locator: Locator, 5 | expectedValues: { max?: number; min?: number; now?: number } 6 | ) { 7 | const { max, min, now } = expectedValues; 8 | 9 | if (max != null) { 10 | await expect(await locator.getAttribute("aria-valuemax")).toBe("" + max); 11 | } 12 | if (min != null) { 13 | await expect(await locator.getAttribute("aria-valuemin")).toBe("" + min); 14 | } 15 | if (now != null) { 16 | await expect(await locator.getAttribute("aria-valuenow")).toBe("" + now); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/utils/assert.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "react-resizable-panels"; 2 | import { 3 | ImperativePanelGroupHandle, 4 | ImperativePanelHandle, 5 | } from "react-resizable-panels"; 6 | 7 | export function assertImperativePanelHandle( 8 | value: any 9 | ): value is ImperativePanelHandle { 10 | assert( 11 | isImperativePanelHandle(value), 12 | "Value is not an ImperativePanelHandle" 13 | ); 14 | return true; 15 | } 16 | 17 | export function assertImperativePanelGroupHandle( 18 | value: any 19 | ): value is ImperativePanelGroupHandle { 20 | assert( 21 | isImperativePanelGroupHandle(value), 22 | "Value is not an ImperativePanelGroupHandle" 23 | ); 24 | return true; 25 | } 26 | 27 | export function isImperativePanelHandle( 28 | value: any 29 | ): value is ImperativePanelHandle { 30 | return ( 31 | value != null && 32 | typeof value === "object" && 33 | typeof value.collapse === "function" && 34 | typeof value.expand === "function" && 35 | typeof value.isCollapsed === "function" && 36 | typeof value.isExpanded === "function" && 37 | typeof value.getSize === "function" && 38 | typeof value.resize === "function" 39 | ); 40 | } 41 | 42 | export function isImperativePanelGroupHandle( 43 | value: any 44 | ): value is ImperativePanelGroupHandle { 45 | return ( 46 | value != null && 47 | typeof value === "object" && 48 | typeof value.getLayout === "function" && 49 | typeof value.setLayout === "function" 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/utils/cursor.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | export async function getBodyCursorStyle(page: Page): Promise { 4 | return page.evaluate(() => { 5 | return getComputedStyle(document.body).getPropertyValue("cursor"); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/utils/debug.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | import { LogEntry, LogEntryType } from "../../src/routes/examples/types"; 4 | 5 | type LogEntryOrEntriesType = LogEntryType | LogEntryType[]; 6 | 7 | export async function clearLogEntries( 8 | page: Page, 9 | logEntryType: LogEntryOrEntriesType | null = null 10 | ) { 11 | await page.evaluate((logEntryType: LogEntryOrEntriesType | null) => { 12 | const div = document.getElementById("debug"); 13 | if (div == null) { 14 | throw Error("Could not find debug div"); 15 | } 16 | 17 | if (logEntryType !== null) { 18 | const textContent = div.textContent!; 19 | const logEntries = JSON.parse(textContent) as LogEntry[]; 20 | const filteredEntries = logEntries.filter(({ type }) => { 21 | if (Array.isArray(logEntryType)) { 22 | return !logEntryType.includes(type); 23 | } else { 24 | return logEntryType !== type; 25 | } 26 | }); 27 | div.textContent = JSON.stringify(filteredEntries); 28 | } else { 29 | div.textContent = "[]"; 30 | } 31 | }, logEntryType); 32 | } 33 | 34 | export async function getLogEntries( 35 | page: Page, 36 | logEntryType: LogEntryOrEntriesType | null = null 37 | ): Promise { 38 | const logEntries: Type[] = await page.evaluate( 39 | (logEntryType: LogEntryOrEntriesType | null) => { 40 | const div = document.getElementById("debug"); 41 | if (div == null) { 42 | throw Error("Could not find debug div"); 43 | } 44 | 45 | const textContent = div.textContent!; 46 | const logEntries = JSON.parse(textContent) as LogEntry[]; 47 | 48 | return logEntries.filter(({ type }) => { 49 | if (logEntryType == null) { 50 | return true; 51 | } else if (Array.isArray(logEntryType)) { 52 | return logEntryType.includes(type); 53 | } else { 54 | return logEntryType === type; 55 | } 56 | }) as Type[]; 57 | }, 58 | logEntryType 59 | ); 60 | return logEntries; 61 | } 62 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | import { ReactElement } from "react"; 3 | import { PanelGroupProps } from "react-resizable-panels"; 4 | import { UrlPanelGroupToEncodedString } from "../../src/utils/UrlData"; 5 | 6 | export async function goToUrl( 7 | page: Page, 8 | element: ReactElement | null 9 | ) { 10 | const encodedString = element ? UrlPanelGroupToEncodedString(element) : ""; 11 | 12 | const url = new URL("http://localhost:1234/__e2e"); 13 | url.searchParams.set("urlPanelGroup", encodedString); 14 | 15 | // Uncomment when testing for easier repros 16 | // console.log(url.toString()); 17 | 18 | await page.goto(url.toString()); 19 | } 20 | 21 | export async function goToUrlWithIframe( 22 | page: Page, 23 | element: ReactElement, 24 | sameOrigin: boolean 25 | ) { 26 | const encodedString = UrlPanelGroupToEncodedString(element); 27 | 28 | const url = new URL("http://localhost:1234/__e2e/iframe"); 29 | url.searchParams.set("urlPanelGroup", encodedString); 30 | if (sameOrigin) { 31 | url.searchParams.set("sameOrigin", ""); 32 | } 33 | 34 | // Uncomment when testing for easier repros 35 | // console.log(url.toString()); 36 | 37 | await page.goto(url.toString()); 38 | } 39 | 40 | export async function updateUrl( 41 | page: Page, 42 | element: ReactElement | null 43 | ) { 44 | const encodedString = element ? UrlPanelGroupToEncodedString(element) : ""; 45 | 46 | await page.evaluate( 47 | ([encodedString]) => { 48 | const url = new URL(window.location.href); 49 | url.searchParams.set("urlPanelGroup", encodedString ?? ""); 50 | 51 | window.history.pushState( 52 | { urlPanelGroup: encodedString }, 53 | "", 54 | url.toString() 55 | ); 56 | 57 | window.dispatchEvent(new Event("popstate")); 58 | }, 59 | [encodedString] 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /packages/react-resizable-panels-website/tests/utils/verify.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from "@playwright/test"; 2 | 3 | import { PanelGroupLayoutLogEntry } from "../../src/routes/examples/types"; 4 | 5 | import { assert } from "react-resizable-panels"; 6 | import { getLogEntries } from "./debug"; 7 | 8 | export async function verifySizes(page: Page, ...expectedSizes: number[]) { 9 | const panels = page.locator("[data-panel-id]"); 10 | 11 | const count = await panels.count(); 12 | expect(count).toBe(expectedSizes.length); 13 | 14 | for (let index = 0; index < count; index++) { 15 | const panel = await panels.nth(index); 16 | const textContent = (await panel.textContent()) || ""; 17 | 18 | const expectedSize = expectedSizes[index]; 19 | const actualSize = parseFloat(textContent.replace("%", "")); 20 | 21 | expect(actualSize).toBe(expectedSize); 22 | } 23 | } 24 | 25 | export async function verifyFuzzySizes( 26 | page: Page, 27 | precision: number, 28 | ...expectedSizes: number[] 29 | ) { 30 | const logEntries = await getLogEntries( 31 | page, 32 | "onLayout" 33 | ); 34 | const logEntry = logEntries[logEntries.length - 1]; 35 | assert(logEntry, `No log entry found for index ${logEntries.length - 1}`); 36 | 37 | const actualSizes = logEntry.layout; 38 | 39 | expect(actualSizes).toHaveLength(expectedSizes.length); 40 | 41 | for (let index = 0; index < actualSizes.length; index++) { 42 | const actualSize = actualSizes[index]; 43 | assert(actualSize, `No actual size found for index ${index}`); 44 | 45 | const expectedSize = expectedSizes[index]; 46 | assert(expectedSize, `No expected size found for index ${index}`); 47 | 48 | expect(actualSize).toBeGreaterThanOrEqual(expectedSize - precision); 49 | expect(actualSize).toBeLessThanOrEqual(expectedSize + precision); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | ignorePatterns: [".parcel-cache", "dist", "node_modules"], 4 | parser: "@typescript-eslint/parser", 5 | parserOptions: { 6 | project: "../../tsconfig.json", 7 | tsconfigRootDir: __dirname, 8 | }, 9 | plugins: ["@typescript-eslint", "react-hooks"], 10 | root: true, 11 | rules: { 12 | "@typescript-eslint/no-non-null-assertion": "error", 13 | "react-hooks/rules-of-hooks": "error", 14 | "react-hooks/exhaustive-deps": [ 15 | "warn", 16 | { 17 | additionalHooks: "(useIsomorphicLayoutEffect)", 18 | }, 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-resizable-panels", 3 | "version": "3.0.2", 4 | "type": "module", 5 | "description": "React components for resizable panel groups/layouts", 6 | "author": "Brian Vaughn ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/bvaughn/react-resizable-panels.git" 11 | }, 12 | "source": "src/index.ts", 13 | "files": [ 14 | "dist", 15 | "package.json", 16 | "README.md", 17 | "LICENSE" 18 | ], 19 | "exports": { 20 | ".": { 21 | "types": "./dist/react-resizable-panels.js", 22 | "development": { 23 | "edge-light": "./dist/react-resizable-panels.development.edge-light.js", 24 | "worker": "./dist/react-resizable-panels.development.edge-light.js", 25 | "workerd": "./dist/react-resizable-panels.development.edge-light.js", 26 | "browser": "./dist/react-resizable-panels.browser.development.js", 27 | "node": "./dist/react-resizable-panels.development.edge-light.js", 28 | "default": "./dist/react-resizable-panels.development.js" 29 | }, 30 | "edge-light": "./dist/react-resizable-panels.edge-light.js", 31 | "worker": "./dist/react-resizable-panels.edge-light.js", 32 | "workerd": "./dist/react-resizable-panels.edge-light.js", 33 | "browser": "./dist/react-resizable-panels.browser.js", 34 | "node": "./dist/react-resizable-panels.edge-light.js", 35 | "default": "./dist/react-resizable-panels.js" 36 | }, 37 | "./package.json": "./package.json" 38 | }, 39 | "imports": { 40 | "#is-development": { 41 | "development": "./src/env-conditions/development.ts", 42 | "default": "./src/env-conditions/production.ts" 43 | }, 44 | "#is-browser": { 45 | "edge-light": "./src/env-conditions/server.ts", 46 | "workerd": "./src/env-conditions/server.ts", 47 | "worker": "./src/env-conditions/server.ts", 48 | "browser": "./src/env-conditions/browser.ts", 49 | "node": "./src/env-conditions/server.ts", 50 | "default": "./src/env-conditions/check-is-browser.ts" 51 | } 52 | }, 53 | "types": "dist/react-resizable-panels.d.ts", 54 | "scripts": { 55 | "clear": "pnpm run clear:builds & pnpm run clear:node_modules", 56 | "clear:builds": "rm -rf ./packages/*/dist", 57 | "clear:node_modules": "rm -rf ./node_modules", 58 | "lint": "eslint \"src/**/*.{ts,tsx}\"", 59 | "test:browser": "vitest", 60 | "test:node": "vitest -c vitest.node.config.ts", 61 | "watch": "parcel watch --port=2345" 62 | }, 63 | "devDependencies": { 64 | "@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6", 65 | "@babel/plugin-proposal-optional-chaining": "7.21.0", 66 | "@vitest/ui": "^3.1.2", 67 | "eslint": "^8.37.0", 68 | "eslint-plugin-react-hooks": "^4.6.0", 69 | "jsdom": "^26.1.0", 70 | "react": "experimental", 71 | "react-dom": "experimental", 72 | "vitest": "^3.1.2" 73 | }, 74 | "peerDependencies": { 75 | "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", 76 | "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 77 | }, 78 | "browserslist": [ 79 | "Chrome 79" 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/Panel.node.test.tsx: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; 2 | import { renderToStaticMarkup } from "react-dom/server"; 3 | import { act } from "react-dom/test-utils"; 4 | import { Panel } from "./Panel"; 5 | import { PanelGroup } from "./PanelGroup"; 6 | import { PanelResizeHandle } from "./PanelResizeHandle"; 7 | 8 | describe("PanelGroup", () => { 9 | let expectedWarnings: string[] = []; 10 | 11 | function expectWarning(expectedMessage: string) { 12 | expectedWarnings.push(expectedMessage); 13 | } 14 | 15 | beforeEach(() => { 16 | // @ts-expect-error 17 | global.IS_REACT_ACT_ENVIRONMENT = true; 18 | 19 | expectedWarnings = []; 20 | 21 | vi.spyOn(console, "warn").mockImplementation((actualMessage: string) => { 22 | const match = expectedWarnings.findIndex((expectedMessage) => { 23 | return actualMessage.includes(expectedMessage); 24 | }); 25 | 26 | if (match >= 0) { 27 | expectedWarnings.splice(match, 1); 28 | return; 29 | } 30 | 31 | throw Error(`Unexpected warning: ${actualMessage}`); 32 | }); 33 | }); 34 | 35 | afterEach(() => { 36 | vi.clearAllMocks(); 37 | expect(expectedWarnings).toHaveLength(0); 38 | }); 39 | 40 | describe("DEV warnings", () => { 41 | test("should warn about server rendered panels with no default size", () => { 42 | act(() => { 43 | // No warning expected if default sizes provided 44 | renderToStaticMarkup( 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }); 52 | 53 | expectWarning( 54 | "Panel defaultSize prop recommended to avoid layout shift after server rendering" 55 | ); 56 | 57 | act(() => { 58 | renderToStaticMarkup( 59 | 60 | 61 | 62 | ); 63 | }); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/PanelGroupContext.ts: -------------------------------------------------------------------------------- 1 | import { PanelConstraints, PanelData } from "./Panel"; 2 | import { CSSProperties, createContext } from "react"; 3 | 4 | // The "contextmenu" event is not supported as a PointerEvent in all browsers yet, so MouseEvent still need to be handled 5 | export type ResizeEvent = KeyboardEvent | PointerEvent | MouseEvent; 6 | export type ResizeHandler = (event: ResizeEvent) => void; 7 | 8 | export type DragState = { 9 | dragHandleId: string; 10 | dragHandleRect: DOMRect; 11 | initialCursorPosition: number; 12 | initialLayout: number[]; 13 | }; 14 | 15 | export type TPanelGroupContext = { 16 | collapsePanel: (panelData: PanelData) => void; 17 | direction: "horizontal" | "vertical"; 18 | dragState: DragState | null; 19 | expandPanel: (panelData: PanelData, minSizeOverride?: number) => void; 20 | getPanelSize: (panelData: PanelData) => number; 21 | getPanelStyle: ( 22 | panelData: PanelData, 23 | defaultSize: number | undefined 24 | ) => CSSProperties; 25 | groupId: string; 26 | isPanelCollapsed: (panelData: PanelData) => boolean; 27 | isPanelExpanded: (panelData: PanelData) => boolean; 28 | reevaluatePanelConstraints: ( 29 | panelData: PanelData, 30 | prevConstraints: PanelConstraints 31 | ) => void; 32 | registerPanel: (panelData: PanelData) => void; 33 | registerResizeHandle: (dragHandleId: string) => ResizeHandler; 34 | resizePanel: (panelData: PanelData, size: number) => void; 35 | startDragging: (dragHandleId: string, event: ResizeEvent) => void; 36 | stopDragging: () => void; 37 | unregisterPanel: (panelData: PanelData) => void; 38 | panelGroupElement: ParentNode | null; 39 | }; 40 | export const PanelGroupContext = createContext(null); 41 | 42 | PanelGroupContext.displayName = "PanelGroupContext"; 43 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DATA_ATTRIBUTES = { 2 | group: "data-panel-group", 3 | groupDirection: "data-panel-group-direction", 4 | groupId: "data-panel-group-id", 5 | panel: "data-panel", 6 | panelCollapsible: "data-panel-collapsible", 7 | panelId: "data-panel-id", 8 | panelSize: "data-panel-size", 9 | resizeHandle: "data-resize-handle", 10 | resizeHandleActive: "data-resize-handle-active", 11 | resizeHandleEnabled: "data-panel-resize-handle-enabled", 12 | resizeHandleId: "data-panel-resize-handle-id", 13 | resizeHandleState: "data-resize-handle-state", 14 | } as const; 15 | 16 | export const PRECISION = 10; 17 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/env-conditions/browser.ts: -------------------------------------------------------------------------------- 1 | export const isBrowser = true; 2 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/env-conditions/check-is-browser.ts: -------------------------------------------------------------------------------- 1 | export const isBrowser = typeof window !== "undefined"; 2 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/env-conditions/development.ts: -------------------------------------------------------------------------------- 1 | export const isDevelopment = true; 2 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/env-conditions/production.ts: -------------------------------------------------------------------------------- 1 | export const isDevelopment = false; 2 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/env-conditions/server.ts: -------------------------------------------------------------------------------- 1 | export const isBrowser = false; 2 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/hooks/useForceUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | export function useForceUpdate() { 4 | const [_, setCount] = useState(0); 5 | 6 | return useCallback(() => setCount((prevCount) => prevCount + 1), []); 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/hooks/useIsomorphicEffect.ts: -------------------------------------------------------------------------------- 1 | import { isBrowser } from "#is-browser"; 2 | import { useLayoutEffect as useLayoutEffectBrowser } from "react"; 3 | 4 | const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffectBrowser : () => {}; 5 | 6 | export default useIsomorphicLayoutEffect; 7 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/hooks/usePanelGroupContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { PanelGroupContext } from "../PanelGroupContext"; 3 | 4 | export function usePanelGroupContext() { 5 | const context = useContext(PanelGroupContext); 6 | 7 | return { 8 | direction: context?.direction, 9 | groupId: context?.groupId, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/hooks/useUniqueId.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useRef } from "react"; 3 | 4 | const useId = (React as any)["useId".toString()] as (() => string) | undefined; 5 | 6 | const wrappedUseId: () => string | null = 7 | typeof useId === "function" ? useId : (): null => null; 8 | 9 | let counter = 0; 10 | 11 | export default function useUniqueId( 12 | idFromParams: string | null = null 13 | ): string { 14 | const idFromUseId = wrappedUseId(); 15 | 16 | const idRef = useRef(idFromParams || idFromUseId || null); 17 | if (idRef.current === null) { 18 | idRef.current = "" + counter++; 19 | } 20 | 21 | return idFromParams ?? idRef.current; 22 | } 23 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/hooks/useWindowSplitterBehavior.ts: -------------------------------------------------------------------------------- 1 | import { DATA_ATTRIBUTES } from ".."; 2 | import { ResizeHandler } from "../types"; 3 | import { assert } from "../utils/assert"; 4 | import { getResizeHandleElement } from "../utils/dom/getResizeHandleElement"; 5 | import { getResizeHandleElementIndex } from "../utils/dom/getResizeHandleElementIndex"; 6 | import { getResizeHandleElementsForGroup } from "../utils/dom/getResizeHandleElementsForGroup"; 7 | import { useEffect } from "react"; 8 | 9 | // https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/ 10 | 11 | export function useWindowSplitterResizeHandlerBehavior({ 12 | disabled, 13 | handleId, 14 | resizeHandler, 15 | panelGroupElement, 16 | }: { 17 | disabled: boolean; 18 | handleId: string; 19 | resizeHandler: ResizeHandler | null; 20 | panelGroupElement: ParentNode | null; 21 | }): void { 22 | useEffect(() => { 23 | if (disabled || resizeHandler == null || panelGroupElement == null) { 24 | return; 25 | } 26 | 27 | const handleElement = getResizeHandleElement(handleId, panelGroupElement); 28 | if (handleElement == null) { 29 | return; 30 | } 31 | 32 | const onKeyDown = (event: KeyboardEvent) => { 33 | if (event.defaultPrevented) { 34 | return; 35 | } 36 | 37 | switch (event.key) { 38 | case "ArrowDown": 39 | case "ArrowLeft": 40 | case "ArrowRight": 41 | case "ArrowUp": 42 | case "End": 43 | case "Home": { 44 | event.preventDefault(); 45 | 46 | resizeHandler(event); 47 | break; 48 | } 49 | case "F6": { 50 | event.preventDefault(); 51 | 52 | const groupId = handleElement.getAttribute(DATA_ATTRIBUTES.groupId); 53 | assert(groupId, `No group element found for id "${groupId}"`); 54 | 55 | const handles = getResizeHandleElementsForGroup( 56 | groupId, 57 | panelGroupElement 58 | ); 59 | const index = getResizeHandleElementIndex( 60 | groupId, 61 | handleId, 62 | panelGroupElement 63 | ); 64 | 65 | assert( 66 | index !== null, 67 | `No resize element found for id "${handleId}"` 68 | ); 69 | 70 | const nextIndex = event.shiftKey 71 | ? index > 0 72 | ? index - 1 73 | : handles.length - 1 74 | : index + 1 < handles.length 75 | ? index + 1 76 | : 0; 77 | 78 | const nextHandle = handles[nextIndex] as HTMLElement; 79 | nextHandle.focus(); 80 | 81 | break; 82 | } 83 | } 84 | }; 85 | 86 | handleElement.addEventListener("keydown", onKeyDown); 87 | return () => { 88 | handleElement.removeEventListener("keydown", onKeyDown); 89 | }; 90 | }, [panelGroupElement, disabled, handleId, resizeHandler]); 91 | } 92 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Panel } from "./Panel"; 2 | import { PanelGroup } from "./PanelGroup"; 3 | import { PanelResizeHandle } from "./PanelResizeHandle"; 4 | import { DATA_ATTRIBUTES } from "./constants"; 5 | import { usePanelGroupContext } from "./hooks/usePanelGroupContext"; 6 | import { assert } from "./utils/assert"; 7 | import { setNonce } from "./utils/csp"; 8 | import { 9 | disableGlobalCursorStyles, 10 | enableGlobalCursorStyles, 11 | } from "./utils/cursor"; 12 | import { getPanelElement } from "./utils/dom/getPanelElement"; 13 | import { getPanelElementsForGroup } from "./utils/dom/getPanelElementsForGroup"; 14 | import { getPanelGroupElement } from "./utils/dom/getPanelGroupElement"; 15 | import { getResizeHandleElement } from "./utils/dom/getResizeHandleElement"; 16 | import { getResizeHandleElementIndex } from "./utils/dom/getResizeHandleElementIndex"; 17 | import { getResizeHandleElementsForGroup } from "./utils/dom/getResizeHandleElementsForGroup"; 18 | import { getResizeHandlePanelIds } from "./utils/dom/getResizeHandlePanelIds"; 19 | import { getIntersectingRectangle } from "./utils/rects/getIntersectingRectangle"; 20 | import { intersects } from "./utils/rects/intersects"; 21 | 22 | import type { 23 | ImperativePanelHandle, 24 | PanelOnCollapse, 25 | PanelOnExpand, 26 | PanelOnResize, 27 | PanelProps, 28 | } from "./Panel"; 29 | import type { 30 | ImperativePanelGroupHandle, 31 | PanelGroupOnLayout, 32 | PanelGroupProps, 33 | PanelGroupStorage, 34 | } from "./PanelGroup"; 35 | import type { 36 | PanelResizeHandleOnDragging, 37 | PanelResizeHandleProps, 38 | } from "./PanelResizeHandle"; 39 | import type { PointerHitAreaMargins } from "./PanelResizeHandleRegistry"; 40 | 41 | export { 42 | // TypeScript types 43 | ImperativePanelGroupHandle, 44 | ImperativePanelHandle, 45 | PanelGroupOnLayout, 46 | PanelGroupProps, 47 | PanelGroupStorage, 48 | PanelOnCollapse, 49 | PanelOnExpand, 50 | PanelOnResize, 51 | PanelProps, 52 | PanelResizeHandleOnDragging, 53 | PanelResizeHandleProps, 54 | PointerHitAreaMargins, 55 | 56 | // React components 57 | Panel, 58 | PanelGroup, 59 | PanelResizeHandle, 60 | 61 | // Hooks 62 | usePanelGroupContext, 63 | 64 | // Utility methods 65 | assert, 66 | getIntersectingRectangle, 67 | intersects, 68 | 69 | // DOM helpers 70 | getPanelElement, 71 | getPanelElementsForGroup, 72 | getPanelGroupElement, 73 | getResizeHandleElement, 74 | getResizeHandleElementIndex, 75 | getResizeHandleElementsForGroup, 76 | getResizeHandlePanelIds, 77 | 78 | // Styles and CSP (see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce) 79 | enableGlobalCursorStyles, 80 | disableGlobalCursorStyles, 81 | setNonce, 82 | 83 | // Data attributes (primarily intended for e2e testing) 84 | DATA_ATTRIBUTES, 85 | }; 86 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Direction = "horizontal" | "vertical"; 2 | 3 | // The "contextmenu" event is not supported as a PointerEvent in all browsers yet, so MouseEvent still need to be handled 4 | export type ResizeEvent = KeyboardEvent | PointerEvent | MouseEvent; 5 | export type ResizeHandler = (event: ResizeEvent) => void; 6 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/arrays.ts: -------------------------------------------------------------------------------- 1 | export function areEqual(arrayA: any[], arrayB: any[]): boolean { 2 | if (arrayA.length !== arrayB.length) { 3 | return false; 4 | } 5 | 6 | for (let index = 0; index < arrayA.length; index++) { 7 | if (arrayA[index] !== arrayB[index]) { 8 | return false; 9 | } 10 | } 11 | 12 | return true; 13 | } 14 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | export function assert( 2 | expectedCondition: any, 3 | message: string 4 | ): asserts expectedCondition { 5 | if (!expectedCondition) { 6 | console.error(message); 7 | 8 | throw Error(message); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/calculateAriaValues.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test } from "vitest"; 2 | import { PanelConstraints, PanelData } from "../Panel"; 3 | import { calculateAriaValues } from "./calculateAriaValues"; 4 | 5 | describe("calculateAriaValues", () => { 6 | let idCounter = 0; 7 | let orderCounter = 0; 8 | 9 | function createPanelData(constraints: PanelConstraints = {}): PanelData { 10 | return { 11 | callbacks: { 12 | onCollapse: undefined, 13 | onExpand: undefined, 14 | onResize: undefined, 15 | }, 16 | constraints, 17 | id: `${idCounter++}`, 18 | idIsFromProps: false, 19 | order: orderCounter++, 20 | }; 21 | } 22 | 23 | beforeEach(() => { 24 | idCounter = 0; 25 | orderCounter = 0; 26 | }); 27 | 28 | test("should work correctly for panels with no min/max constraints", () => { 29 | expect( 30 | calculateAriaValues({ 31 | layout: [50, 50], 32 | panelsArray: [createPanelData(), createPanelData()], 33 | pivotIndices: [0, 1], 34 | }) 35 | ).toEqual({ 36 | valueMax: 100, 37 | valueMin: 0, 38 | valueNow: 50, 39 | }); 40 | 41 | expect( 42 | calculateAriaValues({ 43 | layout: [20, 50, 30], 44 | panelsArray: [createPanelData(), createPanelData(), createPanelData()], 45 | pivotIndices: [0, 1], 46 | }) 47 | ).toEqual({ 48 | valueMax: 100, 49 | valueMin: 0, 50 | valueNow: 20, 51 | }); 52 | 53 | expect( 54 | calculateAriaValues({ 55 | layout: [20, 50, 30], 56 | panelsArray: [createPanelData(), createPanelData(), createPanelData()], 57 | pivotIndices: [1, 2], 58 | }) 59 | ).toEqual({ 60 | valueMax: 100, 61 | valueMin: 0, 62 | valueNow: 50, 63 | }); 64 | }); 65 | 66 | test("should work correctly for panels with min/max constraints", () => { 67 | expect( 68 | calculateAriaValues({ 69 | layout: [25, 75], 70 | panelsArray: [ 71 | createPanelData({ 72 | maxSize: 35, 73 | minSize: 10, 74 | }), 75 | createPanelData(), 76 | ], 77 | pivotIndices: [0, 1], 78 | }) 79 | ).toEqual({ 80 | valueMax: 35, 81 | valueMin: 10, 82 | valueNow: 25, 83 | }); 84 | 85 | expect( 86 | calculateAriaValues({ 87 | layout: [25, 50, 25], 88 | panelsArray: [ 89 | createPanelData({ 90 | maxSize: 35, 91 | minSize: 10, 92 | }), 93 | createPanelData(), 94 | createPanelData({ 95 | maxSize: 35, 96 | minSize: 10, 97 | }), 98 | ], 99 | pivotIndices: [1, 2], 100 | }) 101 | ).toEqual({ 102 | valueMax: 80, 103 | valueMin: 30, 104 | valueNow: 50, 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/calculateAriaValues.ts: -------------------------------------------------------------------------------- 1 | import { PanelData } from "../Panel"; 2 | import { assert } from "./assert"; 3 | 4 | export function calculateAriaValues({ 5 | layout, 6 | panelsArray, 7 | pivotIndices, 8 | }: { 9 | layout: number[]; 10 | panelsArray: PanelData[]; 11 | pivotIndices: number[]; 12 | }) { 13 | let currentMinSize = 0; 14 | let currentMaxSize = 100; 15 | let totalMinSize = 0; 16 | let totalMaxSize = 0; 17 | 18 | const firstIndex = pivotIndices[0]; 19 | assert(firstIndex != null, "No pivot index found"); 20 | 21 | // A panel's effective min/max sizes also need to account for other panel's sizes. 22 | panelsArray.forEach((panelData, index) => { 23 | const { constraints } = panelData; 24 | const { maxSize = 100, minSize = 0 } = constraints; 25 | 26 | if (index === firstIndex) { 27 | currentMinSize = minSize; 28 | currentMaxSize = maxSize; 29 | } else { 30 | totalMinSize += minSize; 31 | totalMaxSize += maxSize; 32 | } 33 | }); 34 | 35 | const valueMax = Math.min(currentMaxSize, 100 - totalMinSize); 36 | const valueMin = Math.max(currentMinSize, 100 - totalMaxSize); 37 | 38 | const valueNow = layout[firstIndex]; 39 | 40 | return { 41 | valueMax, 42 | valueMin, 43 | valueNow, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/calculateDeltaPercentage.ts: -------------------------------------------------------------------------------- 1 | import { DragState, ResizeEvent } from "../PanelGroupContext"; 2 | import { Direction } from "../types"; 3 | import { calculateDragOffsetPercentage } from "./calculateDragOffsetPercentage"; 4 | import { isKeyDown } from "./events"; 5 | 6 | // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX 7 | export function calculateDeltaPercentage( 8 | event: ResizeEvent, 9 | dragHandleId: string, 10 | direction: Direction, 11 | initialDragState: DragState | null, 12 | keyboardResizeBy: number | null, 13 | panelGroupElement: HTMLElement 14 | ): number { 15 | if (isKeyDown(event)) { 16 | const isHorizontal = direction === "horizontal"; 17 | 18 | let delta = 0; 19 | if (event.shiftKey) { 20 | delta = 100; 21 | } else if (keyboardResizeBy != null) { 22 | delta = keyboardResizeBy; 23 | } else { 24 | delta = 10; 25 | } 26 | 27 | let movement = 0; 28 | switch (event.key) { 29 | case "ArrowDown": 30 | movement = isHorizontal ? 0 : delta; 31 | break; 32 | case "ArrowLeft": 33 | movement = isHorizontal ? -delta : 0; 34 | break; 35 | case "ArrowRight": 36 | movement = isHorizontal ? delta : 0; 37 | break; 38 | case "ArrowUp": 39 | movement = isHorizontal ? 0 : -delta; 40 | break; 41 | case "End": 42 | movement = 100; 43 | break; 44 | case "Home": 45 | movement = -100; 46 | break; 47 | } 48 | 49 | return movement; 50 | } else { 51 | if (initialDragState == null) { 52 | return 0; 53 | } 54 | 55 | return calculateDragOffsetPercentage( 56 | event, 57 | dragHandleId, 58 | direction, 59 | initialDragState, 60 | panelGroupElement 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/calculateDragOffsetPercentage.ts: -------------------------------------------------------------------------------- 1 | import { DATA_ATTRIBUTES } from ".."; 2 | import { DragState, ResizeEvent } from "../PanelGroupContext"; 3 | import { Direction } from "../types"; 4 | import { assert } from "./assert"; 5 | import { getPanelGroupElement } from "./dom/getPanelGroupElement"; 6 | import { getResizeHandleElement } from "./dom/getResizeHandleElement"; 7 | import { getResizeEventCursorPosition } from "./events/getResizeEventCursorPosition"; 8 | 9 | export function calculateDragOffsetPercentage( 10 | event: ResizeEvent, 11 | dragHandleId: string, 12 | direction: Direction, 13 | initialDragState: DragState, 14 | panelGroupElement: HTMLElement 15 | ): number { 16 | const isHorizontal = direction === "horizontal"; 17 | 18 | const handleElement = getResizeHandleElement(dragHandleId, panelGroupElement); 19 | assert( 20 | handleElement, 21 | `No resize handle element found for id "${dragHandleId}"` 22 | ); 23 | 24 | const groupId = handleElement.getAttribute(DATA_ATTRIBUTES.groupId); 25 | assert(groupId, `Resize handle element has no group id attribute`); 26 | 27 | let { initialCursorPosition } = initialDragState; 28 | 29 | const cursorPosition = getResizeEventCursorPosition(direction, event); 30 | 31 | const groupElement = getPanelGroupElement(groupId, panelGroupElement); 32 | assert(groupElement, `No group element found for id "${groupId}"`); 33 | 34 | const groupRect = groupElement.getBoundingClientRect(); 35 | const groupSizeInPixels = isHorizontal ? groupRect.width : groupRect.height; 36 | 37 | const offsetPixels = cursorPosition - initialCursorPosition; 38 | const offsetPercentage = (offsetPixels / groupSizeInPixels) * 100; 39 | 40 | return offsetPercentage; 41 | } 42 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/calculateUnsafeDefaultLayout.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test } from "vitest"; 2 | import { PanelConstraints, PanelData } from "../Panel"; 3 | import { calculateUnsafeDefaultLayout } from "./calculateUnsafeDefaultLayout"; 4 | import { expectToBeCloseToArray } from "./test-utils"; 5 | 6 | describe("calculateUnsafeDefaultLayout", () => { 7 | let idCounter = 0; 8 | let orderCounter = 0; 9 | 10 | function createPanelData(constraints: PanelConstraints = {}): PanelData { 11 | return { 12 | callbacks: { 13 | onCollapse: undefined, 14 | onExpand: undefined, 15 | onResize: undefined, 16 | }, 17 | constraints, 18 | id: `${idCounter++}`, 19 | idIsFromProps: false, 20 | order: orderCounter++, 21 | }; 22 | } 23 | 24 | beforeEach(() => { 25 | idCounter = 0; 26 | orderCounter = 0; 27 | }); 28 | 29 | test("should assign even sizes for every panel by default", () => { 30 | expectToBeCloseToArray( 31 | calculateUnsafeDefaultLayout({ 32 | panelDataArray: [createPanelData()], 33 | }), 34 | [100] 35 | ); 36 | 37 | expectToBeCloseToArray( 38 | calculateUnsafeDefaultLayout({ 39 | panelDataArray: [createPanelData(), createPanelData()], 40 | }), 41 | [50, 50] 42 | ); 43 | 44 | expectToBeCloseToArray( 45 | calculateUnsafeDefaultLayout({ 46 | panelDataArray: [ 47 | createPanelData(), 48 | createPanelData(), 49 | createPanelData(), 50 | ], 51 | }), 52 | [33.3, 33.3, 33.3] 53 | ); 54 | }); 55 | 56 | test("should respect default panel size constraints", () => { 57 | expectToBeCloseToArray( 58 | calculateUnsafeDefaultLayout({ 59 | panelDataArray: [ 60 | createPanelData({ 61 | defaultSize: 15, 62 | }), 63 | createPanelData({ 64 | defaultSize: 85, 65 | }), 66 | ], 67 | }), 68 | [15, 85] 69 | ); 70 | }); 71 | 72 | test("should ignore min and max panel size constraints", () => { 73 | expectToBeCloseToArray( 74 | calculateUnsafeDefaultLayout({ 75 | panelDataArray: [ 76 | createPanelData({ 77 | minSize: 40, 78 | }), 79 | createPanelData(), 80 | createPanelData({ 81 | maxSize: 10, 82 | }), 83 | ], 84 | }), 85 | [33.3, 33.3, 33.3] 86 | ); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/calculateUnsafeDefaultLayout.ts: -------------------------------------------------------------------------------- 1 | import { PanelData } from "../Panel"; 2 | import { assert } from "./assert"; 3 | 4 | export function calculateUnsafeDefaultLayout({ 5 | panelDataArray, 6 | }: { 7 | panelDataArray: PanelData[]; 8 | }): number[] { 9 | const layout = Array(panelDataArray.length); 10 | 11 | const panelConstraintsArray = panelDataArray.map( 12 | (panelData) => panelData.constraints 13 | ); 14 | 15 | let numPanelsWithSizes = 0; 16 | let remainingSize = 100; 17 | 18 | // Distribute default sizes first 19 | for (let index = 0; index < panelDataArray.length; index++) { 20 | const panelConstraints = panelConstraintsArray[index]; 21 | assert(panelConstraints, `Panel constraints not found for index ${index}`); 22 | const { defaultSize } = panelConstraints; 23 | 24 | if (defaultSize != null) { 25 | numPanelsWithSizes++; 26 | layout[index] = defaultSize; 27 | remainingSize -= defaultSize; 28 | } 29 | } 30 | 31 | // Remaining size should be distributed evenly between panels without default sizes 32 | for (let index = 0; index < panelDataArray.length; index++) { 33 | const panelConstraints = panelConstraintsArray[index]; 34 | assert(panelConstraints, `Panel constraints not found for index ${index}`); 35 | const { defaultSize } = panelConstraints; 36 | 37 | if (defaultSize != null) { 38 | continue; 39 | } 40 | 41 | const numRemainingPanels = panelDataArray.length - numPanelsWithSizes; 42 | const size = remainingSize / numRemainingPanels; 43 | 44 | numPanelsWithSizes++; 45 | layout[index] = size; 46 | remainingSize -= size; 47 | } 48 | 49 | return layout; 50 | } 51 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/callPanelCallbacks.ts: -------------------------------------------------------------------------------- 1 | import { PanelData } from "../Panel"; 2 | import { assert } from "./assert"; 3 | import { fuzzyNumbersEqual } from "./numbers/fuzzyCompareNumbers"; 4 | 5 | // Layout should be pre-converted into percentages 6 | export function callPanelCallbacks( 7 | panelsArray: PanelData[], 8 | layout: number[], 9 | panelIdToLastNotifiedSizeMap: Record 10 | ) { 11 | layout.forEach((size, index) => { 12 | const panelData = panelsArray[index]; 13 | assert(panelData, `Panel data not found for index ${index}`); 14 | 15 | const { callbacks, constraints, id: panelId } = panelData; 16 | const { collapsedSize = 0, collapsible } = constraints; 17 | 18 | const lastNotifiedSize = panelIdToLastNotifiedSizeMap[panelId]; 19 | if (lastNotifiedSize == null || size !== lastNotifiedSize) { 20 | panelIdToLastNotifiedSizeMap[panelId] = size; 21 | 22 | const { onCollapse, onExpand, onResize } = callbacks; 23 | 24 | if (onResize) { 25 | onResize(size, lastNotifiedSize); 26 | } 27 | 28 | if (collapsible && (onCollapse || onExpand)) { 29 | if ( 30 | onExpand && 31 | (lastNotifiedSize == null || 32 | fuzzyNumbersEqual(lastNotifiedSize, collapsedSize)) && 33 | !fuzzyNumbersEqual(size, collapsedSize) 34 | ) { 35 | onExpand(); 36 | } 37 | 38 | if ( 39 | onCollapse && 40 | (lastNotifiedSize == null || 41 | !fuzzyNumbersEqual(lastNotifiedSize, collapsedSize)) && 42 | fuzzyNumbersEqual(size, collapsedSize) 43 | ) { 44 | onCollapse(); 45 | } 46 | } 47 | } 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/compareLayouts.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { compareLayouts } from "./compareLayouts"; 3 | 4 | describe("compareLayouts", () => { 5 | test("should work", () => { 6 | expect(compareLayouts([1, 2], [1])).toBe(false); 7 | expect(compareLayouts([1], [1, 2])).toBe(false); 8 | expect(compareLayouts([1, 2, 3], [1, 2, 3])).toBe(true); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/compareLayouts.ts: -------------------------------------------------------------------------------- 1 | export function compareLayouts(a: number[], b: number[]) { 2 | if (a.length !== b.length) { 3 | return false; 4 | } else { 5 | for (let index = 0; index < a.length; index++) { 6 | if (a[index] != b[index]) { 7 | return false; 8 | } 9 | } 10 | } 11 | return true; 12 | } 13 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/computePanelFlexBoxStyle.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { PanelConstraints, PanelData } from "../Panel"; 3 | import { computePanelFlexBoxStyle } from "./computePanelFlexBoxStyle"; 4 | 5 | describe("computePanelFlexBoxStyle", () => { 6 | function createPanelData(constraints: PanelConstraints = {}): PanelData { 7 | return { 8 | callbacks: {}, 9 | constraints, 10 | id: "fake", 11 | idIsFromProps: false, 12 | order: undefined, 13 | }; 14 | } 15 | 16 | test("should observe a panel's default size if group layout has not yet been computed", () => { 17 | expect( 18 | computePanelFlexBoxStyle({ 19 | defaultSize: 0.1233456789, 20 | dragState: null, 21 | layout: [], 22 | panelData: [ 23 | createPanelData({ 24 | defaultSize: 0.1233456789, 25 | }), 26 | createPanelData(), 27 | ], 28 | panelIndex: 0, 29 | precision: 2, 30 | }) 31 | ).toMatchInlineSnapshot(` 32 | { 33 | "flexBasis": 0, 34 | "flexGrow": "0.12", 35 | "flexShrink": 1, 36 | "overflow": "hidden", 37 | "pointerEvents": undefined, 38 | } 39 | `); 40 | }); 41 | 42 | test("should always fill the full width for single-panel groups", () => { 43 | expect( 44 | computePanelFlexBoxStyle({ 45 | defaultSize: undefined, 46 | dragState: null, 47 | layout: [], 48 | panelData: [createPanelData()], 49 | panelIndex: 0, 50 | precision: 2, 51 | }) 52 | ).toMatchInlineSnapshot(` 53 | { 54 | "flexBasis": 0, 55 | "flexGrow": "1", 56 | "flexShrink": 1, 57 | "overflow": "hidden", 58 | "pointerEvents": undefined, 59 | } 60 | `); 61 | }); 62 | 63 | test("should round sizes to avoid floating point precision errors", () => { 64 | const layout = [0.25435, 0.5758, 0.1698]; 65 | const panelData = [createPanelData(), createPanelData(), createPanelData()]; 66 | 67 | expect( 68 | computePanelFlexBoxStyle({ 69 | defaultSize: undefined, 70 | dragState: null, 71 | layout, 72 | panelData, 73 | panelIndex: 0, 74 | precision: 2, 75 | }) 76 | ).toMatchInlineSnapshot(` 77 | { 78 | "flexBasis": 0, 79 | "flexGrow": "0.25", 80 | "flexShrink": 1, 81 | "overflow": "hidden", 82 | "pointerEvents": undefined, 83 | } 84 | `); 85 | 86 | expect( 87 | computePanelFlexBoxStyle({ 88 | defaultSize: undefined, 89 | dragState: null, 90 | layout, 91 | panelData, 92 | panelIndex: 1, 93 | precision: 2, 94 | }) 95 | ).toMatchInlineSnapshot(` 96 | { 97 | "flexBasis": 0, 98 | "flexGrow": "0.58", 99 | "flexShrink": 1, 100 | "overflow": "hidden", 101 | "pointerEvents": undefined, 102 | } 103 | `); 104 | 105 | expect( 106 | computePanelFlexBoxStyle({ 107 | defaultSize: undefined, 108 | dragState: null, 109 | layout, 110 | panelData, 111 | panelIndex: 2, 112 | precision: 2, 113 | }) 114 | ).toMatchInlineSnapshot(` 115 | { 116 | "flexBasis": 0, 117 | "flexGrow": "0.17", 118 | "flexShrink": 1, 119 | "overflow": "hidden", 120 | "pointerEvents": undefined, 121 | } 122 | `); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/computePanelFlexBoxStyle.ts: -------------------------------------------------------------------------------- 1 | // This method returns a number between 1 and 100 representing 2 | 3 | import { PanelData } from "../Panel"; 4 | import { DragState } from "../PanelGroupContext"; 5 | import { CSSProperties } from "react"; 6 | 7 | // the % of the group's overall space this panel should occupy. 8 | export function computePanelFlexBoxStyle({ 9 | defaultSize, 10 | dragState, 11 | layout, 12 | panelData, 13 | panelIndex, 14 | precision = 3, 15 | }: { 16 | defaultSize: number | undefined; 17 | layout: number[]; 18 | dragState: DragState | null; 19 | panelData: PanelData[]; 20 | panelIndex: number; 21 | precision?: number; 22 | }): CSSProperties { 23 | const size = layout[panelIndex]; 24 | 25 | let flexGrow; 26 | if (size == null) { 27 | // Initial render (before panels have registered themselves) 28 | // In order to support server rendering, fall back to default size if provided 29 | flexGrow = 30 | defaultSize != undefined ? defaultSize.toPrecision(precision) : "1"; 31 | } else if (panelData.length === 1) { 32 | // Special case: Single panel group should always fill full width/height 33 | flexGrow = "1"; 34 | } else { 35 | flexGrow = size.toPrecision(precision); 36 | } 37 | 38 | return { 39 | flexBasis: 0, 40 | flexGrow, 41 | flexShrink: 1, 42 | 43 | // Without this, Panel sizes may be unintentionally overridden by their content 44 | overflow: "hidden", 45 | 46 | // Disable pointer events inside of a panel during resize 47 | // This avoid edge cases like nested iframes 48 | pointerEvents: dragState !== null ? "none" : undefined, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/csp.ts: -------------------------------------------------------------------------------- 1 | let nonce: string | null; 2 | 3 | export function getNonce(): string | null { 4 | return nonce; 5 | } 6 | 7 | export function setNonce(value: string | null) { 8 | nonce = value; 9 | } 10 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/cursor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EXCEEDED_HORIZONTAL_MAX, 3 | EXCEEDED_HORIZONTAL_MIN, 4 | EXCEEDED_VERTICAL_MAX, 5 | EXCEEDED_VERTICAL_MIN, 6 | } from "../PanelResizeHandleRegistry"; 7 | import { getNonce } from "./csp"; 8 | 9 | type CursorState = "horizontal" | "intersection" | "vertical"; 10 | 11 | let currentCursorStyle: string | null = null; 12 | let enabled: boolean = true; 13 | let prevRuleIndex = -1; 14 | let styleElement: HTMLStyleElement | null = null; 15 | 16 | export function disableGlobalCursorStyles() { 17 | enabled = false; 18 | } 19 | 20 | export function enableGlobalCursorStyles() { 21 | enabled = true; 22 | } 23 | 24 | export function getCursorStyle( 25 | state: CursorState, 26 | constraintFlags: number 27 | ): string { 28 | if (constraintFlags) { 29 | const horizontalMin = (constraintFlags & EXCEEDED_HORIZONTAL_MIN) !== 0; 30 | const horizontalMax = (constraintFlags & EXCEEDED_HORIZONTAL_MAX) !== 0; 31 | const verticalMin = (constraintFlags & EXCEEDED_VERTICAL_MIN) !== 0; 32 | const verticalMax = (constraintFlags & EXCEEDED_VERTICAL_MAX) !== 0; 33 | 34 | if (horizontalMin) { 35 | if (verticalMin) { 36 | return "se-resize"; 37 | } else if (verticalMax) { 38 | return "ne-resize"; 39 | } else { 40 | return "e-resize"; 41 | } 42 | } else if (horizontalMax) { 43 | if (verticalMin) { 44 | return "sw-resize"; 45 | } else if (verticalMax) { 46 | return "nw-resize"; 47 | } else { 48 | return "w-resize"; 49 | } 50 | } else if (verticalMin) { 51 | return "s-resize"; 52 | } else if (verticalMax) { 53 | return "n-resize"; 54 | } 55 | } 56 | 57 | switch (state) { 58 | case "horizontal": 59 | return "ew-resize"; 60 | case "intersection": 61 | return "move"; 62 | case "vertical": 63 | return "ns-resize"; 64 | } 65 | } 66 | 67 | export function resetGlobalCursorStyle() { 68 | if (styleElement !== null) { 69 | document.head.removeChild(styleElement); 70 | 71 | currentCursorStyle = null; 72 | styleElement = null; 73 | prevRuleIndex = -1; 74 | } 75 | } 76 | 77 | export function setGlobalCursorStyle( 78 | state: CursorState, 79 | constraintFlags: number 80 | ) { 81 | if (!enabled) { 82 | return; 83 | } 84 | 85 | const style = getCursorStyle(state, constraintFlags); 86 | 87 | if (currentCursorStyle === style) { 88 | return; 89 | } 90 | 91 | currentCursorStyle = style; 92 | 93 | if (styleElement === null) { 94 | styleElement = document.createElement("style"); 95 | 96 | const nonce = getNonce(); 97 | if (nonce) { 98 | styleElement.setAttribute("nonce", nonce); 99 | } 100 | 101 | document.head.appendChild(styleElement); 102 | } 103 | 104 | if (prevRuleIndex >= 0) { 105 | styleElement.sheet?.removeRule(prevRuleIndex); 106 | } 107 | 108 | prevRuleIndex = 109 | styleElement.sheet?.insertRule(`*{cursor: ${style} !important;}`) ?? -1; 110 | } 111 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export default function debounce( 2 | callback: T, 3 | durationMs: number = 10 4 | ) { 5 | let timeoutId: NodeJS.Timeout | null = null; 6 | 7 | let callable = (...args: any) => { 8 | if (timeoutId !== null) { 9 | clearTimeout(timeoutId); 10 | } 11 | 12 | timeoutId = setTimeout(() => { 13 | callback(...args); 14 | }, durationMs); 15 | }; 16 | 17 | return callable as unknown as T; 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/determinePivotIndices.ts: -------------------------------------------------------------------------------- 1 | import { getResizeHandleElementIndex } from "../utils/dom/getResizeHandleElementIndex"; 2 | 3 | export function determinePivotIndices( 4 | groupId: string, 5 | dragHandleId: string, 6 | panelGroupElement: ParentNode 7 | ): [indexBefore: number, indexAfter: number] { 8 | const index = getResizeHandleElementIndex( 9 | groupId, 10 | dragHandleId, 11 | panelGroupElement 12 | ); 13 | 14 | return index != null ? [index, index + 1] : [-1, -1]; 15 | } 16 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getPanelElement.ts: -------------------------------------------------------------------------------- 1 | export function getPanelElement( 2 | id: string, 3 | scope: ParentNode | HTMLElement = document 4 | ): HTMLElement | null { 5 | const element = scope.querySelector(`[data-panel-id="${id}"]`); 6 | if (element) { 7 | return element as HTMLElement; 8 | } 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getPanelElementsForGroup.ts: -------------------------------------------------------------------------------- 1 | export function getPanelElementsForGroup( 2 | groupId: string, 3 | scope: ParentNode | HTMLElement = document 4 | ): HTMLElement[] { 5 | return Array.from( 6 | scope.querySelectorAll(`[data-panel][data-panel-group-id="${groupId}"]`) 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getPanelGroupElement.ts: -------------------------------------------------------------------------------- 1 | import { isHTMLElement } from "./isHTMLElement"; 2 | 3 | export function getPanelGroupElement( 4 | id: string, 5 | rootElement: ParentNode | HTMLElement = document 6 | ): HTMLElement | null { 7 | // If the root element is the PanelGroup 8 | if (isHTMLElement(rootElement) && rootElement.dataset.panelGroupId == id) { 9 | return rootElement as HTMLElement; 10 | } 11 | 12 | // Else query children 13 | const element = rootElement.querySelector( 14 | `[data-panel-group][data-panel-group-id="${id}"]` 15 | ); 16 | if (element) { 17 | return element as HTMLElement; 18 | } 19 | return null; 20 | } 21 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getResizeHandleElement.ts: -------------------------------------------------------------------------------- 1 | import { DATA_ATTRIBUTES } from "../../constants"; 2 | 3 | export function getResizeHandleElement( 4 | id: string, 5 | scope: ParentNode | HTMLElement = document 6 | ): HTMLElement | null { 7 | const element = scope.querySelector( 8 | `[${DATA_ATTRIBUTES.resizeHandleId}="${id}"]` 9 | ); 10 | if (element) { 11 | return element as HTMLElement; 12 | } 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getResizeHandleElementIndex.ts: -------------------------------------------------------------------------------- 1 | import { DATA_ATTRIBUTES } from "../../constants"; 2 | import { getResizeHandleElementsForGroup } from "./getResizeHandleElementsForGroup"; 3 | 4 | export function getResizeHandleElementIndex( 5 | groupId: string, 6 | id: string, 7 | scope: ParentNode | HTMLElement = document 8 | ): number | null { 9 | const handles = getResizeHandleElementsForGroup(groupId, scope); 10 | const index = handles.findIndex( 11 | (handle) => handle.getAttribute(DATA_ATTRIBUTES.resizeHandleId) === id 12 | ); 13 | return index ?? null; 14 | } 15 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getResizeHandleElementsForGroup.ts: -------------------------------------------------------------------------------- 1 | import { DATA_ATTRIBUTES } from "../../constants"; 2 | 3 | export function getResizeHandleElementsForGroup( 4 | groupId: string, 5 | scope: ParentNode | HTMLElement = document 6 | ): HTMLElement[] { 7 | return Array.from( 8 | scope.querySelectorAll( 9 | `[${DATA_ATTRIBUTES.resizeHandleId}][data-panel-group-id="${groupId}"]` 10 | ) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/getResizeHandlePanelIds.ts: -------------------------------------------------------------------------------- 1 | import { PanelData } from "../../Panel"; 2 | import { getResizeHandleElement } from "./getResizeHandleElement"; 3 | import { getResizeHandleElementsForGroup } from "./getResizeHandleElementsForGroup"; 4 | 5 | export function getResizeHandlePanelIds( 6 | groupId: string, 7 | handleId: string, 8 | panelsArray: PanelData[], 9 | scope: ParentNode | HTMLElement = document 10 | ): [idBefore: string | null, idAfter: string | null] { 11 | const handle = getResizeHandleElement(handleId, scope); 12 | const handles = getResizeHandleElementsForGroup(groupId, scope); 13 | const index = handle ? handles.indexOf(handle) : -1; 14 | 15 | const idBefore: string | null = panelsArray[index]?.id ?? null; 16 | const idAfter: string | null = panelsArray[index + 1]?.id ?? null; 17 | 18 | return [idBefore, idAfter]; 19 | } 20 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/dom/isHTMLElement.ts: -------------------------------------------------------------------------------- 1 | export function isHTMLElement(target: unknown): target is HTMLElement { 2 | if (target instanceof HTMLElement) { 3 | return true; 4 | } 5 | 6 | // Fallback to duck typing to handle edge case of portals within a popup window 7 | return ( 8 | typeof target === "object" && 9 | target !== null && 10 | "tagName" in target && 11 | "getAttribute" in target 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/events/getResizeEventCoordinates.ts: -------------------------------------------------------------------------------- 1 | import { ResizeEvent } from "../../types"; 2 | import { isMouseEvent, isPointerEvent } from "."; 3 | 4 | export function getResizeEventCoordinates(event: ResizeEvent) { 5 | if (isPointerEvent(event)) { 6 | if (event.isPrimary) { 7 | return { 8 | x: event.clientX, 9 | y: event.clientY, 10 | }; 11 | } 12 | } else if (isMouseEvent(event)) { 13 | return { 14 | x: event.clientX, 15 | y: event.clientY, 16 | }; 17 | } 18 | 19 | return { 20 | x: Infinity, 21 | y: Infinity, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/events/getResizeEventCursorPosition.ts: -------------------------------------------------------------------------------- 1 | import { ResizeEvent } from "../../PanelGroupContext"; 2 | import { Direction } from "../../types"; 3 | import { getResizeEventCoordinates } from "./getResizeEventCoordinates"; 4 | 5 | export function getResizeEventCursorPosition( 6 | direction: Direction, 7 | event: ResizeEvent 8 | ): number { 9 | const isHorizontal = direction === "horizontal"; 10 | 11 | const { x, y } = getResizeEventCoordinates(event); 12 | 13 | return isHorizontal ? x : y; 14 | } 15 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/events/index.ts: -------------------------------------------------------------------------------- 1 | import { ResizeEvent } from "../../PanelGroupContext"; 2 | 3 | export function isKeyDown(event: ResizeEvent): event is KeyboardEvent { 4 | return event.type === "keydown"; 5 | } 6 | 7 | export function isPointerEvent(event: ResizeEvent): event is PointerEvent { 8 | return event.type.startsWith("pointer"); 9 | } 10 | 11 | export function isMouseEvent(event: ResizeEvent): event is MouseEvent { 12 | return event.type.startsWith("mouse"); 13 | } 14 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/getInputType.ts: -------------------------------------------------------------------------------- 1 | export function getInputType(): "coarse" | "fine" | undefined { 2 | if (typeof matchMedia === "function") { 3 | return matchMedia("(pointer:coarse)").matches ? "coarse" : "fine"; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/initializeDefaultStorage.ts: -------------------------------------------------------------------------------- 1 | import { PanelGroupStorage } from "../PanelGroup"; 2 | 3 | // PanelGroup might be rendering in a server-side environment where localStorage is not available 4 | // or on a browser with cookies/storage disabled. 5 | // In either case, this function avoids accessing localStorage until needed, 6 | // and avoids throwing user-visible errors. 7 | export function initializeDefaultStorage(storageObject: PanelGroupStorage) { 8 | try { 9 | if (typeof localStorage !== "undefined") { 10 | // Bypass this check for future calls 11 | storageObject.getItem = (name: string) => { 12 | return localStorage.getItem(name); 13 | }; 14 | storageObject.setItem = (name: string, value: string) => { 15 | localStorage.setItem(name, value); 16 | }; 17 | } else { 18 | throw new Error("localStorage not supported in this environment"); 19 | } 20 | } catch (error) { 21 | console.error(error); 22 | 23 | storageObject.getItem = () => null; 24 | storageObject.setItem = () => {}; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/numbers/fuzzyCompareNumbers.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { fuzzyCompareNumbers } from "./fuzzyCompareNumbers"; 3 | 4 | describe("fuzzyCompareNumbers", () => { 5 | test("should return 0 when numbers are equal", () => { 6 | expect(fuzzyCompareNumbers(10.123, 10.123, 5)).toBe(0); 7 | }); 8 | 9 | test("should return 0 when numbers are fuzzy equal", () => { 10 | expect(fuzzyCompareNumbers(0.000001, 0.000002, 5)).toBe(0); 11 | }); 12 | 13 | test("should return a delta when numbers are not unequal", () => { 14 | expect(fuzzyCompareNumbers(0.000001, 0.000002, 6)).toBe(-1); 15 | expect(fuzzyCompareNumbers(0.000005, 0.000002, 6)).toBe(1); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/numbers/fuzzyCompareNumbers.ts: -------------------------------------------------------------------------------- 1 | import { PRECISION } from "../../constants"; 2 | 3 | export function fuzzyCompareNumbers( 4 | actual: number, 5 | expected: number, 6 | fractionDigits: number = PRECISION 7 | ): number { 8 | if (actual.toFixed(fractionDigits) === expected.toFixed(fractionDigits)) { 9 | return 0; 10 | } else { 11 | return actual > expected ? 1 : -1; 12 | } 13 | } 14 | 15 | export function fuzzyNumbersEqual( 16 | actual: number, 17 | expected: number, 18 | fractionDigits: number = PRECISION 19 | ): boolean { 20 | return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0; 21 | } 22 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/numbers/fuzzyLayoutsEqual.ts: -------------------------------------------------------------------------------- 1 | import { fuzzyNumbersEqual } from "./fuzzyNumbersEqual"; 2 | 3 | export function fuzzyLayoutsEqual( 4 | actual: number[], 5 | expected: number[], 6 | fractionDigits?: number 7 | ): boolean { 8 | if (actual.length !== expected.length) { 9 | return false; 10 | } 11 | 12 | for (let index = 0; index < actual.length; index++) { 13 | const actualSize = actual[index] as number; 14 | const expectedSize = expected[index] as number; 15 | 16 | if (!fuzzyNumbersEqual(actualSize, expectedSize, fractionDigits)) { 17 | return false; 18 | } 19 | } 20 | 21 | return true; 22 | } 23 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/numbers/fuzzyNumbersEqual.ts: -------------------------------------------------------------------------------- 1 | import { fuzzyCompareNumbers } from "./fuzzyCompareNumbers"; 2 | 3 | export function fuzzyNumbersEqual( 4 | actual: number, 5 | expected: number, 6 | fractionDigits?: number 7 | ): boolean { 8 | return fuzzyCompareNumbers(actual, expected, fractionDigits) === 0; 9 | } 10 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/rects/getIntersectingRectangle.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test } from "vitest"; 2 | import { getIntersectingRectangle } from "./getIntersectingRectangle"; 3 | import { Rectangle } from "./types"; 4 | 5 | const emptyRect = { x: 0, y: 0, width: 0, height: 0 }; 6 | const rect = { x: 25, y: 25, width: 50, height: 50 }; 7 | 8 | function forkRect(partial: Partial, baseRect: Rectangle = rect) { 9 | return { ...rect, ...partial }; 10 | } 11 | 12 | describe("getIntersectingRectangle", () => { 13 | let strict: boolean = false; 14 | 15 | function verify(rectOne: Rectangle, rectTwo: Rectangle, expected: Rectangle) { 16 | const actual = getIntersectingRectangle(rectOne, rectTwo, strict); 17 | 18 | try { 19 | expect(actual).toEqual(expected); 20 | } catch (thrown) { 21 | console.log( 22 | "Expect", 23 | strict ? "strict mode" : "loose mode", 24 | "\n", 25 | rectOne, 26 | "\n", 27 | rectTwo, 28 | "\n\nto intersect as:\n", 29 | expected, 30 | "\n\nbut got:\n", 31 | actual 32 | ); 33 | 34 | throw thrown; 35 | } 36 | } 37 | 38 | describe("loose", () => { 39 | beforeEach(() => { 40 | strict = false; 41 | }); 42 | 43 | test("should support empty rects", () => { 44 | verify(emptyRect, emptyRect, emptyRect); 45 | }); 46 | 47 | test("should support fully overlapping rects", () => { 48 | verify(rect, forkRect({ x: 35, width: 30 }), { 49 | x: 35, 50 | y: 25, 51 | width: 30, 52 | height: 50, 53 | }); 54 | 55 | verify(rect, forkRect({ y: 35, height: 30 }), { 56 | x: 25, 57 | y: 35, 58 | width: 50, 59 | height: 30, 60 | }); 61 | 62 | verify( 63 | rect, 64 | forkRect({ 65 | x: 35, 66 | y: 35, 67 | width: 30, 68 | height: 30, 69 | }), 70 | 71 | { 72 | x: 35, 73 | y: 35, 74 | width: 30, 75 | height: 30, 76 | } 77 | ); 78 | }); 79 | 80 | test("should support partially overlapping rects", () => { 81 | verify(rect, forkRect({ x: 10, y: 10 }), { 82 | x: 25, 83 | y: 25, 84 | width: 35, 85 | height: 35, 86 | }); 87 | 88 | verify(rect, forkRect({ x: 45, y: 30 }), { 89 | x: 45, 90 | y: 30, 91 | width: 30, 92 | height: 45, 93 | }); 94 | }); 95 | 96 | test("should support non-overlapping rects", () => { 97 | verify(rect, forkRect({ x: 100, y: 100 }), emptyRect); 98 | }); 99 | 100 | test("should support all negative coordinates", () => { 101 | verify( 102 | { 103 | x: -100, 104 | y: -100, 105 | width: 50, 106 | height: 50, 107 | }, 108 | { x: -80, y: -80, width: 50, height: 50 }, 109 | { 110 | x: -80, 111 | y: -80, 112 | width: 30, 113 | height: 30, 114 | } 115 | ); 116 | }); 117 | }); 118 | 119 | describe("strict", () => { 120 | beforeEach(() => { 121 | strict = true; 122 | }); 123 | 124 | test("should support empty rects", () => { 125 | verify(emptyRect, emptyRect, emptyRect); 126 | }); 127 | 128 | test("should support fully overlapping rects", () => { 129 | verify(rect, forkRect({ x: 35, width: 30 }), { 130 | x: 35, 131 | y: 25, 132 | width: 30, 133 | height: 50, 134 | }); 135 | 136 | verify(rect, forkRect({ y: 35, height: 30 }), { 137 | x: 25, 138 | y: 35, 139 | width: 50, 140 | height: 30, 141 | }); 142 | 143 | verify( 144 | rect, 145 | forkRect({ 146 | x: 35, 147 | y: 35, 148 | width: 30, 149 | height: 30, 150 | }), 151 | 152 | { 153 | x: 35, 154 | y: 35, 155 | width: 30, 156 | height: 30, 157 | } 158 | ); 159 | }); 160 | 161 | test("should support partially overlapping rects", () => { 162 | verify(rect, forkRect({ x: 10, y: 10 }), { 163 | x: 25, 164 | y: 25, 165 | width: 35, 166 | height: 35, 167 | }); 168 | 169 | verify(rect, forkRect({ x: 45, y: 30 }), { 170 | x: 45, 171 | y: 30, 172 | width: 30, 173 | height: 45, 174 | }); 175 | }); 176 | 177 | test("should support non-overlapping rects", () => { 178 | verify(rect, forkRect({ x: 100, y: 100 }), emptyRect); 179 | }); 180 | 181 | test("should support all negative coordinates", () => { 182 | verify( 183 | { 184 | x: -100, 185 | y: -100, 186 | width: 50, 187 | height: 50, 188 | }, 189 | { x: -80, y: -80, width: 50, height: 50 }, 190 | { 191 | x: -80, 192 | y: -80, 193 | width: 30, 194 | height: 30, 195 | } 196 | ); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/rects/getIntersectingRectangle.ts: -------------------------------------------------------------------------------- 1 | import { intersects } from "./intersects"; 2 | import { Rectangle } from "./types"; 3 | 4 | export function getIntersectingRectangle( 5 | rectOne: Rectangle, 6 | rectTwo: Rectangle, 7 | strict: boolean 8 | ): Rectangle { 9 | if (!intersects(rectOne, rectTwo, strict)) { 10 | return { 11 | x: 0, 12 | y: 0, 13 | width: 0, 14 | height: 0, 15 | }; 16 | } 17 | 18 | return { 19 | x: Math.max(rectOne.x, rectTwo.x), 20 | y: Math.max(rectOne.y, rectTwo.y), 21 | width: 22 | Math.min(rectOne.x + rectOne.width, rectTwo.x + rectTwo.width) - 23 | Math.max(rectOne.x, rectTwo.x), 24 | height: 25 | Math.min(rectOne.y + rectOne.height, rectTwo.y + rectTwo.height) - 26 | Math.max(rectOne.y, rectTwo.y), 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/rects/intersects.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, test } from "vitest"; 2 | import { intersects } from "./intersects"; 3 | import { Rectangle } from "./types"; 4 | 5 | const emptyRect = { x: 0, y: 0, width: 0, height: 0 }; 6 | const rect = { x: 25, y: 25, width: 50, height: 50 }; 7 | 8 | function forkRect(partial: Partial, baseRect: Rectangle = rect) { 9 | return { ...rect, ...partial }; 10 | } 11 | 12 | describe("intersects", () => { 13 | let strict: boolean = false; 14 | 15 | function verify(rectOne: Rectangle, rectTwo: Rectangle, expected: boolean) { 16 | const actual = intersects(rectOne, rectTwo, strict); 17 | 18 | try { 19 | expect(actual).toBe(expected); 20 | } catch (thrown) { 21 | console.log( 22 | "Expected", 23 | rectOne, 24 | "to", 25 | expected ? "intersect" : "not intersect", 26 | rectTwo, 27 | strict ? "in strict mode" : "in loose mode" 28 | ); 29 | 30 | throw thrown; 31 | } 32 | } 33 | 34 | describe("loose", () => { 35 | beforeEach(() => { 36 | strict = false; 37 | }); 38 | 39 | test("should handle empty rects", () => { 40 | verify(emptyRect, emptyRect, true); 41 | }); 42 | 43 | test("should support fully overlapping rects", () => { 44 | verify(rect, rect, true); 45 | 46 | verify(rect, forkRect({ x: 35, width: 30 }), true); 47 | verify(rect, forkRect({ y: 35, height: 30 }), true); 48 | verify( 49 | rect, 50 | forkRect({ 51 | x: 35, 52 | y: 35, 53 | width: 30, 54 | height: 30, 55 | }), 56 | true 57 | ); 58 | 59 | verify(rect, forkRect({ x: 10, width: 100 }), true); 60 | verify(rect, forkRect({ y: 10, height: 100 }), true); 61 | verify( 62 | rect, 63 | forkRect({ 64 | x: 10, 65 | y: 10, 66 | width: 100, 67 | height: 100, 68 | }), 69 | true 70 | ); 71 | }); 72 | 73 | test("should support partially overlapping rects", () => { 74 | const cases: Partial[] = [ 75 | { x: 0 }, 76 | { y: 0 }, 77 | 78 | // Loose mode only 79 | { x: -25 }, 80 | { x: 75 }, 81 | { y: -25 }, 82 | { y: 75 }, 83 | { x: -25, y: -25 }, 84 | { x: 75, y: 75 }, 85 | ]; 86 | 87 | cases.forEach((partial) => { 88 | verify(forkRect(partial), rect, true); 89 | }); 90 | }); 91 | 92 | test("should support non-overlapping rects", () => { 93 | const cases: Partial[] = [ 94 | { x: 100 }, 95 | { x: -100 }, 96 | { y: 100 }, 97 | { y: -100 }, 98 | { x: -100, y: -100 }, 99 | { x: 100, y: 100 }, 100 | ]; 101 | 102 | cases.forEach((partial) => { 103 | verify(forkRect(partial), rect, false); 104 | }); 105 | }); 106 | 107 | test("should support all negative coordinates", () => { 108 | expect( 109 | intersects( 110 | { x: -100, y: -100, width: 50, height: 50 }, 111 | { x: -110, y: -90, width: 50, height: 50 }, 112 | false 113 | ) 114 | ).toBe(true); 115 | }); 116 | }); 117 | 118 | describe("strict", () => { 119 | beforeEach(() => { 120 | strict = true; 121 | }); 122 | 123 | test("should handle empty rects", () => { 124 | verify(emptyRect, emptyRect, false); 125 | }); 126 | 127 | test("should support fully overlapping rects", () => { 128 | verify(rect, rect, true); 129 | 130 | verify(rect, forkRect({ x: 35, width: 30 }), true); 131 | verify(rect, forkRect({ y: 35, height: 30 }), true); 132 | verify( 133 | rect, 134 | forkRect({ 135 | x: 35, 136 | y: 35, 137 | width: 30, 138 | height: 30, 139 | }), 140 | true 141 | ); 142 | 143 | verify(rect, forkRect({ x: 10, width: 100 }), true); 144 | verify(rect, forkRect({ y: 10, height: 100 }), true); 145 | verify( 146 | rect, 147 | forkRect({ 148 | x: 10, 149 | y: 10, 150 | width: 100, 151 | height: 100, 152 | }), 153 | true 154 | ); 155 | }); 156 | 157 | test("should support partially overlapping rects", () => { 158 | const cases: Partial[] = [{ x: 0 }, { y: 0 }]; 159 | 160 | cases.forEach((partial) => { 161 | verify(forkRect(partial), rect, true); 162 | }); 163 | }); 164 | 165 | test("should support non-overlapping rects", () => { 166 | const cases: Partial[] = [ 167 | { x: 100 }, 168 | { x: -100 }, 169 | { y: 100 }, 170 | { y: -100 }, 171 | { x: -100, y: -100 }, 172 | { x: 100, y: 100 }, 173 | 174 | // Strict mode only 175 | { x: -25 }, 176 | { x: 75 }, 177 | { y: -25 }, 178 | { y: 75 }, 179 | { x: -25, y: -25 }, 180 | { x: 75, y: 75 }, 181 | ]; 182 | 183 | cases.forEach((partial) => { 184 | verify(forkRect(partial), rect, false); 185 | }); 186 | }); 187 | 188 | test("should support all negative coordinates", () => { 189 | expect( 190 | intersects( 191 | { x: -100, y: -100, width: 50, height: 50 }, 192 | { x: -110, y: -90, width: 50, height: 50 }, 193 | true 194 | ) 195 | ).toBe(true); 196 | }); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/rects/intersects.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle } from "./types"; 2 | 3 | export function intersects( 4 | rectOne: Rectangle, 5 | rectTwo: Rectangle, 6 | strict: boolean 7 | ): boolean { 8 | if (strict) { 9 | return ( 10 | rectOne.x < rectTwo.x + rectTwo.width && 11 | rectOne.x + rectOne.width > rectTwo.x && 12 | rectOne.y < rectTwo.y + rectTwo.height && 13 | rectOne.y + rectOne.height > rectTwo.y 14 | ); 15 | } else { 16 | return ( 17 | rectOne.x <= rectTwo.x + rectTwo.width && 18 | rectOne.x + rectOne.width >= rectTwo.x && 19 | rectOne.y <= rectTwo.y + rectTwo.height && 20 | rectOne.y + rectOne.height >= rectTwo.y 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/rects/types.ts: -------------------------------------------------------------------------------- 1 | export interface Rectangle { 2 | x: number; 3 | y: number; 4 | width: number; 5 | height: number; 6 | } 7 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/resizePanel.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { resizePanel } from "./resizePanel"; 3 | 4 | describe("resizePanel", () => { 5 | test("should not collapse (or expand) until a panel size dips below the halfway point between min size and collapsed size", () => { 6 | expect( 7 | resizePanel({ 8 | panelConstraints: [ 9 | { 10 | collapsible: true, 11 | collapsedSize: 10, 12 | minSize: 20, 13 | }, 14 | ], 15 | panelIndex: 0, 16 | size: 15, 17 | }) 18 | ).toBe(20); 19 | 20 | expect( 21 | resizePanel({ 22 | panelConstraints: [ 23 | { 24 | collapsible: true, 25 | collapsedSize: 10, 26 | minSize: 20, 27 | }, 28 | ], 29 | panelIndex: 0, 30 | size: 14, 31 | }) 32 | ).toBe(10); 33 | 34 | expect( 35 | resizePanel({ 36 | panelConstraints: [ 37 | { 38 | collapsible: true, 39 | minSize: 20, 40 | }, 41 | ], 42 | panelIndex: 0, 43 | size: 10, 44 | }) 45 | ).toBe(20); 46 | 47 | expect( 48 | resizePanel({ 49 | panelConstraints: [ 50 | { 51 | collapsible: true, 52 | minSize: 20, 53 | }, 54 | ], 55 | panelIndex: 0, 56 | size: 9, 57 | }) 58 | ).toBe(0); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/resizePanel.ts: -------------------------------------------------------------------------------- 1 | import { PanelConstraints } from "../Panel"; 2 | import { PRECISION } from "../constants"; 3 | import { assert } from "./assert"; 4 | import { fuzzyCompareNumbers } from "./numbers/fuzzyCompareNumbers"; 5 | 6 | // Panel size must be in percentages; pixel values should be pre-converted 7 | export function resizePanel({ 8 | panelConstraints: panelConstraintsArray, 9 | panelIndex, 10 | size, 11 | }: { 12 | panelConstraints: PanelConstraints[]; 13 | panelIndex: number; 14 | size: number; 15 | }) { 16 | const panelConstraints = panelConstraintsArray[panelIndex]; 17 | assert( 18 | panelConstraints != null, 19 | `Panel constraints not found for index ${panelIndex}` 20 | ); 21 | 22 | let { 23 | collapsedSize = 0, 24 | collapsible, 25 | maxSize = 100, 26 | minSize = 0, 27 | } = panelConstraints; 28 | 29 | if (fuzzyCompareNumbers(size, minSize) < 0) { 30 | if (collapsible) { 31 | // Collapsible panels should snap closed or open only once they cross the halfway point between collapsed and min size. 32 | const halfwayPoint = (collapsedSize + minSize) / 2; 33 | if (fuzzyCompareNumbers(size, halfwayPoint) < 0) { 34 | size = collapsedSize; 35 | } else { 36 | size = minSize; 37 | } 38 | } else { 39 | size = minSize; 40 | } 41 | } 42 | 43 | size = Math.min(maxSize, size); 44 | size = parseFloat(size.toFixed(PRECISION)); 45 | 46 | return size; 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/serialization.ts: -------------------------------------------------------------------------------- 1 | import { PanelData } from "../Panel"; 2 | import { PanelGroupStorage } from "../PanelGroup"; 3 | 4 | export type PanelConfigurationState = { 5 | expandToSizes: { 6 | [panelId: string]: number; 7 | }; 8 | layout: number[]; 9 | }; 10 | 11 | export type SerializedPanelGroupState = { 12 | [panelIds: string]: PanelConfigurationState; 13 | }; 14 | 15 | function getPanelGroupKey(autoSaveId: string): string { 16 | return `react-resizable-panels:${autoSaveId}`; 17 | } 18 | 19 | // Note that Panel ids might be user-provided (stable) or useId generated (non-deterministic) 20 | // so they should not be used as part of the serialization key. 21 | // Using the min/max size attributes should work well enough as a backup. 22 | // Pre-sorting by minSize allows remembering layouts even if panels are re-ordered/dragged. 23 | function getPanelKey(panels: PanelData[]): string { 24 | return panels 25 | .map((panel) => { 26 | const { constraints, id, idIsFromProps, order } = panel; 27 | if (idIsFromProps) { 28 | return id; 29 | } else { 30 | return order 31 | ? `${order}:${JSON.stringify(constraints)}` 32 | : JSON.stringify(constraints); 33 | } 34 | }) 35 | .sort((a, b) => a.localeCompare(b)) 36 | .join(","); 37 | } 38 | 39 | function loadSerializedPanelGroupState( 40 | autoSaveId: string, 41 | storage: PanelGroupStorage 42 | ): SerializedPanelGroupState | null { 43 | try { 44 | const panelGroupKey = getPanelGroupKey(autoSaveId); 45 | const serialized = storage.getItem(panelGroupKey); 46 | if (serialized) { 47 | const parsed = JSON.parse(serialized); 48 | if (typeof parsed === "object" && parsed != null) { 49 | return parsed as SerializedPanelGroupState; 50 | } 51 | } 52 | } catch (error) {} 53 | 54 | return null; 55 | } 56 | 57 | export function loadPanelGroupState( 58 | autoSaveId: string, 59 | panels: PanelData[], 60 | storage: PanelGroupStorage 61 | ): PanelConfigurationState | null { 62 | const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {}; 63 | const panelKey = getPanelKey(panels); 64 | return state[panelKey] ?? null; 65 | } 66 | 67 | export function savePanelGroupState( 68 | autoSaveId: string, 69 | panels: PanelData[], 70 | panelSizesBeforeCollapse: Map, 71 | sizes: number[], 72 | storage: PanelGroupStorage 73 | ): void { 74 | const panelGroupKey = getPanelGroupKey(autoSaveId); 75 | const panelKey = getPanelKey(panels); 76 | const state = loadSerializedPanelGroupState(autoSaveId, storage) ?? {}; 77 | state[panelKey] = { 78 | expandToSizes: Object.fromEntries(panelSizesBeforeCollapse.entries()), 79 | layout: sizes, 80 | }; 81 | 82 | try { 83 | storage.setItem(panelGroupKey, JSON.stringify(state)); 84 | } catch (error) { 85 | console.error(error); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { DATA_ATTRIBUTES } from "../constants"; 3 | import { assert } from "./assert"; 4 | import util from "node:util"; 5 | 6 | export function dispatchPointerEvent(type: string, target: HTMLElement) { 7 | const rect = target.getBoundingClientRect(); 8 | 9 | const clientX = rect.left + rect.width / 2; 10 | const clientY = rect.top + rect.height / 2; 11 | 12 | const event = new MouseEvent(type, { 13 | bubbles: true, 14 | clientX, 15 | clientY, 16 | buttons: 1, 17 | }); 18 | Object.defineProperties(event, { 19 | pageX: { 20 | get() { 21 | return clientX; 22 | }, 23 | }, 24 | pageY: { 25 | get() { 26 | return clientY; 27 | }, 28 | }, 29 | isPrimary: { 30 | value: true, 31 | }, 32 | }); 33 | 34 | target.dispatchEvent(event); 35 | } 36 | 37 | export function expectToBeCloseToArray( 38 | actualNumbers: number[], 39 | expectedNumbers: number[] 40 | ) { 41 | expect(actualNumbers.length).toBe(expectedNumbers.length); 42 | 43 | try { 44 | actualNumbers.forEach((actualNumber, index) => { 45 | const expectedNumber = expectedNumbers[index]; 46 | assert(expectedNumber != null, `Expected number not found`); 47 | 48 | expect(actualNumber).toBeCloseTo(expectedNumber, 1); 49 | }); 50 | } catch (error) { 51 | expect(actualNumbers).toEqual(expectedNumbers); 52 | } 53 | } 54 | 55 | export function mockBoundingClientRect( 56 | element: HTMLElement, 57 | rect: { 58 | height: number; 59 | width: number; 60 | x: number; 61 | y: number; 62 | } 63 | ) { 64 | const { height, width, x, y } = rect; 65 | 66 | Object.defineProperty(element, "getBoundingClientRect", { 67 | configurable: true, 68 | value: () => 69 | ({ 70 | bottom: y + height, 71 | height, 72 | left: x, 73 | right: x + width, 74 | toJSON() { 75 | return ""; 76 | }, 77 | top: y, 78 | width, 79 | x, 80 | y, 81 | }) satisfies DOMRect, 82 | }); 83 | } 84 | 85 | export function mockPanelGroupOffsetWidthAndHeight( 86 | mockWidth = 1_000, 87 | mockHeight = 1_000 88 | ) { 89 | const offsetHeightPropertyDescriptor = Object.getOwnPropertyDescriptor( 90 | HTMLElement.prototype, 91 | "offsetHeight" 92 | ); 93 | 94 | const offsetWidthPropertyDescriptor = Object.getOwnPropertyDescriptor( 95 | HTMLElement.prototype, 96 | "offsetWidth" 97 | ); 98 | 99 | Object.defineProperty(HTMLElement.prototype, "offsetHeight", { 100 | configurable: true, 101 | get: function () { 102 | if (this.hasAttribute(DATA_ATTRIBUTES.resizeHandle)) { 103 | return 0; 104 | } else if (this.hasAttribute(DATA_ATTRIBUTES.group)) { 105 | return mockHeight; 106 | } 107 | }, 108 | }); 109 | 110 | Object.defineProperty(HTMLElement.prototype, "offsetWidth", { 111 | configurable: true, 112 | get: function () { 113 | if (this.hasAttribute(DATA_ATTRIBUTES.resizeHandle)) { 114 | return 0; 115 | } else if (this.hasAttribute(DATA_ATTRIBUTES.group)) { 116 | return mockWidth; 117 | } 118 | }, 119 | }); 120 | 121 | return function uninstallMocks() { 122 | if (offsetHeightPropertyDescriptor) { 123 | Object.defineProperty( 124 | HTMLElement.prototype, 125 | "offsetHeight", 126 | offsetHeightPropertyDescriptor 127 | ); 128 | } 129 | 130 | if (offsetWidthPropertyDescriptor) { 131 | Object.defineProperty( 132 | HTMLElement.prototype, 133 | "offsetWidth", 134 | offsetWidthPropertyDescriptor 135 | ); 136 | } 137 | }; 138 | } 139 | 140 | export function verifyAttribute( 141 | element: HTMLElement, 142 | attributeName: string, 143 | expectedValue: string | null 144 | ) { 145 | const actualValue = element.getAttribute(attributeName); 146 | expect(actualValue).toBe(expectedValue); 147 | } 148 | 149 | export function verifyExpandedPanelGroupLayout( 150 | actualLayout: number[], 151 | expectedLayout: number[] 152 | ) { 153 | expect(actualLayout).toEqual(expectedLayout); 154 | } 155 | 156 | export function verifyExpectedWarnings( 157 | callback: Function, 158 | ...expectedMessages: string[] 159 | ) { 160 | const consoleSpy = (format: any, ...args: any[]) => { 161 | const message = util.format(format, ...args); 162 | 163 | for (let index = 0; index < expectedMessages.length; index++) { 164 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 165 | const expectedMessage = expectedMessages[index]!; 166 | if (message.includes(expectedMessage)) { 167 | expectedMessages.splice(index, 1); 168 | return; 169 | } 170 | } 171 | 172 | if (expectedMessages.length === 0) { 173 | throw new Error(`Unexpected message recorded:\n\n${message}`); 174 | } 175 | }; 176 | 177 | const originalError = console.error; 178 | const originalWarn = console.warn; 179 | 180 | console.error = consoleSpy; 181 | console.warn = consoleSpy; 182 | 183 | let caughtError; 184 | let didCatch = false; 185 | try { 186 | callback(); 187 | } catch (error) { 188 | caughtError = error; 189 | didCatch = true; 190 | } finally { 191 | console.error = originalError; 192 | console.warn = originalWarn; 193 | 194 | if (didCatch) { 195 | throw caughtError; 196 | } 197 | 198 | // Any remaining messages indicate a failed expectations. 199 | if (expectedMessages.length > 0) { 200 | throw Error( 201 | `Expected message(s) not recorded:\n\n${expectedMessages.join("\n")}` 202 | ); 203 | } 204 | 205 | return { pass: true }; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/validatePanelConstraints.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from "vitest"; 2 | import { verifyExpectedWarnings } from "./test-utils"; 3 | import { validatePanelConstraints } from "./validatePanelConstraints"; 4 | 5 | describe("validatePanelConstraints", () => { 6 | test("should not warn if there are no validation errors", () => { 7 | verifyExpectedWarnings(() => { 8 | validatePanelConstraints({ 9 | panelConstraints: [{}], 10 | panelIndex: 0, 11 | panelId: "test", 12 | }); 13 | }); 14 | }); 15 | 16 | test("should warn about conflicting min/max sizes", () => { 17 | verifyExpectedWarnings(() => { 18 | validatePanelConstraints({ 19 | panelConstraints: [ 20 | { 21 | maxSize: 5, 22 | minSize: 10, 23 | }, 24 | ], 25 | panelIndex: 0, 26 | panelId: "test", 27 | }); 28 | }, "min size (10%) should not be greater than max size (5%)"); 29 | }); 30 | 31 | test("should warn about conflicting collapsed and min sizes", () => { 32 | verifyExpectedWarnings(() => { 33 | validatePanelConstraints({ 34 | panelConstraints: [ 35 | { 36 | collapsedSize: 15, 37 | minSize: 10, 38 | }, 39 | ], 40 | panelIndex: 0, 41 | panelId: "test", 42 | }); 43 | }, "collapsed size should not be greater than min size"); 44 | }); 45 | 46 | test("should warn about conflicting default and min/max sizes", () => { 47 | verifyExpectedWarnings(() => { 48 | validatePanelConstraints({ 49 | panelConstraints: [ 50 | { 51 | defaultSize: -1, 52 | minSize: 10, 53 | }, 54 | ], 55 | panelIndex: 0, 56 | panelId: "test", 57 | }); 58 | }, "default size should not be less than 0"); 59 | 60 | verifyExpectedWarnings(() => { 61 | validatePanelConstraints({ 62 | panelConstraints: [ 63 | { 64 | defaultSize: 5, 65 | minSize: 10, 66 | }, 67 | ], 68 | panelIndex: 0, 69 | panelId: "test", 70 | }); 71 | }, "default size should not be less than min size"); 72 | 73 | verifyExpectedWarnings(() => { 74 | validatePanelConstraints({ 75 | panelConstraints: [ 76 | { 77 | collapsedSize: 5, 78 | collapsible: true, 79 | defaultSize: 5, 80 | minSize: 10, 81 | }, 82 | ], 83 | panelIndex: 0, 84 | panelId: "test", 85 | }); 86 | }); 87 | 88 | verifyExpectedWarnings(() => { 89 | validatePanelConstraints({ 90 | panelConstraints: [ 91 | { 92 | collapsedSize: 7, 93 | collapsible: true, 94 | defaultSize: 5, 95 | minSize: 10, 96 | }, 97 | ], 98 | panelIndex: 0, 99 | panelId: "test", 100 | }); 101 | }, "default size should not be less than min size"); 102 | 103 | verifyExpectedWarnings(() => { 104 | validatePanelConstraints({ 105 | panelConstraints: [ 106 | { 107 | collapsedSize: 5, 108 | collapsible: false, 109 | defaultSize: 5, 110 | minSize: 10, 111 | }, 112 | ], 113 | panelIndex: 0, 114 | panelId: "test", 115 | }); 116 | }, "default size should not be less than min size"); 117 | 118 | verifyExpectedWarnings(() => { 119 | validatePanelConstraints({ 120 | panelConstraints: [ 121 | { 122 | defaultSize: 101, 123 | maxSize: 10, 124 | }, 125 | ], 126 | panelIndex: 0, 127 | panelId: "test", 128 | }); 129 | }, "default size should not be greater than 100"); 130 | 131 | verifyExpectedWarnings(() => { 132 | validatePanelConstraints({ 133 | panelConstraints: [ 134 | { 135 | defaultSize: 15, 136 | maxSize: 10, 137 | }, 138 | ], 139 | panelIndex: 0, 140 | panelId: "test", 141 | }); 142 | }, "default size should not be greater than max size"); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/validatePanelConstraints.ts: -------------------------------------------------------------------------------- 1 | import { isDevelopment } from "#is-development"; 2 | import { PanelConstraints } from "../Panel"; 3 | import { assert } from "./assert"; 4 | 5 | export function validatePanelConstraints({ 6 | panelConstraints: panelConstraintsArray, 7 | panelId, 8 | panelIndex, 9 | }: { 10 | panelConstraints: PanelConstraints[]; 11 | panelId: string | undefined; 12 | panelIndex: number; 13 | }): boolean { 14 | if (isDevelopment) { 15 | const warnings = []; 16 | 17 | const panelConstraints = panelConstraintsArray[panelIndex]; 18 | assert( 19 | panelConstraints, 20 | `No panel constraints found for index ${panelIndex}` 21 | ); 22 | 23 | const { 24 | collapsedSize = 0, 25 | collapsible = false, 26 | defaultSize, 27 | maxSize = 100, 28 | minSize = 0, 29 | } = panelConstraints; 30 | 31 | if (minSize > maxSize) { 32 | warnings.push( 33 | `min size (${minSize}%) should not be greater than max size (${maxSize}%)` 34 | ); 35 | } 36 | 37 | if (defaultSize != null) { 38 | if (defaultSize < 0) { 39 | warnings.push("default size should not be less than 0"); 40 | } else if ( 41 | defaultSize < minSize && 42 | (!collapsible || defaultSize !== collapsedSize) 43 | ) { 44 | warnings.push("default size should not be less than min size"); 45 | } 46 | 47 | if (defaultSize > 100) { 48 | warnings.push("default size should not be greater than 100"); 49 | } else if (defaultSize > maxSize) { 50 | warnings.push("default size should not be greater than max size"); 51 | } 52 | } 53 | 54 | if (collapsedSize > minSize) { 55 | warnings.push("collapsed size should not be greater than min size"); 56 | } 57 | 58 | if (warnings.length > 0) { 59 | const name = panelId != null ? `Panel "${panelId}"` : "Panel"; 60 | console.warn( 61 | `${name} has an invalid configuration:\n\n${warnings.join("\n")}` 62 | ); 63 | 64 | return false; 65 | } 66 | } 67 | 68 | return true; 69 | } 70 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/validatePanelGroupLayout.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { verifyExpectedWarnings } from "./test-utils"; 3 | import { validatePanelGroupLayout } from "./validatePanelGroupLayout"; 4 | 5 | describe("validatePanelGroupLayout", () => { 6 | test("should accept requested layout if there are no constraints provided", () => { 7 | expect( 8 | validatePanelGroupLayout({ 9 | layout: [10, 60, 30], 10 | panelConstraints: [{}, {}, {}], 11 | }) 12 | ).toEqual([10, 60, 30]); 13 | }); 14 | 15 | test("should normalize layouts that do not total 100%", () => { 16 | let layout; 17 | verifyExpectedWarnings(() => { 18 | layout = validatePanelGroupLayout({ 19 | layout: [10, 20, 20], 20 | panelConstraints: [{}, {}, {}], 21 | }); 22 | }, "Invalid layout total size"); 23 | expect(layout).toEqual([20, 40, 40]); 24 | 25 | verifyExpectedWarnings(() => { 26 | layout = validatePanelGroupLayout({ 27 | layout: [50, 100, 50], 28 | panelConstraints: [{}, {}, {}], 29 | }); 30 | }, "Invalid layout total size"); 31 | expect(layout).toEqual([25, 50, 25]); 32 | }); 33 | 34 | test("should reject layouts that do not match the number of panels", () => { 35 | expect(() => 36 | validatePanelGroupLayout({ 37 | layout: [10, 20, 30], 38 | panelConstraints: [{}, {}], 39 | }) 40 | ).toThrow("Invalid 2 panel layout"); 41 | 42 | expect(() => 43 | validatePanelGroupLayout({ 44 | layout: [50, 50], 45 | panelConstraints: [{}, {}, {}], 46 | }) 47 | ).toThrow("Invalid 3 panel layout"); 48 | }); 49 | 50 | describe("minimum size constraints", () => { 51 | test("should adjust the layout to account for minimum percentage sizes", () => { 52 | expect( 53 | validatePanelGroupLayout({ 54 | layout: [25, 75], 55 | panelConstraints: [ 56 | { 57 | minSize: 35, 58 | }, 59 | {}, 60 | ], 61 | }) 62 | ).toEqual([35, 65]); 63 | }); 64 | 65 | test("should account for multiple panels with minimum size constraints", () => { 66 | expect( 67 | validatePanelGroupLayout({ 68 | layout: [20, 60, 20], 69 | panelConstraints: [ 70 | { 71 | minSize: 25, 72 | }, 73 | {}, 74 | { 75 | minSize: 25, 76 | }, 77 | ], 78 | }) 79 | ).toEqual([25, 50, 25]); 80 | }); 81 | }); 82 | 83 | describe("maximum size constraints", () => { 84 | test("should adjust the layout to account for maximum percentage sizes", () => { 85 | expect( 86 | validatePanelGroupLayout({ 87 | layout: [25, 75], 88 | panelConstraints: [{}, { maxSize: 65 }], 89 | }) 90 | ).toEqual([35, 65]); 91 | }); 92 | 93 | test("should account for multiple panels with maximum size constraints", () => { 94 | expect( 95 | validatePanelGroupLayout({ 96 | layout: [20, 60, 20], 97 | panelConstraints: [ 98 | { 99 | maxSize: 15, 100 | }, 101 | { maxSize: 50 }, 102 | {}, 103 | ], 104 | }) 105 | ).toEqual([15, 50, 35]); 106 | }); 107 | }); 108 | 109 | describe("collapsible panels", () => { 110 | test("should not collapse a panel that's at or above the minimum size", () => { 111 | expect( 112 | validatePanelGroupLayout({ 113 | layout: [25, 75], 114 | panelConstraints: [{ collapsible: true, minSize: 25 }, {}], 115 | }) 116 | ).toEqual([25, 75]); 117 | }); 118 | 119 | test("should collapse a panel once it drops below the halfway point between collapsed and minimum percentage sizes", () => { 120 | expect( 121 | validatePanelGroupLayout({ 122 | layout: [15, 85], 123 | panelConstraints: [ 124 | { 125 | collapsible: true, 126 | collapsedSize: 10, 127 | minSize: 20, 128 | }, 129 | {}, 130 | ], 131 | }) 132 | ).toEqual([20, 80]); 133 | 134 | expect( 135 | validatePanelGroupLayout({ 136 | layout: [14, 86], 137 | panelConstraints: [ 138 | { 139 | collapsible: true, 140 | collapsedSize: 10, 141 | minSize: 20, 142 | }, 143 | {}, 144 | ], 145 | }) 146 | ).toEqual([10, 90]); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/utils/validatePanelGroupLayout.ts: -------------------------------------------------------------------------------- 1 | import { isDevelopment } from "#is-development"; 2 | import { PanelConstraints } from "../Panel"; 3 | import { assert } from "./assert"; 4 | import { fuzzyNumbersEqual } from "./numbers/fuzzyNumbersEqual"; 5 | import { resizePanel } from "./resizePanel"; 6 | 7 | // All units must be in percentages; pixel values should be pre-converted 8 | export function validatePanelGroupLayout({ 9 | layout: prevLayout, 10 | panelConstraints, 11 | }: { 12 | layout: number[]; 13 | panelConstraints: PanelConstraints[]; 14 | }): number[] { 15 | const nextLayout = [...prevLayout]; 16 | const nextLayoutTotalSize = nextLayout.reduce( 17 | (accumulated, current) => accumulated + current, 18 | 0 19 | ); 20 | 21 | // Validate layout expectations 22 | if (nextLayout.length !== panelConstraints.length) { 23 | throw Error( 24 | `Invalid ${panelConstraints.length} panel layout: ${nextLayout 25 | .map((size) => `${size}%`) 26 | .join(", ")}` 27 | ); 28 | } else if ( 29 | !fuzzyNumbersEqual(nextLayoutTotalSize, 100) && 30 | nextLayout.length > 0 31 | ) { 32 | // This is not ideal so we should warn about it, but it may be recoverable in some cases 33 | // (especially if the amount is small) 34 | if (isDevelopment) { 35 | console.warn( 36 | `WARNING: Invalid layout total size: ${nextLayout 37 | .map((size) => `${size}%`) 38 | .join(", ")}. Layout normalization will be applied.` 39 | ); 40 | } 41 | for (let index = 0; index < panelConstraints.length; index++) { 42 | const unsafeSize = nextLayout[index]; 43 | assert(unsafeSize != null, `No layout data found for index ${index}`); 44 | const safeSize = (100 / nextLayoutTotalSize) * unsafeSize; 45 | nextLayout[index] = safeSize; 46 | } 47 | } 48 | 49 | let remainingSize = 0; 50 | 51 | // First pass: Validate the proposed layout given each panel's constraints 52 | for (let index = 0; index < panelConstraints.length; index++) { 53 | const unsafeSize = nextLayout[index]; 54 | assert(unsafeSize != null, `No layout data found for index ${index}`); 55 | 56 | const safeSize = resizePanel({ 57 | panelConstraints, 58 | panelIndex: index, 59 | size: unsafeSize, 60 | }); 61 | 62 | if (unsafeSize != safeSize) { 63 | remainingSize += unsafeSize - safeSize; 64 | 65 | nextLayout[index] = safeSize; 66 | } 67 | } 68 | 69 | // If there is additional, left over space, assign it to any panel(s) that permits it 70 | // (It's not worth taking multiple additional passes to evenly distribute) 71 | if (!fuzzyNumbersEqual(remainingSize, 0)) { 72 | for (let index = 0; index < panelConstraints.length; index++) { 73 | const prevSize = nextLayout[index]; 74 | assert(prevSize != null, `No layout data found for index ${index}`); 75 | const unsafeSize = prevSize + remainingSize; 76 | const safeSize = resizePanel({ 77 | panelConstraints, 78 | panelIndex: index, 79 | size: unsafeSize, 80 | }); 81 | 82 | if (prevSize !== safeSize) { 83 | remainingSize -= safeSize - prevSize; 84 | nextLayout[index] = safeSize; 85 | 86 | // Once we've used up the remainder, bail 87 | if (fuzzyNumbersEqual(remainingSize, 0)) { 88 | break; 89 | } 90 | } 91 | } 92 | } 93 | 94 | return nextLayout; 95 | } 96 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/src/vendor/stacking-order.ts: -------------------------------------------------------------------------------- 1 | // Forked from NPM stacking-order@2.0.0 2 | // Background at https://github.com/Rich-Harris/stacking-order/issues/3 3 | // Background at https://github.com/Rich-Harris/stacking-order/issues/6 4 | 5 | import { assert } from ".."; 6 | 7 | /** 8 | * Determine which of two nodes appears in front of the other — 9 | * if `a` is in front, returns 1, otherwise returns -1 10 | * @param {HTMLElement | SVGElement} a 11 | * @param {HTMLElement | SVGElement} b 12 | */ 13 | export function compare(a: HTMLElement | SVGElement, b: HTMLElement | SVGElement): number { 14 | if (a === b) throw new Error("Cannot compare node with itself"); 15 | 16 | const ancestors = { 17 | a: get_ancestors(a), 18 | b: get_ancestors(b), 19 | }; 20 | 21 | let common_ancestor; 22 | 23 | // remove shared ancestors 24 | while (ancestors.a.at(-1) === ancestors.b.at(-1)) { 25 | a = ancestors.a.pop() as HTMLElement; 26 | b = ancestors.b.pop() as HTMLElement; 27 | 28 | common_ancestor = a; 29 | } 30 | 31 | assert( 32 | common_ancestor, 33 | "Stacking order can only be calculated for elements with a common ancestor" 34 | ); 35 | 36 | const z_indexes = { 37 | a: get_z_index(find_stacking_context(ancestors.a)), 38 | b: get_z_index(find_stacking_context(ancestors.b)), 39 | }; 40 | 41 | if (z_indexes.a === z_indexes.b) { 42 | const children = common_ancestor.childNodes; 43 | 44 | const furthest_ancestors = { 45 | a: ancestors.a.at(-1), 46 | b: ancestors.b.at(-1), 47 | }; 48 | 49 | let i = children.length; 50 | while (i--) { 51 | const child = children[i]; 52 | if (child === furthest_ancestors.a) return 1; 53 | if (child === furthest_ancestors.b) return -1; 54 | } 55 | } 56 | 57 | return Math.sign(z_indexes.a - z_indexes.b); 58 | } 59 | 60 | const props = 61 | /\b(?:position|zIndex|opacity|transform|webkitTransform|mixBlendMode|filter|webkitFilter|isolation)\b/; 62 | 63 | /** @param {HTMLElement | SVGElement} node */ 64 | function is_flex_item(node: HTMLElement | SVGElement) { 65 | // @ts-ignore 66 | const display = getComputedStyle(get_parent(node) ?? node).display; 67 | return display === "flex" || display === "inline-flex"; 68 | } 69 | 70 | /** @param {HTMLElement | SVGElement} node */ 71 | function creates_stacking_context(node: HTMLElement | SVGElement) { 72 | const style = getComputedStyle(node); 73 | 74 | // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context 75 | if (style.position === "fixed") return true; 76 | // Forked to fix upstream bug https://github.com/Rich-Harris/stacking-order/issues/3 77 | // if ( 78 | // (style.zIndex !== "auto" && style.position !== "static") || 79 | // is_flex_item(node) 80 | // ) 81 | if ( 82 | style.zIndex !== "auto" && 83 | (style.position !== "static" || is_flex_item(node)) 84 | ) 85 | return true; 86 | if (+style.opacity < 1) return true; 87 | if ("transform" in style && style.transform !== "none") return true; 88 | if ("webkitTransform" in style && style.webkitTransform !== "none") 89 | return true; 90 | if ("mixBlendMode" in style && style.mixBlendMode !== "normal") return true; 91 | if ("filter" in style && style.filter !== "none") return true; 92 | if ("webkitFilter" in style && style.webkitFilter !== "none") return true; 93 | if ("isolation" in style && style.isolation === "isolate") return true; 94 | if (props.test(style.willChange)) return true; 95 | // @ts-expect-error 96 | if (style.webkitOverflowScrolling === "touch") return true; 97 | 98 | return false; 99 | } 100 | 101 | /** @param {(HTMLElement| SVGElement)[]} nodes */ 102 | function find_stacking_context(nodes: (HTMLElement | SVGElement)[]) { 103 | let i = nodes.length; 104 | 105 | while (i--) { 106 | const node = nodes[i]; 107 | assert(node, "Missing node"); 108 | if (creates_stacking_context(node)) return node; 109 | } 110 | 111 | return null; 112 | } 113 | 114 | /** @param {HTMLElement | SVGElement} node */ 115 | function get_z_index(node: HTMLElement | SVGElement | null) { 116 | return (node && Number(getComputedStyle(node).zIndex)) || 0; 117 | } 118 | 119 | /** @param {HTMLElement} node */ 120 | function get_ancestors(node: HTMLElement | SVGElement | null) { 121 | const ancestors = []; 122 | 123 | while (node) { 124 | ancestors.push(node); 125 | // @ts-ignore 126 | node = get_parent(node); 127 | } 128 | 129 | return ancestors; // [ node, ... , , document ] 130 | } 131 | 132 | /** @param {HTMLElement} node */ 133 | function get_parent(node: HTMLElement) { 134 | const { parentNode } = node; 135 | if (parentNode && parentNode instanceof ShadowRoot) { 136 | return parentNode.host 137 | } 138 | return parentNode; 139 | } 140 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, defaultExclude } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [...defaultExclude, "**/*.node.{test,spec}.?(c|m)[jt]s?(x)"], 6 | environment: "jsdom", // Use for browser-like tests 7 | coverage: { 8 | reporter: ["text", "json", "html"], // Optional: Add coverage reports 9 | }, 10 | }, 11 | resolve: { 12 | conditions: ["development", "browser"], 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react-resizable-panels/vitest.node.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["**/*.node.{test,spec}.?(c|m)[jt]s?(x)"], 6 | coverage: { 7 | reporter: ["text", "json", "html"], // Optional: Add coverage reports 8 | }, 9 | }, 10 | resolve: { 11 | conditions: ["development"], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**' -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "jsx": "react-jsx", 5 | "lib": ["ES2015", "DOM"], 6 | "module": "es2020", 7 | "moduleResolution": "bundler", 8 | "noImplicitAny": true, 9 | "noUncheckedIndexedAccess": true, 10 | "strict": true, 11 | "target": "ESNext", 12 | "typeRoots": ["node_modules/@types"], 13 | "types": ["node"], 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["declaration.d.ts", "packages/**/*.ts", "packages/**/*.tsx"], 17 | } 18 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }] 3 | } 4 | --------------------------------------------------------------------------------