├── docs ├── content │ ├── 1.getting-started │ │ ├── .navigation.yml │ │ ├── 1.installation.md │ │ ├── 3.contributing.md │ │ └── 2.usage.md │ └── index.md ├── public │ └── favicon.ico ├── app.config.ts ├── plugins │ └── split-panel.ts ├── tsconfig.json ├── nuxt.config.ts ├── components │ └── example │ │ ├── ExampleBasic.vue │ │ ├── ExampleSnap.vue │ │ ├── ExamplePrimary.vue │ │ ├── ExampleVertical.vue │ │ ├── ExamplePx.vue │ │ ├── ExamplePercentage.vue │ │ ├── ExampleCollapsible.vue │ │ ├── ExampleTransitions.vue │ │ ├── ExampleDivider.vue │ │ └── ExampleNested.vue └── package.json ├── package.json ├── packages └── vue-split-panel │ ├── src │ ├── index.ts │ ├── utils │ │ ├── percentage-to-pixels.ts │ │ ├── pixels-to-percentage.ts │ │ ├── pixels-to-percentage.test.ts │ │ ├── percentage-to-pixels.test.ts │ │ ├── closest-number.test.ts │ │ └── closest-number.ts │ ├── composables │ │ ├── use-collapse.ts │ │ ├── use-resize.ts │ │ ├── use-grid-template.ts │ │ ├── use-keyboard.ts │ │ ├── use-resize.test.ts │ │ ├── use-pointer.ts │ │ ├── use-sizes.ts │ │ ├── use-grid-template.test.ts │ │ ├── use-sizes.test.ts │ │ ├── use-pointer.test.ts │ │ ├── use-keyboard.test.ts │ │ └── use-collapse.test.ts │ ├── types.ts │ └── SplitPanel.vue │ ├── playground │ ├── src │ │ ├── index.ts │ │ ├── App.vue │ │ └── style.css │ └── index.html │ ├── vite.config.ts │ ├── vitest.config.ts │ ├── tsdown.config.ts │ ├── tsconfig.json │ ├── tests │ ├── mounting.test.ts │ └── collapse.test.ts │ └── package.json ├── pnpm-workspace.yaml ├── .gitignore ├── eslint.config.js ├── .github └── workflows │ ├── conventional.yml │ ├── release.yml │ ├── unit-test.yml │ └── deploy-docs.yml ├── readme.md ├── license ├── .vscode └── settings.json └── attributions.md /docs/content/1.getting-started/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: Getting Started 2 | icon: false 3 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/directus/vue-split-panel/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | header: { 3 | title: 'Vue Split Panel', 4 | }, 5 | }); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "devDependencies": { 4 | "@directus/eslint-config": "0.1.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SplitPanel } from './SplitPanel.vue'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /packages/vue-split-panel/playground/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import './style.css'; 4 | 5 | createApp(App).mount('#app'); 6 | -------------------------------------------------------------------------------- /packages/vue-split-panel/vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | root: './playground', 6 | plugins: [vue()], 7 | }); 8 | -------------------------------------------------------------------------------- /docs/plugins/split-panel.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "nuxt/app"; 2 | import { SplitPanel } from '@directus/vue-split-panel'; 3 | 4 | export default defineNuxtPlugin((nuxtApp) => { 5 | nuxtApp.vueApp.component('SplitPanel', SplitPanel); 6 | }); 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - docs 4 | 5 | onlyBuiltDependencies: 6 | - '@parcel/watcher' 7 | - '@tailwindcss/oxide' 8 | - better-sqlite3 9 | - esbuild 10 | - sharp 11 | - unrs-resolver 12 | - vue-demi 13 | -------------------------------------------------------------------------------- /packages/vue-split-panel/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | test: { 7 | environment: 'happy-dom', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/vue-split-panel/tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown'; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ['./src/index.ts'], 6 | platform: 'browser', 7 | fromVite: true, 8 | dts: { 9 | vue: true, 10 | }, 11 | }, 12 | ]); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | .DS_Store 5 | .eslintcache 6 | coverage 7 | .output 8 | .data 9 | .nuxt 10 | .nitro 11 | .cache 12 | logs 13 | .fleet 14 | .idea 15 | .env 16 | .env.* 17 | !.env.example 18 | *.tgz 19 | .tmp 20 | .profile 21 | *.0x 22 | .history 23 | .wrangler 24 | 25 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./.nuxt/tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./.nuxt/tsconfig.server.json" 9 | }, 10 | { 11 | "path": "./.nuxt/tsconfig.shared.json" 12 | }, 13 | { 14 | "path": "./.nuxt/tsconfig.node.json" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | extends: ['docus'], 3 | modules: [], 4 | css: ['@directus/vue-split-panel/index.css'], 5 | compatibilityDate: '2025-07-18', 6 | components: [{ path: '~/components', global: true }], 7 | robots: { 8 | robotsTxt: false 9 | }, 10 | llms: { 11 | domain: 'https://directus.github.io/vue-split-panel', 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/vue-split-panel/playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vue Components Starter 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/utils/percentage-to-pixels.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a percentage value to pixels based on a given area. 3 | * @param area - The total area in pixels 4 | * @param percentage - The percentage value to convert (0-100) 5 | * @returns The pixel value corresponding to the percentage of the area 6 | */ 7 | export const percentageToPixels = (area: number, percentage: number) => area * (percentage / 100); 8 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/utils/pixels-to-percentage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a pixel value to a percentage relative to a given area. 3 | * @param area - The total area in pixels to calculate percentage against 4 | * @param pixels - The pixel value to convert to percentage 5 | * @returns The percentage value (0-100) that the pixels represent of the total area 6 | */ 7 | export const pixelsToPercentage = (area: number, pixels: number) => (pixels / area) * 100; 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import directusConfig from '@directus/eslint-config'; 2 | 3 | export default [ 4 | ...directusConfig, 5 | { 6 | ignores: ['.github/', 'crates/', '*.toml'], 7 | }, 8 | { 9 | rules: { 10 | 'unicorn/prefer-switch': 'off', 11 | 'unicorn/prefer-ternary': 'off', 12 | }, 13 | }, 14 | { 15 | files: ['**/*.test.ts', '**/*.spec.ts'], 16 | rules: { 17 | 'unicorn/consistent-function-scoping': 'off', 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /.github/workflows/conventional.yml: -------------------------------------------------------------------------------- 1 | name: PR Conventional Commit Validation 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, edited] 6 | 7 | jobs: 8 | validate-pr-title: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: PR Conventional Commit Validation 12 | uses: ytanikin/pr-conventional-commits@1.4.0 13 | with: 14 | add_label: false 15 | task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]' -------------------------------------------------------------------------------- /docs/components/example/ExampleBasic.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 4 | "scripts": { 5 | "dev": "nuxt dev", 6 | "build": "nuxt build" 7 | }, 8 | "dependencies": { 9 | "@directus/vue-split-panel": "workspace:*", 10 | "@nuxt/kit": "4.2.0", 11 | "@nuxt/ui-pro": "3.3.7", 12 | "better-sqlite3": "12.4.1", 13 | "docus": "4.2.0", 14 | "nuxt": "4.2.0", 15 | "tailwindcss": "4.1.16", 16 | "vue": "3.5.22" 17 | }, 18 | "devDependencies": { 19 | "@nuxtjs/mdc": "0.18.0", 20 | "unist-util-visit": "5.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/components/example/ExampleSnap.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | 24 | - run: npx changelogithub 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /docs/components/example/ExamplePrimary.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /packages/vue-split-panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["es2023", "DOM", "DOM.Iterable"], 5 | "moduleDetection": "force", 6 | "module": "preserve", 7 | "moduleResolution": "bundler", 8 | "resolveJsonModule": true, 9 | "types": ["node"], 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "declaration": true, 13 | "emitDeclarationOnly": true, 14 | "esModuleInterop": true, 15 | "isolatedModules": true, 16 | "verbatimModuleSyntax": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /docs/components/example/ExampleVertical.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /docs/components/example/ExamplePx.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | -------------------------------------------------------------------------------- /docs/components/example/ExamplePercentage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 22 | -------------------------------------------------------------------------------- /docs/components/example/ExampleCollapsible.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /docs/components/example/ExampleTransitions.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | -------------------------------------------------------------------------------- /docs/components/example/ExampleDivider.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 27 | -------------------------------------------------------------------------------- /docs/components/example/ExampleNested.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v4.1.0 18 | with: 19 | version: 10 20 | 21 | - name: Set node LTS 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | cache: pnpm 26 | 27 | - name: Install 28 | run: pnpm install 29 | 30 | - name: Build 31 | run: pnpm -F '!docs' run build 32 | 33 | - name: Lint 34 | run: pnpm -F '!docs' run lint 35 | 36 | - name: Typecheck 37 | run: pnpm -F '!docs' run typecheck 38 | 39 | - name: Test 40 | run: pnpm -F '!docs' run test 41 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Vue Split Panel 2 | 3 | Splitter component based on the usage syntax of Reka UI but using the internal rendering logic of Web Awesome's Split Panel. 4 | 5 | ## Installation 6 | 7 | ``` 8 | pnpm add @directus/vue-split-panel 9 | ``` 10 | 11 | ```vue 12 | 16 | 17 | 28 | ``` 29 | 30 | ## Usage 31 | 32 | Please refer to [the documentation](https://directus.github.io/vue-split-panel/) for the full usage guide. 33 | 34 | ## License 35 | 36 | MIT 37 | 38 | This project also incorporates third-party software licensed under the MIT 39 | License. See [`ATTRIBUTIONS.md`](https://github.com/directus/vue-split-panel/blob/main/attributions.md) for details. 40 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/1.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | description: Get started with Vue Split Panel. 4 | navigation: 5 | icon: i-lucide-download 6 | seo: 7 | description: Get started with Vue Split Panel. 8 | --- 9 | 10 | ::steps 11 | ### Install the package 12 | 13 | ```bash [Terminal] 14 | pnpm add @directus/vue-split-panel 15 | ``` 16 | 17 | ### Import the component's CSS 18 | 19 | Either in JS if your bundler allows: 20 | 21 | `import '@directus/vue-split-panel/index.css';` 22 | 23 | Or in CSS 24 | 25 | `@import '@directus/vue-split-panel/index.css';` 26 | 27 | ### Import and use the SplitPanel component 28 | 29 | ```vue [MyComponent.vue] 30 | 33 | 34 | 45 | ``` 46 | :: 47 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Rijk van Zanten 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/utils/pixels-to-percentage.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { pixelsToPercentage } from './pixels-to-percentage'; 3 | 4 | describe('pixelsToPercentage', () => { 5 | it('returns 0 when pixels is 0', () => { 6 | expect(pixelsToPercentage(800, 0)).toBe(0); 7 | }); 8 | 9 | it('returns 100 when pixels equals area', () => { 10 | expect(pixelsToPercentage(800, 800)).toBe(100); 11 | }); 12 | 13 | it('calculates common percentages correctly', () => { 14 | expect(pixelsToPercentage(800, 400)).toBe(50); 15 | expect(pixelsToPercentage(1000, 250)).toBe(25); 16 | }); 17 | 18 | it('handles decimal pixel values', () => { 19 | expect(pixelsToPercentage(200, 10.5)).toBe(5.25); 20 | expect(pixelsToPercentage(900, 300)).toBeCloseTo(33.333, 3); 21 | }); 22 | 23 | it('handles very large areas without precision issues (within tolerance)', () => { 24 | expect(pixelsToPercentage(1e9, 1e6)).toBeCloseTo(0.1, 6); 25 | }); 26 | 27 | it('does not clamp out-of-range values (negative or > 100%)', () => { 28 | expect(pixelsToPercentage(500, -50)).toBe(-10); 29 | expect(pixelsToPercentage(500, 750)).toBe(150); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silence the stylistic rules in your IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "@stylistic/*", "severity": "off", "fixable": true }, 15 | { "rule": "dprint/*", "severity": "off", "fixable": true }, 16 | { "rule": "*-spacing", "severity": "off", "fixable": true }, 17 | { "rule": "*-spaces", "severity": "off", "fixable": true }, 18 | { "rule": "*-order", "severity": "off", "fixable": true }, 19 | { "rule": "*-dangle", "severity": "off", "fixable": true }, 20 | { "rule": "*-newline", "severity": "off", "fixable": true }, 21 | { "rule": "*indent", "severity": "off", "fixable": true }, 22 | { "rule": "*quotes", "severity": "off", "fixable": true } 23 | ], 24 | 25 | // Enable eslint for all supported languages 26 | "eslint.validate": [ 27 | "javascript", 28 | "typescript", 29 | "vue", 30 | "html", 31 | "markdown", 32 | "json", 33 | "jsonc", 34 | "yaml", 35 | "toml", 36 | "gql", 37 | "graphql", 38 | "css", 39 | "scss" 40 | ] 41 | } -------------------------------------------------------------------------------- /packages/vue-split-panel/src/utils/percentage-to-pixels.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { percentageToPixels } from './percentage-to-pixels'; 3 | 4 | describe('percentageToPixels', () => { 5 | it('returns 0 when percentage is 0', () => { 6 | expect(percentageToPixels(800, 0)).toBe(0); 7 | }); 8 | 9 | it('returns full area when percentage is 100', () => { 10 | expect(percentageToPixels(800, 100)).toBe(800); 11 | }); 12 | 13 | it('calculates common percentages correctly', () => { 14 | expect(percentageToPixels(800, 50)).toBe(400); 15 | expect(percentageToPixels(1000, 25)).toBe(250); 16 | }); 17 | 18 | it('handles decimal percentages', () => { 19 | // 33.3333% of 900 is approximately 300 20 | expect(percentageToPixels(900, 33.3333)).toBeCloseTo(300, 3); 21 | // 10.5% of 200 is exactly 21 22 | expect(percentageToPixels(200, 10.5)).toBe(21); 23 | }); 24 | 25 | it('handles very large areas without precision issues (within tolerance)', () => { 26 | expect(percentageToPixels(1e9, 0.1)).toBeCloseTo(1e6, 0); 27 | }); 28 | 29 | it('does not clamp out-of-range percentages (documented as 0-100 but mathematically allowed)', () => { 30 | expect(percentageToPixels(500, -10)).toBe(-50); 31 | expect(percentageToPixels(500, 150)).toBe(750); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Install pnpm 15 | uses: pnpm/action-setup@v4.1.0 16 | with: 17 | version: 10 18 | 19 | - name: Set node LTS 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | cache: pnpm 24 | - run: pnpm install 25 | - run: pnpm -F '!docs' build 26 | - run: NUXT_APP_BASE_URL=/vue-split-panel/ pnpm -F docs build --preset github_pages 27 | env: 28 | NUXT_UI_PRO_LICENSE: ${{ secrets.NUXT_UI_PRO_LICENSE }} 29 | - name: Upload artifact 30 | uses: actions/upload-pages-artifact@v3 31 | with: 32 | path: ./docs/.output/public 33 | 34 | deploy: 35 | needs: build 36 | permissions: 37 | pages: write 38 | id-token: write 39 | 40 | environment: 41 | name: github-pages 42 | url: ${{ steps.deployment.outputs.page_url }} 43 | 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v4 49 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-collapse.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeRefOrGetter, Ref } from 'vue'; 2 | import { refAutoReset } from '@vueuse/core'; 3 | import { computed, toValue, watch } from 'vue'; 4 | 5 | export interface UseCollapseOptions { 6 | transitionDuration: MaybeRefOrGetter; 7 | collapsedSize: MaybeRefOrGetter; 8 | } 9 | 10 | export const useCollapse = (collapsed: Ref, sizePercentage: Ref, options: UseCollapseOptions) => { 11 | let expandedSizePercentage = 0; 12 | 13 | const collapseTransitionState = refAutoReset(null, toValue(options.transitionDuration)); 14 | 15 | const transitionDurationCss = computed(() => `${toValue(options.transitionDuration)}ms`); 16 | 17 | watch(collapsed, (newCollapsed) => { 18 | if (newCollapsed === true) { 19 | expandedSizePercentage = sizePercentage.value; 20 | sizePercentage.value = toValue(options.collapsedSize); 21 | collapseTransitionState.value = 'collapsing'; 22 | } 23 | else { 24 | sizePercentage.value = expandedSizePercentage; 25 | collapseTransitionState.value = 'expanding'; 26 | } 27 | }); 28 | 29 | const collapse = () => collapsed.value = true; 30 | const expand = () => collapsed.value = false; 31 | const toggle = (val: boolean) => collapsed.value = val; 32 | 33 | return { collapse, expand, toggle, collapseTransitionState, transitionDurationCss }; 34 | }; 35 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/3.contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | navigation: 4 | icon: i-lucide-folder-git-2 5 | --- 6 | 7 | Thank you for your interest in contributing to this project! 8 | 9 | `@directus/vue-split-panel` as the name suggests is maintained by your friends at [`directus`](https://github.com/directus/directus). 10 | 11 | PRs are very welcome. Please be mindful of breaking changes. If you're thinking of a change but aren't sure about it yet, feel free to open an issue to discuss first! 12 | 13 | ## Development 14 | 15 | ### Setup 16 | 17 | ::steps 18 | 19 | ### Clone this repo to your local machine 20 | 21 | `git clone git@github.com:directus/vue-split-panel.git` 22 | 23 | ### Install the npm dependencies 24 | 25 | `pnpm install` 26 | 27 | :: 28 | 29 | ## Debug using the playground 30 | 31 | This allows you to mess around with the component in isolation to quickly prototype and iterate on your change. 32 | 33 | `pnpm -F vue-split-panel playground` 34 | 35 | ## Writing docs 36 | 37 | The documentation is a [Docus](https://docus.dev)-based Nuxt application. You can run it in development with: 38 | 39 | `pnpm -F docs dev` 40 | 41 | Please make sure to update the docs if you're implementing new features, or changing the default behavior. 42 | 43 | ## Testing 44 | 45 | `vue-split-panel` relies on vitest for it's tests. You can run them with 46 | 47 | `pnpm -F vue-split-panel test` 48 | 49 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-resize.ts: -------------------------------------------------------------------------------- 1 | import type { ResizeObserverCallback } from '@vueuse/core'; 2 | import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; 3 | import type { Orientation, Primary } from '../types'; 4 | import { useResizeObserver } from '@vueuse/core'; 5 | import { onMounted, toValue, watch } from 'vue'; 6 | import { pixelsToPercentage } from '../utils/pixels-to-percentage'; 7 | 8 | export interface UseResizeOptions { 9 | sizePixels: ComputedRef; 10 | panelEl: MaybeRefOrGetter; 11 | orientation: MaybeRefOrGetter; 12 | primary: MaybeRefOrGetter; 13 | } 14 | 15 | export const useResize = (sizePercentage: Ref, options: UseResizeOptions) => { 16 | let cachedSizePixels = 0; 17 | 18 | onMounted(() => { 19 | cachedSizePixels = options.sizePixels.value; 20 | }); 21 | 22 | watch(options.sizePixels, (newPixels, oldPixels) => { 23 | if (newPixels === oldPixels) return; 24 | cachedSizePixels = newPixels; 25 | }); 26 | 27 | const onResize: ResizeObserverCallback = (entries) => { 28 | const entry = entries[0]; 29 | const { width, height } = entry.contentRect; 30 | const size = toValue(options.orientation) === 'horizontal' ? width : height; 31 | 32 | if (toValue(options.primary)) { 33 | sizePercentage.value = pixelsToPercentage(size, cachedSizePixels); 34 | } 35 | }; 36 | 37 | useResizeObserver(options.panelEl, onResize); 38 | 39 | return { onResize }; 40 | }; 41 | -------------------------------------------------------------------------------- /packages/vue-split-panel/playground/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 42 | 43 | 80 | -------------------------------------------------------------------------------- /packages/vue-split-panel/playground/src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | .card { 58 | padding: 2em; 59 | } 60 | 61 | #app { 62 | max-width: 1280px; 63 | margin: 0 auto; 64 | padding: 2rem; 65 | text-align: center; 66 | } 67 | 68 | @media (prefers-color-scheme: light) { 69 | :root { 70 | color: #213547; 71 | background-color: #ffffff; 72 | } 73 | a:hover { 74 | color: #747bff; 75 | } 76 | button { 77 | background-color: #f9f9f9; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/utils/closest-number.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { closestNumber } from './closest-number'; 3 | 4 | describe('closestNumber', () => { 5 | it('returns undefined for empty array', () => { 6 | expect(closestNumber([], 10)).toBeUndefined(); 7 | }); 8 | 9 | it('returns the only element for single-item array', () => { 10 | expect(closestNumber([5], 10)).toBe(5); 11 | }); 12 | 13 | it('finds the exact match when present', () => { 14 | expect(closestNumber([1, 5, 10, 15], 10)).toBe(10); 15 | }); 16 | 17 | it('returns closest lower value when tie broken by smaller number', () => { 18 | // distance to 9: |8-9|=1, |10-9|=1 => choose 8 19 | expect(closestNumber([8, 10], 9)).toBe(8); 20 | }); 21 | 22 | it('works with negative numbers', () => { 23 | expect(closestNumber([-10, -3, 2, 5], -4)).toBe(-3); 24 | }); 25 | 26 | it('handles large numbers', () => { 27 | expect(closestNumber([1e9, 1e12], 5e11)).toBe(1e9); // distances: 4.99e11 vs 5e11 28 | }); 29 | 30 | it('ignores NaN and Infinity values', () => { 31 | // Only finite numbers 5 and 20 considered => closest to 12 is 5 (distance 7 vs 8) 32 | expect(closestNumber([Number.NaN, 5, Infinity, -Infinity, 20], 12)).toBe(5); 33 | }); 34 | 35 | it('returns undefined if all values are non-finite', () => { 36 | expect(closestNumber([Number.NaN, Infinity, -Infinity], 3)).toBeUndefined(); 37 | }); 38 | 39 | it('handles target being negative infinity', () => { 40 | expect(closestNumber([-100, 0, 100], Number.NEGATIVE_INFINITY)).toBe(-100); 41 | }); 42 | 43 | it('handles target being positive infinity', () => { 44 | expect(closestNumber([-100, 0, 100], Number.POSITIVE_INFINITY)).toBe(100); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/vue-split-panel/tests/mounting.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { SplitPanel } from '../src'; 4 | 5 | // There's a tsdown/vitest issue causing false positives here, so we skip these tests for now 6 | // => TypeError: Cannot define property split-panel, object is not extensible 7 | describe.todo('basic mounting and rendering', () => { 8 | it('mounts without crashing', () => { 9 | const wrapper = mount(SplitPanel); 10 | expect(wrapper.exists()).toBe(true); 11 | }); 12 | 13 | it('renders start, divider, and end slots', () => { 14 | const wrapper = mount(SplitPanel); 15 | expect(wrapper.find('[data-testid="start"]').exists()).toBe(true); 16 | expect(wrapper.find('[data-testid="divider"]').exists()).toBe(true); 17 | expect(wrapper.find('[data-testid="end"]').exists()).toBe(true); 18 | }); 19 | 20 | it('renders slot content correctly', () => { 21 | const wrapper = mount(SplitPanel, { 22 | slots: { 23 | start: '
Start Panel
', 24 | divider: '
Divider
', 25 | end: '
End Panel
', 26 | }, 27 | }); 28 | 29 | expect(wrapper.find('.test-panel-start').text()).toBe('Start Panel'); 30 | expect(wrapper.find('.test-divider').text()).toBe('Divider'); 31 | expect(wrapper.find('.test-panel-end').text()).toBe('End Panel'); 32 | }); 33 | 34 | it('renders default divider div when no divider slot content is given', () => { 35 | const wrapper = mount(SplitPanel); 36 | const divider = wrapper.find('[data-testid="divider"]'); 37 | 38 | expect(divider.exists()).toBe(true); 39 | expect(divider.find('div').exists()).toBe(true); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/utils/closest-number.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the number from an array that is closest to the provided value. 3 | * 4 | * Tie breaking: 5 | * - For finite target values: if two numbers are equally close, the smaller numeric value is returned (stable + predictable) 6 | * - For target = +Infinity: the largest candidate wins (intuitive 'towards' the target) 7 | * - For target = -Infinity: the smallest candidate wins 8 | * 9 | * Non-finite (NaN / ±Infinity) entries in the candidate list are ignored. If, after filtering, no numbers remain, `undefined` is returned. 10 | * 11 | * @param numbers - The list of candidate numbers 12 | * @param value - The target value to compare against 13 | * @returns The closest number from the list, or `undefined` if the list is empty or only contained non-finite values 14 | */ 15 | export const closestNumber = (numbers: readonly number[], value: number): number | undefined => { 16 | let closest: number | undefined; 17 | let smallestDiff = Number.POSITIVE_INFINITY; 18 | 19 | for (const n of numbers) { 20 | if (!Number.isFinite(n)) continue; // ignore NaN / Infinity 21 | const diff = Math.abs(n - value); 22 | 23 | if (diff < smallestDiff) { 24 | smallestDiff = diff; 25 | closest = n; 26 | continue; 27 | } 28 | 29 | if (diff === smallestDiff && closest !== undefined) { 30 | if (value === Number.POSITIVE_INFINITY) { 31 | if (n > closest) closest = n; 32 | } 33 | else if (value === Number.NEGATIVE_INFINITY) { 34 | if (n < closest) closest = n; 35 | } 36 | else if (n < closest) { 37 | closest = n; // finite target: choose smaller 38 | } 39 | } 40 | 41 | if (closest === undefined) { 42 | closest = n; 43 | smallestDiff = diff; 44 | } 45 | } 46 | 47 | return closest; 48 | }; 49 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | seo: 3 | title: Vue Split Panel 4 | --- 5 | 6 | ::u-page-hero{orientation="horizontal"} 7 | #title 8 | Vue Split Panel 9 | 10 | #description 11 | A split panel component based on Reka-UI and Web Awesome Split Panel 12 | 13 | #links 14 | :::u-button 15 | --- 16 | color: neutral 17 | size: xl 18 | to: /getting-started/installation 19 | trailing-icon: i-lucide-arrow-right 20 | --- 21 | Get started 22 | ::: 23 | 24 | :::u-button 25 | --- 26 | color: neutral 27 | icon: simple-icons-github 28 | size: xl 29 | to: https://github.com/directus/vue-split-panel 30 | variant: outline 31 | --- 32 | Star on GitHub 33 | ::: 34 | 35 | #default 36 | :::div{class="flex flex-col gap-4"} 37 | ::::u-page-feature 38 | --- 39 | title: Accessible 40 | icon: lucide-person-standing 41 | description: Uses the Window Splitter WAI-ARIA design pattern 42 | --- 43 | :::: 44 | 45 | ::::u-page-feature 46 | --- 47 | title: Keyboard Interaction 48 | icon: lucide-keyboard 49 | description: Supports keyboard interactions for resizing and collapsing panes 50 | --- 51 | :::: 52 | 53 | ::::u-page-feature 54 | --- 55 | title: Text Direction 56 | icon: lucide-arrow-left-right 57 | description: Works in either LTR or RTL text directions 58 | --- 59 | :::: 60 | 61 | ::::u-page-feature 62 | --- 63 | title: Nested Layouts 64 | icon: lucide-layout-panel-left 65 | description: Create rich layouts by nesting split panel components 66 | --- 67 | :::: 68 | 69 | ::::u-page-feature 70 | --- 71 | title: Transitions 72 | icon: lucide-send-to-back 73 | description: Support for transitions when collapsing/expanding the primary panel 74 | --- 75 | :::: 76 | ::: 77 | :: 78 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-grid-template.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; 2 | import type { Orientation, Primary } from '../types'; 3 | import { computed, toValue } from 'vue'; 4 | 5 | export interface UseGridTemplateOptions { 6 | collapsed: Ref; 7 | minSizePercentage: ComputedRef; 8 | maxSizePercentage: ComputedRef; 9 | sizePercentage: ComputedRef; 10 | dividerSize: ComputedRef; 11 | primary: MaybeRefOrGetter; 12 | orientation: MaybeRefOrGetter; 13 | collapsedSizePercentage: ComputedRef; 14 | } 15 | 16 | export const useGridTemplate = (options: UseGridTemplateOptions) => { 17 | const gridTemplate = computed(() => { 18 | let primary: string; 19 | 20 | if (options.collapsed.value) { 21 | primary = `${options.collapsedSizePercentage.value}%`; 22 | } 23 | else if (options.minSizePercentage.value !== undefined && options.maxSizePercentage.value !== undefined) { 24 | primary = `clamp(0%, clamp(${options.minSizePercentage.value}%, ${options.sizePercentage.value}%, ${options.maxSizePercentage.value}%), calc(100% - ${options.dividerSize.value}px))`; 25 | } 26 | else if (options.minSizePercentage.value !== undefined) { 27 | primary = `clamp(${options.minSizePercentage.value}%, ${options.sizePercentage.value}%, calc(100% - ${options.dividerSize.value}px))`; 28 | } 29 | else { 30 | primary = `clamp(0%, ${options.sizePercentage.value}%, calc(100% - ${options.dividerSize.value}px))`; 31 | } 32 | 33 | const secondary = 'auto'; 34 | 35 | if (!toValue(options.primary) || toValue(options.primary) === 'start') { 36 | return `${primary} ${options.dividerSize.value}px ${secondary}`; 37 | } 38 | else { 39 | return `${secondary} ${options.dividerSize.value}px ${primary}`; 40 | } 41 | }); 42 | 43 | return { gridTemplate }; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/vue-split-panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@directus/vue-split-panel", 3 | "type": "module", 4 | "version": "0.8.2", 5 | "description": "Split panel component for Vue based on WebAwesome Split Panel", 6 | "author": "Rijk van Zanten ", 7 | "homepage": "https://github.com/directus/vue-split-panel", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/directus/vue-split-panel.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/rijkvanzanten/vue-split-panel/issues" 14 | }, 15 | "keywords": [ 16 | "vue", 17 | "ui", 18 | "components", 19 | "panel", 20 | "splitter" 21 | ], 22 | "sideEffects": false, 23 | "exports": { 24 | ".": { 25 | "import": "./dist/index.js" 26 | }, 27 | "./package.json": "./package.json", 28 | "./index.css": "./dist/index.css" 29 | }, 30 | "module": "./dist/index.js", 31 | "types": "./dist/index.d.ts", 32 | "files": [ 33 | "dist" 34 | ], 35 | "scripts": { 36 | "build": "tsdown", 37 | "dev": "tsdown --watch", 38 | "playground": "vite", 39 | "test": "vitest", 40 | "test:coverage": "vitest --coverage", 41 | "typecheck": "vue-tsc --noEmit", 42 | "release": "bumpp && pnpm publish", 43 | "prepublishOnly": "pnpm run build", 44 | "lint": "eslint --flag v10_config_lookup_from_file --cache .", 45 | "lint:fix": "pnpm run lint --fix" 46 | }, 47 | "peerDependencies": { 48 | "vue": "^3.5.0" 49 | }, 50 | "dependencies": { 51 | "@vueuse/core": "13.9.0", 52 | "pnpm": "10.18.3", 53 | "test": "3.3.0" 54 | }, 55 | "devDependencies": { 56 | "@types/node": "24.7.2", 57 | "@vitejs/plugin-vue": "6.0.1", 58 | "@vitest/coverage-v8": "3.2.4", 59 | "@vue/test-utils": "2.4.6", 60 | "bumpp": "10.3.1", 61 | "eslint": "9.37.0", 62 | "happy-dom": "20.0.0", 63 | "tsdown": "0.15.7", 64 | "typescript": "5.9.3", 65 | "vite": "npm:rolldown-vite@latest", 66 | "vitest": "3.2.4", 67 | "vue": "3.5.19", 68 | "vue-tsc": "3.1.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/vue-split-panel/tests/collapse.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { SplitPanel } from '../src'; 4 | 5 | // There's a tsdown/vitest issue causing false positives here, so we skip these tests for now 6 | // => TypeError: Cannot define property split-panel, object is not extensible 7 | describe.todo('collapse', () => { 8 | it('collapses when collapsed is set to true', () => { 9 | const wrapper = mount(SplitPanel, { 10 | props: { collapsed: true }, 11 | }); 12 | 13 | expect(wrapper.find('[data-testid="root"]').classes()).toContain('sp-collapsed'); 14 | }); 15 | 16 | it('is not collapsed when collapsed is set to false', () => { 17 | const wrapper = mount(SplitPanel, { 18 | props: { collapsed: false }, 19 | }); 20 | 21 | expect(wrapper.find('[data-testid="root"]').classes()).not.toContain('sp-collapsed'); 22 | }); 23 | 24 | it('can be collapsed through a prop even when collapsible is false', () => { 25 | const wrapper = mount(SplitPanel, { 26 | props: { collapsible: false, collapsed: true }, 27 | }); 28 | 29 | expect(wrapper.find('[data-testid="root"]').classes()).toContain('sp-collapsed'); 30 | }); 31 | 32 | it('sets size to 0 when collapsed', async () => { 33 | const wrapper = mount(SplitPanel, { 34 | props: { size: 30, collapsed: false }, 35 | slots: { start: 'Start', end: 'End' }, 36 | }); 37 | 38 | await wrapper.setProps({ collapsed: true }); 39 | 40 | expect(wrapper.find('[data-testid="divider"]').attributes('aria-valuenow')).toBe('0'); 41 | }); 42 | 43 | it('preserves size when expanding back from collapsed state', async () => { 44 | const wrapper = mount(SplitPanel, { 45 | props: { size: 75 }, 46 | }); 47 | 48 | // Collapse 49 | await wrapper.setProps({ collapsed: true }); 50 | expect(wrapper.find('[data-testid="divider"]').attributes('aria-valuenow')).toBe('0'); 51 | 52 | // Expand back 53 | await wrapper.setProps({ collapsed: false }); 54 | expect(wrapper.find('[data-testid="divider"]').attributes('aria-valuenow')).toBe('75'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-keyboard.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; 2 | import type { Orientation, Primary } from '../types'; 3 | import { clamp } from '@vueuse/core'; 4 | import { toValue } from 'vue'; 5 | 6 | export interface UseKeyboardOptions { 7 | disabled: MaybeRefOrGetter; 8 | collapsible: MaybeRefOrGetter; 9 | primary: MaybeRefOrGetter; 10 | orientation: MaybeRefOrGetter; 11 | minSizePercentage: ComputedRef; 12 | maxSizePercentage: ComputedRef; 13 | } 14 | 15 | export const useKeyboard = (sizePercentage: Ref, collapsed: Ref, options: UseKeyboardOptions) => { 16 | const handleKeydown = (event: KeyboardEvent) => { 17 | if (toValue(options.disabled)) return; 18 | 19 | if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter'].includes(event.key)) { 20 | event.preventDefault(); 21 | 22 | let newPosition = sizePercentage.value; 23 | 24 | const increment = (event.shiftKey ? 10 : 1) * (toValue(options.primary) === 'end' ? -1 : 1); 25 | 26 | if ( 27 | (event.key === 'ArrowLeft' && toValue(options.orientation) === 'horizontal') 28 | || (event.key === 'ArrowUp' && toValue(options.orientation) === 'vertical') 29 | ) { 30 | newPosition -= increment; 31 | } 32 | 33 | if ( 34 | (event.key === 'ArrowRight' && toValue(options.orientation) === 'horizontal') 35 | || (event.key === 'ArrowDown' && toValue(options.orientation) === 'vertical') 36 | ) { 37 | newPosition += increment; 38 | } 39 | 40 | if (event.key === 'Home') { 41 | newPosition = toValue(options.primary) === 'end' ? 100 : 0; 42 | } 43 | 44 | if (event.key === 'End') { 45 | newPosition = toValue(options.primary) === 'end' ? 0 : 100; 46 | } 47 | 48 | if (event.key === 'Enter' && toValue(options.collapsible)) { 49 | collapsed.value = !collapsed.value; 50 | } 51 | 52 | sizePercentage.value = clamp(newPosition, options.minSizePercentage.value, options.maxSizePercentage.value ?? 100); 53 | } 54 | }; 55 | 56 | return { handleKeydown }; 57 | }; 58 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-resize.test.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import { beforeEach, describe, expect, it } from 'vitest'; 3 | import { computed, defineComponent, ref } from 'vue'; 4 | import { useResize } from './use-resize'; 5 | 6 | describe('useResize', () => { 7 | let mockObserver: ResizeObserver; 8 | 9 | beforeEach(() => { 10 | mockObserver = new ResizeObserver(() => {}); 11 | }); 12 | 13 | it('should not reset sizePercentage when no primary has been set', () => { 14 | const sizePercentage = ref(100); 15 | 16 | const wrapper = mount(defineComponent({ 17 | template: '
', 18 | setup() { 19 | return useResize(sizePercentage, { 20 | sizePixels: computed(() => 50), 21 | panelEl: document.createElement('div'), 22 | orientation: ref('horizontal'), 23 | primary: ref(undefined), 24 | }); 25 | }, 26 | })); 27 | 28 | const entry = { contentRect: { width: 75, height: 75 } } as ResizeObserverEntry; 29 | 30 | wrapper.vm.onResize([entry], mockObserver); 31 | 32 | expect(sizePercentage.value).toBe(100); 33 | }); 34 | 35 | it('should set the sizePercentage based on the primary size width', () => { 36 | const sizePercentage = ref(50); 37 | 38 | const wrapper = mount(defineComponent({ 39 | template: '
', 40 | setup() { 41 | return useResize(sizePercentage, { 42 | sizePixels: computed(() => 250), 43 | panelEl: document.createElement('div'), 44 | orientation: ref('horizontal'), 45 | primary: ref('start'), 46 | }); 47 | }, 48 | })); 49 | 50 | const entry = { contentRect: { width: 400, height: 150 } } as ResizeObserverEntry; 51 | 52 | wrapper.vm.onResize([entry], mockObserver); 53 | 54 | // Pixel size of 250 on a total width of 400 = 62.5% 55 | expect(sizePercentage.value).toBe(62.5); 56 | }); 57 | 58 | it('should use the height when orientation is vertical', () => { 59 | const sizePercentage = ref(50); 60 | 61 | const wrapper = mount(defineComponent({ 62 | template: '
', 63 | setup() { 64 | return useResize(sizePercentage, { 65 | sizePixels: computed(() => 250), 66 | panelEl: document.createElement('div'), 67 | orientation: ref('vertical'), 68 | primary: ref('start'), 69 | }); 70 | }, 71 | })); 72 | 73 | const entry = { contentRect: { width: 150, height: 400 } } as ResizeObserverEntry; 74 | 75 | wrapper.vm.onResize([entry], mockObserver); 76 | 77 | // Pixel size of 250 on a total width of 400 = 62.5% 78 | expect(sizePercentage.value).toBe(62.5); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /attributions.md: -------------------------------------------------------------------------------- 1 | # Third-Party Attributions 2 | 3 | This project includes or derives from the following MIT-licensed works: 4 | 5 | - **Web Awesome**\ 6 | Copyright (c) 2023 Fonticons, Inc. Source: https://github.com/shoelace-style/webawesome 7 | - **Reka UI**\ 8 | Copyright (c) 2023 UnoVue Source: https://github.com/unovue/reka-ui 9 | 10 | Each of the above components is licensed under the MIT License.\ 11 | For convenience, the original license texts are reproduced below. 12 | 13 | --- 14 | 15 | ## Web Awesome License (MIT) 16 | 17 | Copyright (c) 2023 Fonticons, Inc. 18 | 19 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 20 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 21 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 22 | persons to whom the Software is furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 25 | Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 28 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 29 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 30 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | ## Reka UI 33 | 34 | MIT License 35 | 36 | Copyright (c) 2023 UnoVue 37 | 38 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 39 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 40 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 41 | persons to whom the Software is furnished to do so, subject to the following conditions: 42 | 43 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 44 | Software. 45 | 46 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 47 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 48 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 49 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 50 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Orientation = 'horizontal' | 'vertical'; 2 | export type Direction = 'ltr' | 'rtl'; 3 | export type Primary = 'start' | 'end'; 4 | export type SizeUnit = '%' | 'px'; 5 | 6 | export interface UiClasses { 7 | start?: string; 8 | divider?: string; 9 | end?: string; 10 | } 11 | 12 | export interface SplitPanelProps { 13 | /** 14 | * Sets the split panel's orientation 15 | * @default 'horizontal' 16 | */ 17 | orientation?: Orientation; 18 | 19 | /** 20 | * Sets the split panel's text direction 21 | * @default 'ltr' 22 | */ 23 | direction?: Direction; 24 | 25 | /** If no primary panel is designated, both panels will resize proportionally when the host element is resized. If a primary panel is designated, it will maintain its size and the other panel will grow or shrink as needed when the panels component is resized */ 26 | primary?: Primary | undefined; 27 | 28 | /** 29 | * The invisible region around the divider where dragging can occur. This is usually wider than the divider to facilitate easier dragging. CSS value 30 | * @default '12px' 31 | */ 32 | dividerHitArea?: string; 33 | 34 | /** 35 | * Whether the size v-model should be in relative percentages or absolute pixels 36 | * @default '%' 37 | */ 38 | sizeUnit?: SizeUnit; 39 | 40 | /** 41 | * Disable the manual resizing of the panels 42 | * @default false 43 | */ 44 | disabled?: boolean; 45 | 46 | /** 47 | * The minimum allowed size of the primary panel 48 | * @default 0 49 | */ 50 | minSize?: number; 51 | 52 | /** The maximum allowed size of the primary panel */ 53 | maxSize?: number; 54 | 55 | /** 56 | * Whether to allow the primary panel to be collapsed on enter key on divider or when the collapse threshold is met 57 | * @default false 58 | */ 59 | collapsible?: boolean; 60 | 61 | /** How far to drag beyond the minSize to collapse/expand the primary panel */ 62 | collapseThreshold?: number; 63 | 64 | /** 65 | * How much of the panel content is visible when the panel is collapsed 66 | * @default 0 67 | */ 68 | collapsedSize?: number; 69 | 70 | /** 71 | * How long should the collapse/expand state transition for in ms 72 | * @default 0 73 | */ 74 | transitionDuration?: number; 75 | 76 | /** 77 | * CSS transition timing function for the expand transition 78 | * @default 'cubic-bezier(0, 0, 0.2, 1)' 79 | */ 80 | transitionTimingFunctionExpand?: string; 81 | 82 | /** 83 | * CSS transition timing function for the collapse transition 84 | * @default 'cubic-bezier(0.4, 0, 0.6, 1)' 85 | */ 86 | transitionTimingFunctionCollapse?: string; 87 | 88 | /** 89 | * What size values the divider should snap to 90 | * @default [] 91 | */ 92 | snapPoints?: number[]; 93 | 94 | /** 95 | * How close to the snap point the size should be before the snapping occurs 96 | * @default 12 97 | */ 98 | snapThreshold?: number; 99 | 100 | /** 101 | * Inject additional classes into the elements that split-panel renders 102 | */ 103 | ui?: UiClasses; 104 | } 105 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-pointer.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeRefOrGetter } from '@vueuse/core'; 2 | import type { ComputedRef, Ref } from 'vue'; 3 | import type { Direction, Orientation, Primary } from '../types'; 4 | import { clamp, useDraggable } from '@vueuse/core'; 5 | import { toValue, watch } from 'vue'; 6 | import { closestNumber } from '../utils/closest-number'; 7 | import { pixelsToPercentage } from '../utils/pixels-to-percentage'; 8 | 9 | export interface UsePointerOptions { 10 | disabled: MaybeRefOrGetter; 11 | collapsible: MaybeRefOrGetter; 12 | primary: MaybeRefOrGetter; 13 | orientation: MaybeRefOrGetter; 14 | direction: MaybeRefOrGetter; 15 | collapseThreshold: MaybeRefOrGetter; 16 | snapThreshold: MaybeRefOrGetter; 17 | dividerEl: MaybeRefOrGetter; 18 | panelEl: MaybeRefOrGetter; 19 | componentSize: ComputedRef; 20 | minSizePixels: ComputedRef; 21 | snapPixels: ComputedRef; 22 | } 23 | 24 | export const usePointer = (collapsed: Ref, sizePercentage: Ref, sizePixels: Ref, options: UsePointerOptions) => { 25 | const { x: dividerX, y: dividerY, isDragging } = useDraggable(options.dividerEl, { containerElement: options.panelEl }); 26 | 27 | let thresholdLocation: 'expand' | 'collapse' = collapsed.value ? 'expand' : 'collapse'; 28 | 29 | watch([dividerX, dividerY], ([newX, newY]) => { 30 | if (toValue(options.disabled)) return; 31 | 32 | let newPositionInPixels = toValue(options.orientation) === 'horizontal' ? newX : newY; 33 | 34 | if (toValue(options.orientation) === 'horizontal' && toValue(options.direction) === 'rtl') { 35 | newPositionInPixels = options.componentSize.value - newPositionInPixels; 36 | } 37 | 38 | if (toValue(options.primary) === 'end') { 39 | newPositionInPixels = options.componentSize.value - newPositionInPixels; 40 | } 41 | 42 | if (toValue(options.collapsible) && toValue(options.collapseThreshold) !== undefined) { 43 | let threshold: number; 44 | 45 | if (thresholdLocation === 'collapse') threshold = options.minSizePixels.value - (toValue(options.collapseThreshold) ?? 0); 46 | else threshold = (toValue(options.collapseThreshold) ?? 0); 47 | 48 | if (newPositionInPixels < threshold && collapsed.value === false) { 49 | collapsed.value = true; 50 | } 51 | else if (newPositionInPixels > threshold && collapsed.value === true) { 52 | collapsed.value = false; 53 | } 54 | } 55 | 56 | for (const snapPoint of options.snapPixels.value) { 57 | if ( 58 | newPositionInPixels >= snapPoint - toValue(options.snapThreshold) 59 | && newPositionInPixels <= snapPoint + toValue(options.snapThreshold) 60 | ) { 61 | newPositionInPixels = snapPoint; 62 | } 63 | } 64 | 65 | sizePercentage.value = clamp(pixelsToPercentage(options.componentSize.value, newPositionInPixels), 0, 100); 66 | }); 67 | 68 | watch(isDragging, (newDragging) => { 69 | if (newDragging === false) { 70 | thresholdLocation = collapsed.value ? 'expand' : 'collapse'; 71 | } 72 | }); 73 | 74 | const handleDblClick = () => { 75 | const closest = closestNumber(options.snapPixels.value, sizePixels.value); 76 | 77 | if (closest !== undefined) { 78 | sizePixels.value = closest; 79 | } 80 | 81 | if (collapsed.value === true) { 82 | collapsed.value = false; 83 | } 84 | }; 85 | 86 | return { handleDblClick, isDragging }; 87 | }; 88 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-sizes.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeComputedElementRef } from '@vueuse/core'; 2 | import type { MaybeRefOrGetter, Ref } from 'vue'; 3 | import type { Orientation, Primary, SizeUnit } from '../types'; 4 | import { useElementSize } from '@vueuse/core'; 5 | import { computed, toValue } from 'vue'; 6 | import { percentageToPixels } from '../utils/percentage-to-pixels'; 7 | import { pixelsToPercentage } from '../utils/pixels-to-percentage'; 8 | 9 | export interface UseSizesOptions { 10 | disabled: MaybeRefOrGetter; 11 | collapsible: MaybeRefOrGetter; 12 | primary: MaybeRefOrGetter; 13 | orientation: MaybeRefOrGetter; 14 | sizeUnit: MaybeRefOrGetter; 15 | minSize: MaybeRefOrGetter; 16 | maxSize: MaybeRefOrGetter; 17 | snapPoints: MaybeRefOrGetter; 18 | panelEl: MaybeComputedElementRef; 19 | dividerEl: MaybeComputedElementRef; 20 | collapsedSize: MaybeRefOrGetter; 21 | } 22 | 23 | export const useSizes = (size: Ref, options: UseSizesOptions) => { 24 | const { width: componentWidth, height: componentHeight } = useElementSize(options.panelEl); 25 | const componentSize = computed(() => toValue(options.orientation) === 'horizontal' ? componentWidth.value : componentHeight.value); 26 | 27 | const { width: dividerWidth, height: dividerHeight } = useElementSize(options.dividerEl); 28 | const dividerSize = computed(() => toValue(options.orientation) === 'horizontal' ? dividerWidth.value : dividerHeight.value); 29 | 30 | const sizePercentage = computed({ 31 | get() { 32 | if (toValue(options.sizeUnit) === '%') return size.value; 33 | return pixelsToPercentage(componentSize.value, size.value); 34 | }, 35 | set(newValue: number) { 36 | if (toValue(options.sizeUnit) === '%') { 37 | size.value = newValue; 38 | } 39 | else { 40 | size.value = percentageToPixels(componentSize.value, newValue); 41 | } 42 | }, 43 | }); 44 | 45 | const sizePixels = computed({ 46 | get() { 47 | if (toValue(options.sizeUnit) === 'px') return size.value; 48 | return percentageToPixels(componentSize.value, size.value); 49 | }, 50 | set(newValue: number) { 51 | if (toValue(options.sizeUnit) === 'px') { 52 | size.value = newValue; 53 | } 54 | else { 55 | size.value = pixelsToPercentage(componentSize.value, newValue); 56 | } 57 | }, 58 | }); 59 | 60 | const minSizePercentage = computed(() => { 61 | if (toValue(options.sizeUnit) === '%') return toValue(options.minSize); 62 | return pixelsToPercentage(componentSize.value, toValue(options.minSize)); 63 | }); 64 | 65 | const minSizePixels = computed(() => { 66 | if (toValue(options.sizeUnit) === 'px') return toValue(options.minSize); 67 | return percentageToPixels(componentSize.value, toValue(options.minSize)); 68 | }); 69 | 70 | const maxSizePercentage = computed(() => { 71 | if (toValue(options.maxSize) === undefined) return; 72 | 73 | if (toValue(options.sizeUnit) === '%') return toValue(options.maxSize); 74 | return pixelsToPercentage(componentSize.value, toValue(options.maxSize)!); 75 | }); 76 | 77 | const collapsedSizePercentage = computed(() => { 78 | if (toValue(options.sizeUnit) === '%') return toValue(options.collapsedSize); 79 | return pixelsToPercentage(componentSize.value, toValue(options.collapsedSize)!); 80 | }); 81 | 82 | const snapPixels = computed(() => { 83 | if (toValue(options.sizeUnit) === 'px') return toValue(options.snapPoints); 84 | return toValue(options.snapPoints).map((snapPercentage) => percentageToPixels(componentSize.value, snapPercentage)); 85 | }); 86 | 87 | return { 88 | componentSize, 89 | dividerSize, 90 | sizePercentage, 91 | sizePixels, 92 | minSizePercentage, 93 | minSizePixels, 94 | maxSizePercentage, 95 | snapPixels, 96 | collapsedSizePercentage, 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-grid-template.test.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef } from 'vue'; 2 | import type { UseGridTemplateOptions } from './use-grid-template'; 3 | import { describe, expect, it } from 'vitest'; 4 | import { computed, ref } from 'vue'; 5 | import { useGridTemplate } from './use-grid-template'; 6 | 7 | describe('useGridTemplate', () => { 8 | const createOptions = (overrides = {}): UseGridTemplateOptions => ({ 9 | collapsed: ref(false), 10 | minSizePercentage: computed(() => {}) as ComputedRef, 11 | maxSizePercentage: computed(() => {}) as ComputedRef, 12 | sizePercentage: computed(() => 50), 13 | dividerSize: computed(() => 4), 14 | collapsedSizePercentage: computed(() => 0), 15 | primary: 'start', 16 | orientation: 'horizontal', 17 | ...overrides, 18 | }); 19 | 20 | it('returns collapsed state when collapsed is true', () => { 21 | const options = createOptions({ collapsed: ref(true) }); 22 | const { gridTemplate } = useGridTemplate(options); 23 | 24 | expect(gridTemplate.value).toBe('0% 4px auto'); 25 | }); 26 | 27 | it('uses custom collapsedSizePercentage when collapsed', () => { 28 | const options = createOptions({ 29 | collapsed: ref(true), 30 | collapsedSizePercentage: computed(() => 10), 31 | }); 32 | const { gridTemplate } = useGridTemplate(options); 33 | 34 | expect(gridTemplate.value).toBe('10% 4px auto'); 35 | }); 36 | 37 | it('uses custom collapsedSizePercentage with end primary', () => { 38 | const options = createOptions({ 39 | collapsed: ref(true), 40 | collapsedSizePercentage: computed(() => 15), 41 | primary: 'end', 42 | }); 43 | const { gridTemplate } = useGridTemplate(options); 44 | 45 | expect(gridTemplate.value).toBe('auto 4px 15%'); 46 | }); 47 | 48 | it('returns basic clamp template when no min/max constraints', () => { 49 | const options = createOptions(); 50 | const { gridTemplate } = useGridTemplate(options); 51 | 52 | expect(gridTemplate.value).toBe('clamp(0%, 50%, calc(100% - 4px)) 4px auto'); 53 | }); 54 | 55 | it('returns complex clamp template with min/max constraints', () => { 56 | const options = createOptions({ 57 | minSizePercentage: computed(() => 20), 58 | maxSizePercentage: computed(() => 80), 59 | }); 60 | const { gridTemplate } = useGridTemplate(options); 61 | 62 | expect(gridTemplate.value).toBe('clamp(0%, clamp(20%, 50%, 80%), calc(100% - 4px)) 4px auto'); 63 | }); 64 | 65 | it('respects minSizePercentage as a floor when max is not set', () => { 66 | const options = createOptions({ 67 | minSizePercentage: computed(() => 30), 68 | }); 69 | 70 | const { gridTemplate } = useGridTemplate(options); 71 | 72 | expect(gridTemplate.value).toBe( 73 | 'clamp(30%, 50%, calc(100% - 4px)) 4px auto', 74 | ); 75 | }); 76 | 77 | it('reverses order when primary is end and direction is ltr', () => { 78 | const options = createOptions({ primary: 'end' }); 79 | const { gridTemplate } = useGridTemplate(options); 80 | 81 | expect(gridTemplate.value).toBe('auto 4px clamp(0%, 50%, calc(100% - 4px))'); 82 | }); 83 | 84 | it('handles vertical orientation correctly', () => { 85 | const options = createOptions({ orientation: 'vertical' }); 86 | const { gridTemplate } = useGridTemplate(options); 87 | 88 | expect(gridTemplate.value).toBe('clamp(0%, 50%, calc(100% - 4px)) 4px auto'); 89 | }); 90 | 91 | it('handles vertical orientation with end primary', () => { 92 | const options = createOptions({ 93 | orientation: 'vertical', 94 | primary: 'end', 95 | }); 96 | const { gridTemplate } = useGridTemplate(options); 97 | 98 | expect(gridTemplate.value).toBe('auto 4px clamp(0%, 50%, calc(100% - 4px))'); 99 | }); 100 | 101 | it('uses custom divider size', () => { 102 | const options = createOptions({ dividerSize: computed(() => 8) }); 103 | const { gridTemplate } = useGridTemplate(options); 104 | 105 | expect(gridTemplate.value).toBe('clamp(0%, 50%, calc(100% - 8px)) 8px auto'); 106 | }); 107 | 108 | it('handles undefined primary as start', () => { 109 | const options = createOptions({ primary: undefined }); 110 | const { gridTemplate } = useGridTemplate(options); 111 | 112 | expect(gridTemplate.value).toBe('clamp(0%, 50%, calc(100% - 4px)) 4px auto'); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/SplitPanel.vue: -------------------------------------------------------------------------------- 1 | 116 | 117 | 153 | 154 | 241 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-sizes.test.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue'; 2 | import type { UseSizesOptions } from './use-sizes'; 3 | import { beforeEach, describe, expect, it } from 'vitest'; 4 | import { ref } from 'vue'; 5 | import { useSizes } from './use-sizes'; 6 | 7 | describe('useSizes', () => { 8 | let mockPanelEl: HTMLElement; 9 | let mockDividerEl: HTMLElement; 10 | let size: Ref; 11 | let defaultOptions: UseSizesOptions; 12 | 13 | beforeEach(() => { 14 | mockPanelEl = document.createElement('div'); 15 | mockDividerEl = document.createElement('div'); 16 | 17 | Object.defineProperty(mockPanelEl, 'offsetWidth', { value: 400, writable: true }); 18 | Object.defineProperty(mockPanelEl, 'offsetHeight', { value: 300, writable: true }); 19 | Object.defineProperty(mockDividerEl, 'offsetWidth', { value: 8, writable: true }); 20 | Object.defineProperty(mockDividerEl, 'offsetHeight', { value: 8, writable: true }); 21 | 22 | size = ref(50); 23 | 24 | defaultOptions = { 25 | disabled: false, 26 | collapsible: false, 27 | primary: 'start', 28 | orientation: 'horizontal', 29 | sizeUnit: '%', 30 | minSize: 10, 31 | maxSize: 90, 32 | snapPoints: [25, 50, 75], 33 | panelEl: mockPanelEl, 34 | dividerEl: mockDividerEl, 35 | collapsedSize: 0, 36 | }; 37 | }); 38 | 39 | describe('componentSize', () => { 40 | it('should return width for horizontal orientation', () => { 41 | const { componentSize } = useSizes(size, defaultOptions); 42 | expect(componentSize.value).toBe(400); 43 | }); 44 | 45 | it('should return height for vertical orientation', () => { 46 | const options = { ...defaultOptions, orientation: 'vertical' as const }; 47 | const { componentSize } = useSizes(size, options); 48 | expect(componentSize.value).toBe(300); 49 | }); 50 | }); 51 | 52 | describe('dividerSize', () => { 53 | it('should return divider width for horizontal orientation', () => { 54 | const { dividerSize } = useSizes(size, defaultOptions); 55 | expect(dividerSize.value).toBe(8); 56 | }); 57 | 58 | it('should return divider height for vertical orientation', () => { 59 | const options = { ...defaultOptions, orientation: 'vertical' as const }; 60 | const { dividerSize } = useSizes(size, options); 61 | expect(dividerSize.value).toBe(8); 62 | }); 63 | }); 64 | 65 | describe('sizePercentage', () => { 66 | it('should return size value when sizeUnit is percentage', () => { 67 | const { sizePercentage } = useSizes(size, defaultOptions); 68 | expect(sizePercentage.value).toBe(50); 69 | }); 70 | 71 | it('should convert pixels to percentage when sizeUnit is pixels', () => { 72 | const options = { ...defaultOptions, sizeUnit: 'px' as const }; 73 | size.value = 200; // 200px out of 400px = 50% 74 | const { sizePercentage } = useSizes(size, options); 75 | expect(sizePercentage.value).toBe(50); 76 | }); 77 | 78 | it('should update size when setting percentage value with percentage unit', () => { 79 | const { sizePercentage } = useSizes(size, defaultOptions); 80 | sizePercentage.value = 75; 81 | expect(size.value).toBe(75); 82 | }); 83 | 84 | it('should convert and update size when setting percentage value with pixel unit', () => { 85 | const options = { ...defaultOptions, sizeUnit: 'px' as const }; 86 | const { sizePercentage } = useSizes(size, options); 87 | sizePercentage.value = 75; // 75% of 400px = 300px 88 | expect(size.value).toBe(300); 89 | }); 90 | }); 91 | 92 | describe('sizePixels', () => { 93 | it('should return size value when sizeUnit is pixels', () => { 94 | const options = { ...defaultOptions, sizeUnit: 'px' as const }; 95 | size.value = 200; 96 | const { sizePixels } = useSizes(size, options); 97 | expect(sizePixels.value).toBe(200); 98 | }); 99 | 100 | it('should convert percentage to pixels when sizeUnit is percentage', () => { 101 | size.value = 50; // 50% of 400px = 200px 102 | const { sizePixels } = useSizes(size, defaultOptions); 103 | expect(sizePixels.value).toBe(200); 104 | }); 105 | 106 | it('should update size when setting pixel value with pixel unit', () => { 107 | const options = { ...defaultOptions, sizeUnit: 'px' as const }; 108 | const { sizePixels } = useSizes(size, options); 109 | sizePixels.value = 300; 110 | expect(size.value).toBe(300); 111 | }); 112 | 113 | it('should convert and update size when setting pixel value with percentage unit', () => { 114 | const { sizePixels } = useSizes(size, defaultOptions); 115 | sizePixels.value = 300; // 300px out of 400px = 75% 116 | expect(size.value).toBe(75); 117 | }); 118 | }); 119 | 120 | describe('minSizePercentage', () => { 121 | it('should return minSize value when sizeUnit is percentage', () => { 122 | const { minSizePercentage } = useSizes(size, defaultOptions); 123 | expect(minSizePercentage.value).toBe(10); 124 | }); 125 | 126 | it('should convert pixels to percentage when sizeUnit is pixels', () => { 127 | const options = { ...defaultOptions, sizeUnit: 'px' as const, minSize: 40 }; 128 | const { minSizePercentage } = useSizes(size, options); 129 | expect(minSizePercentage.value).toBe(10); // 40px out of 400px = 10% 130 | }); 131 | }); 132 | 133 | describe('minSizePixels', () => { 134 | it('should return minSize value when sizeUnit is pixels', () => { 135 | const options = { ...defaultOptions, sizeUnit: 'px' as const, minSize: 40 }; 136 | const { minSizePixels } = useSizes(size, options); 137 | expect(minSizePixels.value).toBe(40); 138 | }); 139 | 140 | it('should convert percentage to pixels when sizeUnit is percentage', () => { 141 | const { minSizePixels } = useSizes(size, defaultOptions); 142 | expect(minSizePixels.value).toBe(40); // 10% of 400px = 40px 143 | }); 144 | }); 145 | 146 | describe('maxSizePercentage', () => { 147 | it('should return undefined when maxSize is undefined', () => { 148 | const options = { ...defaultOptions, maxSize: undefined }; 149 | const { maxSizePercentage } = useSizes(size, options); 150 | expect(maxSizePercentage.value).toBeUndefined(); 151 | }); 152 | 153 | it('should return maxSize value when sizeUnit is percentage', () => { 154 | const { maxSizePercentage } = useSizes(size, defaultOptions); 155 | expect(maxSizePercentage.value).toBe(90); 156 | }); 157 | 158 | it('should convert pixels to percentage when sizeUnit is pixels', () => { 159 | const options = { ...defaultOptions, sizeUnit: 'px' as const, maxSize: 360 }; 160 | const { maxSizePercentage } = useSizes(size, options); 161 | expect(maxSizePercentage.value).toBe(90); // 360px out of 400px = 90% 162 | }); 163 | }); 164 | 165 | describe('snapPixels', () => { 166 | it('should return snapPoints as-is when sizeUnit is pixels', () => { 167 | const options = { ...defaultOptions, sizeUnit: 'px' as const, snapPoints: [100, 200, 300] }; 168 | const { snapPixels } = useSizes(size, options); 169 | expect(snapPixels.value).toEqual([100, 200, 300]); 170 | }); 171 | 172 | it('should convert percentage snapPoints to pixels when sizeUnit is percentage', () => { 173 | const { snapPixels } = useSizes(size, defaultOptions); 174 | expect(snapPixels.value).toEqual([100, 200, 300]); // 25%, 50%, 75% of 400px 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-pointer.test.ts: -------------------------------------------------------------------------------- 1 | import type { UseDraggableReturn } from '@vueuse/core'; 2 | import type { Ref } from 'vue'; 3 | import type { UsePointerOptions } from './use-pointer'; 4 | import { useDraggable } from '@vueuse/core'; 5 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 6 | import { computed, nextTick, ref } from 'vue'; 7 | import { usePointer } from './use-pointer'; 8 | 9 | vi.mock('@vueuse/core', async () => { 10 | const actual = await vi.importActual('@vueuse/core'); 11 | 12 | return { 13 | ...actual, 14 | useDraggable: vi.fn(), 15 | }; 16 | }); 17 | 18 | describe('usePointer', () => { 19 | let collapsed: Ref; 20 | let sizePercentage: Ref; 21 | let sizePixels: Ref; 22 | let options: UsePointerOptions; 23 | let dividerEl: Ref; 24 | let panelEl: Ref; 25 | 26 | let mockDragX: Ref; 27 | let mockDragY: Ref; 28 | let mockDragDragging: Ref; 29 | 30 | beforeEach(() => { 31 | collapsed = ref(false); 32 | sizePercentage = ref(50); 33 | sizePixels = ref(200); 34 | 35 | // Create mock DOM elements using real HTMLElements for better type safety 36 | const dividerElement = document.createElement('div'); 37 | dividerElement.getBoundingClientRect = () => new DOMRect(0, 0, 10, 10); 38 | dividerEl = ref(dividerElement); 39 | 40 | const panelElement = document.createElement('div'); 41 | panelElement.getBoundingClientRect = () => new DOMRect(0, 0, 400, 400); 42 | panelEl = ref(panelElement); 43 | 44 | options = { 45 | disabled: ref(false), 46 | collapsible: ref(true), 47 | primary: ref('start'), 48 | orientation: ref('horizontal'), 49 | direction: ref('ltr'), 50 | collapseThreshold: ref(10), 51 | snapThreshold: ref(5), 52 | dividerEl, 53 | panelEl, 54 | componentSize: computed(() => 400), 55 | minSizePixels: computed(() => 50), 56 | snapPixels: computed(() => [100, 200, 300]), 57 | }; 58 | 59 | mockDragX = ref(75); 60 | mockDragY = ref(75); 61 | mockDragDragging = ref(false); 62 | 63 | vi.mocked(useDraggable).mockReturnValue({ x: mockDragX, y: mockDragY, isDragging: mockDragDragging } as UseDraggableReturn); 64 | }); 65 | 66 | it('should return handleDblClick and isDragging', () => { 67 | const result = usePointer(collapsed, sizePercentage, sizePixels, options); 68 | 69 | expect(result).toHaveProperty('handleDblClick'); 70 | expect(result).toHaveProperty('isDragging'); 71 | expect(typeof result.handleDblClick).toBe('function'); 72 | expect(typeof result.isDragging.value).toBe('boolean'); 73 | }); 74 | 75 | describe('dbl click', () => { 76 | it('should handle double click to snap to closest point', () => { 77 | const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options); 78 | 79 | sizePixels.value = 195; // Close to 200 80 | handleDblClick(); 81 | 82 | expect(sizePixels.value).toBe(200); 83 | }); 84 | 85 | it('should expand on double click when collapsed', () => { 86 | collapsed.value = true; 87 | const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options); 88 | 89 | handleDblClick(); 90 | 91 | expect(collapsed.value).toBe(false); 92 | }); 93 | 94 | it('should not snap on double click when disabled', () => { 95 | options.disabled = ref(true); 96 | const originalSize = sizePixels.value; 97 | const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options); 98 | 99 | handleDblClick(); 100 | 101 | expect(sizePixels.value).toBe(originalSize); 102 | }); 103 | 104 | it('should not snap on double click when no snap points exist', () => { 105 | options.snapPixels = computed(() => []); 106 | const originalSize = sizePixels.value; 107 | const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options); 108 | 109 | handleDblClick(); 110 | 111 | expect(sizePixels.value).toBe(originalSize); 112 | }); 113 | 114 | it('should handle when collapsible is false', () => { 115 | options.collapsible = ref(false); 116 | collapsed.value = false; 117 | const { handleDblClick } = usePointer(collapsed, sizePercentage, sizePixels, options); 118 | 119 | handleDblClick(); 120 | 121 | expect(collapsed.value).toBe(false); // Should remain visible when not collapsible 122 | }); 123 | }); 124 | 125 | describe('dragging', () => { 126 | it('should not do anything when disabled', async () => { 127 | options.disabled = true; 128 | 129 | usePointer(collapsed, sizePercentage, sizePixels, options); 130 | 131 | mockDragDragging.value = true; 132 | mockDragX.value = 30; // below the minsize - threshold 133 | await nextTick(); 134 | expect(collapsed.value).toBe(false); 135 | }); 136 | 137 | it('should update sizePercentage when dragging horizontally', async () => { 138 | usePointer(collapsed, sizePercentage, sizePixels, options); 139 | 140 | mockDragX.value = 100; // Move to 100px 141 | await nextTick(); 142 | expect(sizePercentage.value).toBe(25); // 100/400 * 100 = 25% 143 | }); 144 | 145 | it('should update sizePercentage when dragging vertically', async () => { 146 | options.orientation = ref('vertical'); 147 | usePointer(collapsed, sizePercentage, sizePixels, options); 148 | 149 | mockDragY.value = 150; // Move to 150px 150 | await nextTick(); 151 | expect(sizePercentage.value).toBe(37.5); // 150/400 * 100 = 37.5% 152 | }); 153 | 154 | it('should handle primary end positioning', async () => { 155 | options.primary = ref('end'); 156 | usePointer(collapsed, sizePercentage, sizePixels, options); 157 | 158 | mockDragX.value = 100; // Drag position at 100px 159 | await nextTick(); 160 | // With primary end, actual position = 400 - 100 = 300px 161 | 162 | expect(sizePercentage.value).toBe(75); // 300/400 * 100 = 75% 163 | }); 164 | 165 | it('should collapse when dragging below collapse threshold', async () => { 166 | options.minSizePixels = computed(() => 50); 167 | options.collapseThreshold = ref(10); 168 | usePointer(collapsed, sizePercentage, sizePixels, options); 169 | 170 | mockDragX.value = 30; // Below minSize (50) - collapseThreshold (10) = 40 171 | await nextTick(); 172 | expect(collapsed.value).toBe(true); 173 | }); 174 | 175 | it('should expand when dragging above expand threshold', async () => { 176 | collapsed.value = true; 177 | options.collapseThreshold = ref(15); 178 | usePointer(collapsed, sizePercentage, sizePixels, options); 179 | 180 | mockDragX.value = 20; // Above collapseThreshold (15) 181 | await nextTick(); 182 | expect(collapsed.value).toBe(false); 183 | }); 184 | 185 | it('should not collapse when collapsible is false', async () => { 186 | options.collapsible = ref(false); 187 | usePointer(collapsed, sizePercentage, sizePixels, options); 188 | 189 | mockDragX.value = 10; // Very low position 190 | await nextTick(); 191 | expect(collapsed.value).toBe(false); 192 | }); 193 | 194 | it('should snap to snap points within threshold', async () => { 195 | options.snapPixels = computed(() => [100, 200, 300]); 196 | options.snapThreshold = ref(8); 197 | usePointer(collapsed, sizePercentage, sizePixels, options); 198 | 199 | mockDragX.value = 195; // Within 8px of snap point 200 200 | await nextTick(); 201 | expect(sizePercentage.value).toBe(50); // 200/400 * 100 = 50% 202 | }); 203 | 204 | it('should not snap when outside snap threshold', async () => { 205 | options.snapPixels = computed(() => [100, 200, 300]); 206 | options.snapThreshold = ref(5); 207 | usePointer(collapsed, sizePercentage, sizePixels, options); 208 | 209 | mockDragX.value = 190; // Outside 5px threshold of snap point 200 210 | await nextTick(); 211 | expect(sizePercentage.value).toBe(47.5); // 190/400 * 100 = 47.5% 212 | }); 213 | 214 | it('should handle RTL direction with horizontal orientation', async () => { 215 | options.direction = ref('rtl'); 216 | options.orientation = ref('horizontal'); 217 | options.snapPixels = computed(() => [100]); 218 | options.snapThreshold = ref(5); 219 | usePointer(collapsed, sizePercentage, sizePixels, options); 220 | 221 | // With RTL, drag position is mirrored: newPositionInPixels = 400 - mockDragX 222 | // mockDragX = 298 transforms to 102, which is within threshold of snap point 100 223 | mockDragX.value = 298; 224 | await nextTick(); 225 | expect(sizePercentage.value).toBe(25); // 100/400 * 100 = 25% 226 | }); 227 | 228 | it('should compose RTL and primary end correctly (double mirror)', async () => { 229 | // Horizontal, RTL and primary=end cause two mirrors: 230 | // 1) RTL: position -> 400 - x 231 | // 2) primary=end: position -> 400 - position 232 | // Net effect: original x (double mirror cancels out) 233 | options.direction = ref('rtl'); 234 | options.orientation = ref('horizontal'); 235 | options.primary = ref('end'); 236 | usePointer(collapsed, sizePercentage, sizePixels, options); 237 | 238 | mockDragX.value = 100; // Expect net position = 100 239 | await nextTick(); 240 | expect(sizePercentage.value).toBe(25); // 100/400 * 100 = 25% 241 | 242 | // Also verify snapping respects net position 243 | options.snapPixels = computed(() => [100]); 244 | options.snapThreshold = ref(5); 245 | mockDragX.value = 102; // Within threshold of 100 246 | await nextTick(); 247 | expect(sizePercentage.value).toBe(25); // snapped to 100 -> 25% 248 | }); 249 | 250 | it('should clamp sizePercentage between 0 and 100', async () => { 251 | usePointer(collapsed, sizePercentage, sizePixels, options); 252 | 253 | mockDragX.value = -50; // Negative position 254 | await nextTick(); 255 | expect(sizePercentage.value).toBe(0); 256 | 257 | mockDragX.value = 500; // Beyond component size 258 | await nextTick(); 259 | expect(sizePercentage.value).toBe(100); 260 | }); 261 | 262 | it('should update threshold location when dragging stops', async () => { 263 | usePointer(collapsed, sizePercentage, sizePixels, options); 264 | 265 | // Start dragging 266 | mockDragDragging.value = true; 267 | await nextTick(); 268 | 269 | // Collapse during drag 270 | mockDragX.value = 30; 271 | await nextTick(); 272 | expect(collapsed.value).toBe(true); 273 | 274 | // Stop dragging 275 | mockDragDragging.value = false; 276 | await nextTick(); 277 | 278 | // Should now be in expand mode for next drag 279 | mockDragX.value = 5; // Below expand threshold 280 | await nextTick(); 281 | expect(collapsed.value).toBe(true); // Should remain collapsed 282 | }); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-keyboard.test.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef } from 'vue'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | import { computed, ref } from 'vue'; 4 | import { useKeyboard } from './use-keyboard'; 5 | 6 | describe('useKeyboard', () => { 7 | const createMockKeyboardEvent = (key: string, shiftKey = false): KeyboardEvent => { 8 | const event = new KeyboardEvent('keydown', { key, shiftKey }); 9 | vi.spyOn(event, 'preventDefault'); 10 | return event; 11 | }; 12 | 13 | // Helper to build options with defaults and optional overrides 14 | const createOptions = (override: Partial<{ 15 | disabled: boolean; 16 | collapsible: boolean; 17 | primary: 'start' | 'end'; 18 | orientation: 'horizontal' | 'vertical'; 19 | minSizePercentage: ComputedRef; 20 | maxSizePercentage: ComputedRef; 21 | }> = {}) => ({ 22 | disabled: override.disabled ?? false, 23 | collapsible: override.collapsible ?? true, 24 | primary: override.primary ?? 'start', 25 | orientation: override.orientation ?? 'horizontal', 26 | minSizePercentage: override.minSizePercentage ?? computed(() => 0), 27 | maxSizePercentage: override.maxSizePercentage ?? computed(() => void 0), 28 | }); 29 | 30 | it('should return handleKeydown function', () => { 31 | const sizePercentage = ref(50); 32 | const collapsed = ref(false); 33 | const options = createOptions(); 34 | 35 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 36 | 37 | expect(typeof handleKeydown).toBe('function'); 38 | }); 39 | 40 | it('should do nothing when disabled', () => { 41 | const sizePercentage = ref(50); 42 | const collapsed = ref(false); 43 | const options = createOptions({ disabled: true }); 44 | 45 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 46 | const event = createMockKeyboardEvent('ArrowRight'); 47 | 48 | handleKeydown(event); 49 | 50 | expect(sizePercentage.value).toBe(50); 51 | expect(event.preventDefault).not.toHaveBeenCalled(); 52 | }); 53 | 54 | describe('horizontal orientation', () => { 55 | it('should decrease size on ArrowLeft when primary is start', () => { 56 | const sizePercentage = ref(50); 57 | const collapsed = ref(false); 58 | const options = createOptions(); 59 | 60 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 61 | const event = createMockKeyboardEvent('ArrowLeft'); 62 | 63 | handleKeydown(event); 64 | 65 | expect(sizePercentage.value).toBe(49); 66 | expect(event.preventDefault).toHaveBeenCalled(); 67 | }); 68 | 69 | it('should increase size on ArrowRight when primary is start', () => { 70 | const sizePercentage = ref(50); 71 | const collapsed = ref(false); 72 | const options = createOptions(); 73 | 74 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 75 | const event = createMockKeyboardEvent('ArrowRight'); 76 | 77 | handleKeydown(event); 78 | 79 | expect(sizePercentage.value).toBe(51); 80 | expect(event.preventDefault).toHaveBeenCalled(); 81 | }); 82 | 83 | it('should increase size on ArrowLeft when primary is end', () => { 84 | const sizePercentage = ref(50); 85 | const collapsed = ref(false); 86 | const options = createOptions({ primary: 'end' }); 87 | 88 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 89 | const event = createMockKeyboardEvent('ArrowLeft'); 90 | 91 | handleKeydown(event); 92 | 93 | expect(sizePercentage.value).toBe(51); 94 | }); 95 | }); 96 | 97 | describe('vertical orientation', () => { 98 | it('should decrease size on ArrowUp when primary is start', () => { 99 | const sizePercentage = ref(50); 100 | const collapsed = ref(false); 101 | const options = createOptions({ orientation: 'vertical' }); 102 | 103 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 104 | const event = createMockKeyboardEvent('ArrowUp'); 105 | 106 | handleKeydown(event); 107 | 108 | expect(sizePercentage.value).toBe(49); 109 | }); 110 | 111 | it('should increase size on ArrowDown when primary is start', () => { 112 | const sizePercentage = ref(50); 113 | const collapsed = ref(false); 114 | const options = createOptions({ orientation: 'vertical' }); 115 | 116 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 117 | const event = createMockKeyboardEvent('ArrowDown'); 118 | 119 | handleKeydown(event); 120 | 121 | expect(sizePercentage.value).toBe(51); 122 | }); 123 | }); 124 | 125 | describe('shift key modifier', () => { 126 | it('should change by 10 when shift key is pressed', () => { 127 | const sizePercentage = ref(50); 128 | const collapsed = ref(false); 129 | const options = createOptions(); 130 | 131 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 132 | const event = createMockKeyboardEvent('ArrowRight', true); 133 | 134 | handleKeydown(event); 135 | 136 | expect(sizePercentage.value).toBe(60); 137 | }); 138 | }); 139 | 140 | describe('Home and End keys', () => { 141 | it('should set to 0 on Home when primary is start', () => { 142 | const sizePercentage = ref(50); 143 | const collapsed = ref(false); 144 | const options = createOptions(); 145 | 146 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 147 | const event = createMockKeyboardEvent('Home'); 148 | 149 | handleKeydown(event); 150 | 151 | expect(sizePercentage.value).toBe(0); 152 | }); 153 | 154 | it('should set to 100 on End when primary is start', () => { 155 | const sizePercentage = ref(50); 156 | const collapsed = ref(false); 157 | const options = createOptions(); 158 | 159 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 160 | const event = createMockKeyboardEvent('End'); 161 | 162 | handleKeydown(event); 163 | 164 | expect(sizePercentage.value).toBe(100); 165 | }); 166 | 167 | it('should set to 100 on Home when primary is end', () => { 168 | const sizePercentage = ref(50); 169 | const collapsed = ref(false); 170 | const options = createOptions({ primary: 'end' }); 171 | 172 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 173 | const event = createMockKeyboardEvent('Home'); 174 | 175 | handleKeydown(event); 176 | 177 | expect(sizePercentage.value).toBe(100); 178 | }); 179 | }); 180 | 181 | describe('Enter key and collapsible', () => { 182 | it('should toggle collapsed state on Enter when collapsible is true', () => { 183 | const sizePercentage = ref(50); 184 | const collapsed = ref(false); 185 | const options = createOptions(); 186 | 187 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 188 | const event = createMockKeyboardEvent('Enter'); 189 | 190 | handleKeydown(event); 191 | 192 | expect(collapsed.value).toBe(true); 193 | }); 194 | 195 | it('should not toggle collapsed state on Enter when collapsible is false', () => { 196 | const sizePercentage = ref(50); 197 | const collapsed = ref(false); 198 | const options = createOptions({ collapsible: false }); 199 | 200 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 201 | const event = createMockKeyboardEvent('Enter'); 202 | 203 | handleKeydown(event); 204 | 205 | expect(collapsed.value).toBe(false); 206 | }); 207 | }); 208 | 209 | describe('clamping values', () => { 210 | it('should clamp size to 0 minimum', () => { 211 | const sizePercentage = ref(2); 212 | const collapsed = ref(false); 213 | const options = createOptions(); 214 | 215 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 216 | const event = createMockKeyboardEvent('ArrowLeft', true); 217 | 218 | handleKeydown(event); 219 | 220 | expect(sizePercentage.value).toBe(0); 221 | }); 222 | 223 | it('should clamp size to 100 maximum', () => { 224 | const sizePercentage = ref(98); 225 | const collapsed = ref(false); 226 | const options = createOptions(); 227 | 228 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 229 | const event = createMockKeyboardEvent('ArrowRight', true); 230 | 231 | handleKeydown(event); 232 | 233 | expect(sizePercentage.value).toBe(100); 234 | }); 235 | }); 236 | 237 | it('should ignore non-handled keys', () => { 238 | const sizePercentage = ref(50); 239 | const collapsed = ref(false); 240 | const options = createOptions(); 241 | 242 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 243 | const event = createMockKeyboardEvent('KeyA'); 244 | 245 | handleKeydown(event); 246 | 247 | expect(sizePercentage.value).toBe(50); 248 | expect(event.preventDefault).not.toHaveBeenCalled(); 249 | }); 250 | 251 | describe('custom min/max size percentages', () => { 252 | it('respects a custom minimum size percentage', () => { 253 | const sizePercentage = ref(25); 254 | const collapsed = ref(false); 255 | const options = createOptions({ minSizePercentage: computed(() => 20) }); 256 | 257 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 258 | 259 | const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); 260 | handleKeydown(event); 261 | 262 | expect(sizePercentage.value).toBe(24); // still above min -> decremented 263 | 264 | for (let i = 0; i < 10; i++) handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); 265 | 266 | expect(sizePercentage.value).toBe(20); // clamped to min 267 | }); 268 | 269 | it('respects a custom maximum size percentage', () => { 270 | const sizePercentage = ref(75); 271 | const collapsed = ref(false); 272 | const options = createOptions({ maxSizePercentage: computed(() => 80) }); 273 | 274 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 275 | 276 | const event = new KeyboardEvent('keydown', { key: 'ArrowRight' }); 277 | handleKeydown(event); 278 | 279 | expect(sizePercentage.value).toBe(76); 280 | 281 | for (let i = 0; i < 10; i++) handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowRight' })); 282 | 283 | expect(sizePercentage.value).toBe(80); // clamped to max 284 | }); 285 | 286 | it('clamps both min and max simultaneously', () => { 287 | const sizePercentage = ref(40); 288 | const collapsed = ref(false); 289 | const options = createOptions({ minSizePercentage: computed(() => 30), maxSizePercentage: computed(() => 60) }); 290 | 291 | const { handleKeydown } = useKeyboard(sizePercentage, collapsed, options); 292 | 293 | // Grow past max with shift 294 | handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true })); // +10 => 50 295 | expect(sizePercentage.value).toBe(50); 296 | 297 | handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true })); // +10 => 60 298 | expect(sizePercentage.value).toBe(60); 299 | 300 | handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true })); // attempt +10 => 70 -> clamp 60 301 | expect(sizePercentage.value).toBe(60); 302 | 303 | // Shrink past min with shift 304 | handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true })); // -10 => 50 305 | expect(sizePercentage.value).toBe(50); 306 | 307 | for (let i = 0; i < 10; i++) handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true })); 308 | expect(sizePercentage.value).toBe(30); 309 | }); 310 | }); 311 | }); 312 | -------------------------------------------------------------------------------- /packages/vue-split-panel/src/composables/use-collapse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextTick, ref } from 'vue'; 3 | import { useCollapse } from './use-collapse'; 4 | 5 | describe('useCollapse', () => { 6 | it('should return expected methods and properties', () => { 7 | const collapsed = ref(false); 8 | const sizePercentage = ref(50); 9 | const options = { transitionDuration: 300, collapsedSize: 0 }; 10 | 11 | const result = useCollapse(collapsed, sizePercentage, options); 12 | 13 | expect(result).toHaveProperty('collapse'); 14 | expect(result).toHaveProperty('expand'); 15 | expect(result).toHaveProperty('toggle'); 16 | expect(result).toHaveProperty('collapseTransitionState'); 17 | expect(result).toHaveProperty('transitionDurationCss'); 18 | expect(typeof result.collapse).toBe('function'); 19 | expect(typeof result.expand).toBe('function'); 20 | expect(typeof result.toggle).toBe('function'); 21 | expect(result.collapseTransitionState.value).toBeNull(); 22 | expect(result.transitionDurationCss.value).toBe('300ms'); 23 | }); 24 | 25 | describe('collapse method', () => { 26 | it('should set collapsed to true', () => { 27 | const collapsed = ref(false); 28 | const sizePercentage = ref(50); 29 | const options = { transitionDuration: 300, collapsedSize: 0 }; 30 | 31 | const { collapse } = useCollapse(collapsed, sizePercentage, options); 32 | 33 | collapse(); 34 | 35 | expect(collapsed.value).toBe(true); 36 | }); 37 | }); 38 | 39 | describe('expand method', () => { 40 | it('should set collapsed to false', () => { 41 | const collapsed = ref(true); 42 | const sizePercentage = ref(0); 43 | const options = { transitionDuration: 300, collapsedSize: 0 }; 44 | 45 | const { expand } = useCollapse(collapsed, sizePercentage, options); 46 | 47 | expand(); 48 | 49 | expect(collapsed.value).toBe(false); 50 | }); 51 | }); 52 | 53 | describe('toggle method', () => { 54 | it('should set collapsed to the provided value', () => { 55 | const collapsed = ref(false); 56 | const sizePercentage = ref(50); 57 | const options = { transitionDuration: 300, collapsedSize: 0 }; 58 | 59 | const { toggle } = useCollapse(collapsed, sizePercentage, options); 60 | 61 | toggle(true); 62 | expect(collapsed.value).toBe(true); 63 | 64 | toggle(false); 65 | expect(collapsed.value).toBe(false); 66 | }); 67 | }); 68 | 69 | describe('collapsed watcher behavior', () => { 70 | it('should store size and set to collapsedSize when collapsing', async () => { 71 | const collapsed = ref(false); 72 | const sizePercentage = ref(75); 73 | const options = { transitionDuration: 300, collapsedSize: 0 }; 74 | 75 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 76 | 77 | collapsed.value = true; 78 | await nextTick(); 79 | 80 | expect(sizePercentage.value).toBe(0); 81 | expect(collapseTransitionState.value).toBe('collapsing'); 82 | }); 83 | 84 | it('should restore size when expanding', async () => { 85 | const collapsed = ref(false); 86 | const sizePercentage = ref(60); 87 | const options = { transitionDuration: 300, collapsedSize: 0 }; 88 | 89 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 90 | 91 | // First collapse to store the size 92 | collapsed.value = true; 93 | await nextTick(); 94 | expect(sizePercentage.value).toBe(0); 95 | 96 | // Then expand to restore 97 | collapsed.value = false; 98 | await nextTick(); 99 | 100 | expect(sizePercentage.value).toBe(60); 101 | expect(collapseTransitionState.value).toBe('expanding'); 102 | }); 103 | 104 | it('should preserve original size through multiple collapse/expand cycles', async () => { 105 | const collapsed = ref(false); 106 | const sizePercentage = ref(42); 107 | const options = { transitionDuration: 300, collapsedSize: 0 }; 108 | 109 | useCollapse(collapsed, sizePercentage, options); 110 | 111 | // First cycle 112 | collapsed.value = true; 113 | await nextTick(); 114 | expect(sizePercentage.value).toBe(0); 115 | 116 | collapsed.value = false; 117 | await nextTick(); 118 | expect(sizePercentage.value).toBe(42); 119 | 120 | // Second cycle 121 | collapsed.value = true; 122 | await nextTick(); 123 | expect(sizePercentage.value).toBe(0); 124 | 125 | collapsed.value = false; 126 | await nextTick(); 127 | expect(sizePercentage.value).toBe(42); 128 | }); 129 | 130 | it('should handle size changes between collapse cycles', async () => { 131 | const collapsed = ref(false); 132 | const sizePercentage = ref(30); 133 | const options = { transitionDuration: 300, collapsedSize: 0 }; 134 | 135 | useCollapse(collapsed, sizePercentage, options); 136 | 137 | // First collapse 138 | collapsed.value = true; 139 | await nextTick(); 140 | expect(sizePercentage.value).toBe(0); 141 | 142 | // Expand and change size 143 | collapsed.value = false; 144 | await nextTick(); 145 | expect(sizePercentage.value).toBe(30); 146 | 147 | // Manually change size while expanded 148 | sizePercentage.value = 80; 149 | 150 | // Collapse again - should store new size 151 | collapsed.value = true; 152 | await nextTick(); 153 | expect(sizePercentage.value).toBe(0); 154 | 155 | // Expand - should restore new size 156 | collapsed.value = false; 157 | await nextTick(); 158 | expect(sizePercentage.value).toBe(80); 159 | }); 160 | }); 161 | 162 | describe('transition state management', () => { 163 | it('should start with null transition state', () => { 164 | const collapsed = ref(false); 165 | const sizePercentage = ref(50); 166 | const options = { transitionDuration: 300, collapsedSize: 0 }; 167 | 168 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 169 | 170 | expect(collapseTransitionState.value).toBeNull(); 171 | }); 172 | 173 | it('should set collapsing state when collapsed becomes true', async () => { 174 | const collapsed = ref(false); 175 | const sizePercentage = ref(50); 176 | const options = { transitionDuration: 300, collapsedSize: 0 }; 177 | 178 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 179 | 180 | collapsed.value = true; 181 | await nextTick(); 182 | 183 | expect(collapseTransitionState.value).toBe('collapsing'); 184 | }); 185 | 186 | it('should set expanding state when collapsed becomes false', async () => { 187 | const collapsed = ref(true); 188 | const sizePercentage = ref(0); 189 | const options = { transitionDuration: 300, collapsedSize: 0 }; 190 | 191 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 192 | 193 | collapsed.value = false; 194 | await nextTick(); 195 | 196 | expect(collapseTransitionState.value).toBe('expanding'); 197 | }); 198 | }); 199 | 200 | describe('transitionDurationCss', () => { 201 | it('should return CSS transition duration', () => { 202 | const collapsed = ref(false); 203 | const sizePercentage = ref(50); 204 | const options = { transitionDuration: 500, collapsedSize: 0 }; 205 | 206 | const { transitionDurationCss } = useCollapse(collapsed, sizePercentage, options); 207 | 208 | expect(transitionDurationCss.value).toBe('500ms'); 209 | }); 210 | 211 | it('should be reactive to transition duration changes', () => { 212 | const collapsed = ref(false); 213 | const sizePercentage = ref(50); 214 | const transitionDuration = ref(300); 215 | const options = { transitionDuration, collapsedSize: 0 }; 216 | 217 | const { transitionDurationCss } = useCollapse(collapsed, sizePercentage, options); 218 | 219 | expect(transitionDurationCss.value).toBe('300ms'); 220 | 221 | transitionDuration.value = 600; 222 | expect(transitionDurationCss.value).toBe('600ms'); 223 | }); 224 | }); 225 | 226 | describe('integration scenarios', () => { 227 | it('should handle rapid collapse/expand operations', async () => { 228 | const collapsed = ref(false); 229 | const sizePercentage = ref(65); 230 | const options = { transitionDuration: 300, collapsedSize: 0 }; 231 | 232 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 233 | 234 | // Rapid collapse 235 | collapsed.value = true; 236 | await nextTick(); 237 | expect(collapseTransitionState.value).toBe('collapsing'); 238 | expect(sizePercentage.value).toBe(0); 239 | 240 | // Immediate expand before transition ends 241 | collapsed.value = false; 242 | await nextTick(); 243 | expect(collapseTransitionState.value).toBe('expanding'); 244 | expect(sizePercentage.value).toBe(65); 245 | }); 246 | 247 | it('should work with methods triggering state changes', async () => { 248 | const collapsed = ref(false); 249 | const sizePercentage = ref(45); 250 | const options = { transitionDuration: 300, collapsedSize: 0 }; 251 | 252 | const { collapse, expand, toggle, collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 253 | 254 | // Use collapse method 255 | collapse(); 256 | await nextTick(); 257 | expect(collapsed.value).toBe(true); 258 | expect(sizePercentage.value).toBe(0); 259 | expect(collapseTransitionState.value).toBe('collapsing'); 260 | 261 | // Use expand method 262 | expand(); 263 | await nextTick(); 264 | expect(collapsed.value).toBe(false); 265 | expect(sizePercentage.value).toBe(45); 266 | expect(collapseTransitionState.value).toBe('expanding'); 267 | 268 | // Use toggle method 269 | toggle(true); 270 | await nextTick(); 271 | expect(collapsed.value).toBe(true); 272 | expect(sizePercentage.value).toBe(0); 273 | expect(collapseTransitionState.value).toBe('collapsing'); 274 | }); 275 | 276 | it('should work with zero initial size', async () => { 277 | const collapsed = ref(false); 278 | const sizePercentage = ref(0); 279 | const options = { transitionDuration: 300, collapsedSize: 0 }; 280 | 281 | useCollapse(collapsed, sizePercentage, options); 282 | 283 | // Collapse when already at 0 284 | collapsed.value = true; 285 | await nextTick(); 286 | expect(sizePercentage.value).toBe(0); 287 | 288 | // Expand - should restore to 0 289 | collapsed.value = false; 290 | await nextTick(); 291 | expect(sizePercentage.value).toBe(0); 292 | }); 293 | 294 | it('should use custom collapsedSize value', async () => { 295 | const collapsed = ref(false); 296 | const sizePercentage = ref(60); 297 | const options = { transitionDuration: 300, collapsedSize: 10 }; 298 | 299 | const { collapseTransitionState } = useCollapse(collapsed, sizePercentage, options); 300 | 301 | // Collapse to custom size 302 | collapsed.value = true; 303 | await nextTick(); 304 | expect(sizePercentage.value).toBe(10); 305 | expect(collapseTransitionState.value).toBe('collapsing'); 306 | 307 | // Expand should restore original size 308 | collapsed.value = false; 309 | await nextTick(); 310 | expect(sizePercentage.value).toBe(60); 311 | expect(collapseTransitionState.value).toBe('expanding'); 312 | }); 313 | 314 | it('should support reactive collapsedSize value', async () => { 315 | const collapsed = ref(false); 316 | const sizePercentage = ref(70); 317 | const collapsedSize = ref(5); 318 | const options = { transitionDuration: 300, collapsedSize }; 319 | 320 | useCollapse(collapsed, sizePercentage, options); 321 | 322 | // Collapse with initial collapsedSize 323 | collapsed.value = true; 324 | await nextTick(); 325 | expect(sizePercentage.value).toBe(5); 326 | 327 | // Change collapsedSize while collapsed 328 | collapsedSize.value = 15; 329 | 330 | // Expand and collapse again with new collapsedSize 331 | collapsed.value = false; 332 | await nextTick(); 333 | expect(sizePercentage.value).toBe(70); 334 | 335 | collapsed.value = true; 336 | await nextTick(); 337 | expect(sizePercentage.value).toBe(15); 338 | }); 339 | }); 340 | }); 341 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/2.usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | description: All usage options available in Vue Split Panel 4 | navigation: 5 | icon: i-lucide-align-horizontal-justify-center 6 | --- 7 | 8 | ## Anatomy 9 | 10 | The splitter component is rendered as a single Vue component with two slots, one for each panel. 11 | 12 | ::code-preview 13 | :example-basic 14 | 15 | #code 16 | ```vue 17 | 20 | 21 | 32 | ``` 33 | :: 34 | 35 | ## API Reference 36 | 37 | ### Model 38 | 39 | ::field-group 40 | ::field{name="size" type="number"} 41 | The current size of the start panel. 42 | :: 43 | 44 | ::field{name="collapsed" type="boolean"} 45 | Whether the primary panel is currently collapsed. 46 | :: 47 | :: 48 | 49 | ### Props 50 | 51 | ::field-group 52 | ::field{name="orientation" type="'horizontal' | 'vertical'"} 53 | Set the rendering orientation. 54 | Defaults to `'horizontal'` 55 | :: 56 | 57 | ::field{name="direction" type="'ltr' | 'rtl'"} 58 | Sets the panels text direction. 59 | Defaults to `'ltr'` 60 | :: 61 | 62 | ::field{name="primary" type="'start' | 'end'"} 63 | If no primary panel is designated, both panels will resize equally. 64 | :: 65 | 66 | ::field{name="dividerHitArea" type="string"} 67 | A CSS size to define the hitbox area around the divider slot. 68 | Defaults to `'12px'` 69 | :: 70 | 71 | ::field{name="sizeUnit" type="'%' | 'px'"} 72 | Whether `size`, `minSize`, `maxSize`, and `collapseThreshold` are configured in percentages (0-100) or pixel values. 73 | Defaults to `'%'` 74 | :: 75 | 76 | ::field{name="disabled" type="boolean"} 77 | Disable the manual resizing/collapsing of the panels. 78 | Defaults to `false` 79 | :: 80 | 81 | ::field{name="minSize" type="number"} 82 | Minimum allowed size of the primary panel. Requires `primary` to be set. 83 | Defaults to `false` 84 | :: 85 | 86 | ::field{name="maxSize" type="number"} 87 | Maximum allowed size of the primary panel. Requires `primary` to be set. 88 | Defaults to `false` 89 | :: 90 | 91 | ::field{name="collapsible" type="boolean"} 92 | Whether to allow the primary panel to be collapsed on enter key on divider or when the collapse threshold is met. 93 | Defaults to `false` 94 | :: 95 | 96 | ::field{name="collapseThreshold" type="number"} 97 | How far to drag beyond the minSize to collapse/expand the primary panel. 98 | :: 99 | 100 | ::field{name="collapsedSize" type="number"} 101 | How much of the collapsed panel is visible in its collapsed state. 102 | :: 103 | 104 | ::field{name="transitionDuration" type="number"} 105 | Duration of the collapse/expand transition in ms. 106 | Defaults to `0` 107 | :: 108 | 109 | ::field{name="transitionTimingFunctionExpand" type="string"} 110 | CSS timing function for the expand transition. 111 | Defaults to `"cubic-bezier(0, 0, 0.2, 1)"` 112 | :: 113 | 114 | ::field{name="transitionTimingFunctionCollapse" type="string"} 115 | CSS timing function for the collapse transition. 116 | Defaults to `"cubic-bezier(0.4, 0, 0.6, 1)"` 117 | :: 118 | 119 | ::field{name="snapPoints" type="number[]"} 120 | Where to snap the primary panel to during dragging operations. 121 | Defaults to `[]` 122 | :: 123 | 124 | ::field{name="snapThreshold" type="number"} 125 | How close the divider must be to a snap point for snapping to occur. 126 | Defaults to `12` 127 | :: 128 | 129 | ::field{name="ui" type="{ start?: string, divider?: string, end?: string }"} 130 | Inject additional classes in the slot container elements. 131 | :: 132 | :: 133 | 134 | ## Examples 135 | 136 | ### % / Pixels 137 | 138 | By setting `sizeUnit` to `px`, the component will use pixel values for `minSize`, `maxSize`, and `size`. 139 | 140 | ::tabs{.w-full} 141 | ::tabs-item{icon="i-lucide-percent" label="Percentages"} 142 | ::code-preview 143 | :example-percentage 144 | 145 | #code 146 | ```vue 147 | 150 | 151 | 167 | ``` 168 | :: 169 | :: 170 | 171 | ::tabs-item{icon="i-lucide-grid-2x2" label="Pixels"} 172 | ::code-preview 173 | :example-px 174 | 175 | #code 176 | ```vue 177 | 180 | 181 | 197 | ``` 198 | :: 199 | :: 200 | :: 201 | 202 | ### Collapsible 203 | 204 | By setting `collapsible` to `true`, you can allow the primary panel to be collapsed by either dragging the divider the `collapseThreshold` beyond the `minSize` or using Enter when focussed on the divider. 205 | 206 | Both `minSize` and `collapsibleThreshold` have to be defined. 207 | 208 | ::code-preview 209 | :example-collapsible 210 | 211 | #code 212 | ```vue 213 | 216 | 217 | 235 | ``` 236 | 237 | :: 238 | 239 | ### Collapse / Expand Programmatically 240 | 241 | To collapse or expand the primary panel programmatically, you can either use the `collapsed` model: 242 | 243 | ```vue 244 | 250 | 251 | 256 | ``` 257 | 258 | or use the exposed `collapse`, `expand`, and `toggle` functions: 259 | 260 | ```vue 261 | 271 | 272 | 277 | ``` 278 | 279 | ### Customize Divider 280 | 281 | Customize the divider element by passing any element into the divider slot: 282 | 283 | ::code-preview 284 | :example-divider 285 | 286 | #code 287 | ```vue 288 | 291 | 292 | 313 | ``` 314 | :: 315 | 316 | ### Vertical Split Orientation 317 | 318 | The panels can be oriented vertically by setting the `orientation` prop to `"vertical"`. Make sure to add a height to the parent split component. 319 | 320 | ::code-preview 321 | :example-vertical 322 | 323 | #code 324 | ```vue 325 | 328 | 329 | 343 | ``` 344 | :: 345 | 346 | ### Nested Split Panels 347 | 348 | While a `SplitPanel` only holds two panels at a time, you can nest multiple split panels to create rich layouts. 349 | 350 | ::code-preview 351 | :example-nested 352 | 353 | #code 354 | ```vue 355 | 358 | 359 | 378 | ``` 379 | :: 380 | 381 | ### Primary Panel 382 | 383 | By setting `primary` to either `start` or `end`, you can make sure that the primary panel doesn't resize when the parent container resizes. This is particularly helpful for layouts where you have a primary sidebar that you'd like to remain a fixed size, with a page view that takes up the remaining space. 384 | 385 | ::code-preview 386 | :example-primary 387 | 388 | #code 389 | ```vue 390 | 393 | 394 | 405 | ``` 406 | :: 407 | 408 | ::callout{icon="i-lucide-info" color="primary"} 409 | Resize your browser window to see primary in action. 410 | :: 411 | 412 | ### Transitions 413 | 414 | By setting the `transitionDuration` prop, you can control the duration of the transition. Use the `transitionTimingFunctionExpand` and `transitionTimingFunctionCollapse` properties to fine-tune the timing functions of the expand and collapse transition respectively. 415 | 416 | ::code-preview 417 | :example-transitions 418 | 419 | #code 420 | ```vue 421 | 424 | 425 | 445 | ``` 446 | :: 447 | 448 | ### Snapping 449 | 450 | To snap the divider to a given point while dragging, pass an array of points in the `snapPoints` property. 451 | 452 | ::code-preview 453 | :example-snap 454 | 455 | #code 456 | ```vue 457 | 460 | 461 | 472 | ``` 473 | :: 474 | 475 | ## Accessibility 476 | 477 | Uses the [Window Splitter WAI-ARIA pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter). 478 | 479 | ### Keyboard Interactions 480 | 481 | | Key | Description | 482 | |--------------------------|------------------------------------------------| 483 | | :kbd{value="Enter"} | Toggles the collapse state of the primary pane | 484 | | :kbd{value="ArrowDown"} | Moves a horizontal panel down | 485 | | :kbd{value="ArrowUp"} | Moves a horizontal panel up | 486 | | :kbd{value="ArrowRight"} | Moves a vertical splitter right | 487 | | :kbd{value="ArrowLeft"} | Moves a vertical splitter left | 488 | | :kbd{value="Home"} | Moves the splitter to the configured min-size | 489 | | :kbd{value="End"} | Moves the splitter to the configured max-size | 490 | 491 | ::callout{icon="i-lucide-person-standing" color="warning"} 492 | The divider by default has no styling. Please add your own divider element to handle focus styles. 493 | :: 494 | --------------------------------------------------------------------------------