├── 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 |
6 |
7 |
8 | Panel A
9 |
10 |
11 |
12 | Panel B
13 |
14 |
15 |
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 |
6 |
7 |
8 | Panel A
9 |
10 |
11 |
12 | Panel B
13 |
14 |
15 |
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 |
6 |
7 |
8 | Panel A
9 |
10 |
11 |
12 | Panel B
13 |
14 |
15 |
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 |
6 |
10 |
11 | Panel A
12 |
13 |
14 |
15 | Panel B
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/docs/components/example/ExamplePx.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
13 |
14 | Panel A
15 |
16 |
17 |
18 | Panel B
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/docs/components/example/ExamplePercentage.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
13 |
14 | Panel A
15 |
16 |
17 |
18 | Panel B
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/docs/components/example/ExampleCollapsible.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
15 |
16 | Panel A
17 |
18 |
19 |
20 | Panel B
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/docs/components/example/ExampleTransitions.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
16 |
17 | Panel A
18 |
19 |
20 |
21 | Panel B
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/docs/components/example/ExampleDivider.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
14 |
15 | Panel A
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Panel B
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/docs/components/example/ExampleNested.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 | Panel A
9 |
10 |
11 |
12 |
13 |
14 | Panel B
15 |
16 |
17 |
18 | Panel C
19 |
20 |
21 |
22 |
23 |
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 |
18 |
19 |
20 | Panel A
21 |
22 |
23 |
24 | Panel B
25 |
26 |
27 |
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 |
35 |
36 |
37 | Panel A
38 |
39 |
40 |
41 | Panel B
42 |
43 |
44 |
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 |
10 |
25 |
26 |
27 | Panel A
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | Panel B
38 |
39 |
40 |
41 |
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 |
118 |
127 |
128 |
129 |
130 |
148 |
149 |
150 |
151 |
152 |
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 |
22 |
23 |
24 | Panel A
25 |
26 |
27 |
28 | Panel B
29 |
30 |
31 |
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 |
152 |
158 |
159 | Panel A
160 |
161 |
162 |
163 | Panel B
164 |
165 |
166 |
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 |
182 |
188 |
189 | Panel A
190 |
191 |
192 |
193 | Panel B
194 |
195 |
196 |
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 |
218 |
226 |
227 | Panel A
228 |
229 |
230 |
231 | Panel B
232 |
233 |
234 |
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 |
252 |
253 |
254 |
255 |
256 | ```
257 |
258 | or use the exposed `collapse`, `expand`, and `toggle` functions:
259 |
260 | ```vue
261 |
271 |
272 |
273 |
274 |
275 |
276 |
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 |
293 |
300 |
301 | Panel A
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 | Panel B
310 |
311 |
312 |
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 |
330 |
334 |
335 | Panel A
336 |
337 |
338 |
339 | Panel B
340 |
341 |
342 |
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 |
360 |
361 |
362 | Panel A
363 |
364 |
365 |
366 |
367 |
368 | Panel B
369 |
370 |
371 |
372 | Panel C
373 |
374 |
375 |
376 |
377 |
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 |
395 |
396 |
397 | Panel A
398 |
399 |
400 |
401 | Panel B
402 |
403 |
404 |
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 |
426 |
436 |
437 | Panel A
438 |
439 |
440 |
441 | Panel B
442 |
443 |
444 |
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 |
462 |
463 |
464 | Panel A
465 |
466 |
467 |
468 | Panel B
469 |
470 |
471 |
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 |
--------------------------------------------------------------------------------