From d720e5c31cd253f04e5193ade63f82eeccc1a874 Mon Sep 17 00:00:00 2001
From: Gnalvin <31533594+Gnalvin@users.noreply.github.com>
Date: Tue, 3 Jun 2025 23:01:28 +0800
Subject: [PATCH] feat(components): [splitter] new component (#20145)
* feat(components): [splitter] new component
* docs(components): [splitter] update docs
* feat(components): [splitter] add export ElSplitter ElSplitterPanel types
* feat(components): [splitter] support touch even
* Update docs/.vitepress/crowdin/en-US/pages/component.json
Co-authored-by: sea <45450994+warmthsea@users.noreply.github.com>
* docs: update
* docs: update
* chore: update docs & code
* chore: update docs & fix code type error
* fix: use slot show error
* refactor(components): [splitter] modify props use
* refactor(components): [splitter] use native event listener to drag
* fix(components): [splitter] panel bind attrs
* refactor: use `useOrderedChildren`
* chore: fix types
* feat: add icon
* chore: translate comments and add beta tag
* docs: add default-size api
* docs: remove demo lang="scss"
* refactor: remove default-size api
---------
Co-authored-by: kooriookami <38392315+kooriookami@users.noreply.github.com>
Co-authored-by: sea <45450994+warmthsea@users.noreply.github.com>
Co-authored-by: warmthsea <2586244885@qq.com>
Co-authored-by: Dsaquel <291874700n@gmail.com>
Co-authored-by: kooriookami
---
.../crowdin/en-US/pages/component.json | 5 +
.../vitepress/components/globals/overview.vue | 13 +-
.../components/overview-icons/index.ts | 2 +
.../components/overview-icons/ov-splitter.vue | 109 ++++++++++
docs/components.d.ts | 1 +
docs/en-US/component/splitter.md | 100 +++++++++
docs/examples/splitter/basic.vue | 23 ++
docs/examples/splitter/collapsible.vue | 36 ++++
docs/examples/splitter/disableDrag.vue | 41 ++++
docs/examples/splitter/size.vue | 48 +++++
docs/examples/splitter/vertical.vue | 23 ++
packages/components/index.ts | 1 +
.../components/splitter-panel/style/css.ts | 2 +
.../components/splitter-panel/style/index.ts | 2 +
.../splitter/__tests__/__mocks__/vueuse.ts | 23 ++
.../splitter/__tests__/splitter.test.tsx | 135 ++++++++++++
packages/components/splitter/index.ts | 17 ++
.../components/splitter/src/hooks/index.ts | 3 +
.../splitter/src/hooks/useContainer.ts | 14 ++
.../components/splitter/src/hooks/usePanel.ts | 33 +++
.../splitter/src/hooks/useResize.ts | 134 ++++++++++++
.../components/splitter/src/hooks/useSize.ts | 79 +++++++
.../components/splitter/src/split-bar.vue | 161 ++++++++++++++
.../components/splitter/src/split-panel.ts | 27 +++
.../components/splitter/src/split-panel.vue | 200 ++++++++++++++++++
packages/components/splitter/src/splitter.ts | 15 ++
packages/components/splitter/src/splitter.vue | 87 ++++++++
packages/components/splitter/src/type.ts | 34 +++
packages/components/splitter/style/css.ts | 2 +
packages/components/splitter/style/index.ts | 2 +
packages/element-plus/component.ts | 3 +
packages/theme-chalk/src/index.scss | 2 +
packages/theme-chalk/src/splitter-panel.scss | 8 +
packages/theme-chalk/src/splitter.scss | 129 +++++++++++
typings/global.d.ts | 2 +
35 files changed, 1515 insertions(+), 1 deletion(-)
create mode 100644 docs/.vitepress/vitepress/components/overview-icons/ov-splitter.vue
create mode 100644 docs/en-US/component/splitter.md
create mode 100644 docs/examples/splitter/basic.vue
create mode 100644 docs/examples/splitter/collapsible.vue
create mode 100644 docs/examples/splitter/disableDrag.vue
create mode 100644 docs/examples/splitter/size.vue
create mode 100644 docs/examples/splitter/vertical.vue
create mode 100644 packages/components/splitter-panel/style/css.ts
create mode 100644 packages/components/splitter-panel/style/index.ts
create mode 100644 packages/components/splitter/__tests__/__mocks__/vueuse.ts
create mode 100644 packages/components/splitter/__tests__/splitter.test.tsx
create mode 100644 packages/components/splitter/index.ts
create mode 100644 packages/components/splitter/src/hooks/index.ts
create mode 100644 packages/components/splitter/src/hooks/useContainer.ts
create mode 100644 packages/components/splitter/src/hooks/usePanel.ts
create mode 100644 packages/components/splitter/src/hooks/useResize.ts
create mode 100644 packages/components/splitter/src/hooks/useSize.ts
create mode 100644 packages/components/splitter/src/split-bar.vue
create mode 100644 packages/components/splitter/src/split-panel.ts
create mode 100644 packages/components/splitter/src/split-panel.vue
create mode 100644 packages/components/splitter/src/splitter.ts
create mode 100644 packages/components/splitter/src/splitter.vue
create mode 100644 packages/components/splitter/src/type.ts
create mode 100644 packages/components/splitter/style/css.ts
create mode 100644 packages/components/splitter/style/index.ts
create mode 100644 packages/theme-chalk/src/splitter-panel.scss
create mode 100644 packages/theme-chalk/src/splitter.scss
diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json
index b32f8e5a68..16ff3e689f 100644
--- a/docs/.vitepress/crowdin/en-US/pages/component.json
+++ b/docs/.vitepress/crowdin/en-US/pages/component.json
@@ -52,6 +52,11 @@
"link": "/space",
"text": "Space"
},
+ {
+ "link": "/splitter",
+ "text": "Splitter",
+ "promotion": "2.10.0"
+ },
{
"link": "/typography",
"text": "Typography"
diff --git a/docs/.vitepress/vitepress/components/globals/overview.vue b/docs/.vitepress/vitepress/components/globals/overview.vue
index 74481483f4..edaac4397d 100644
--- a/docs/.vitepress/vitepress/components/globals/overview.vue
+++ b/docs/.vitepress/vitepress/components/globals/overview.vue
@@ -61,6 +61,14 @@
>
@叨叨
+
+ @卡卡
+
@@ -189,7 +197,10 @@ onMounted(() => {
}
.designed-by {
- text-align: right;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 5px;
font-size: 14px;
}
}
diff --git a/docs/.vitepress/vitepress/components/overview-icons/index.ts b/docs/.vitepress/vitepress/components/overview-icons/index.ts
index f2a634e16c..168b0fb7c7 100644
--- a/docs/.vitepress/vitepress/components/overview-icons/index.ts
+++ b/docs/.vitepress/vitepress/components/overview-icons/index.ts
@@ -73,6 +73,7 @@ import OvDivider from './ov-divider.vue'
import OvWatermark from './ov-watermark.vue'
import OvMention from './ov-mention.vue'
import OvInputTag from './ov-input-tag.vue'
+import OvSplitter from './ov-splitter.vue'
export default {
button: OvButton,
@@ -139,6 +140,7 @@ export default {
menu: OvMenu,
'page-header': OvPageHeader,
steps: OvSteps,
+ splitter: OvSplitter,
tabs: OvTabs,
alert: OvAlert,
dialog: OvDialog,
diff --git a/docs/.vitepress/vitepress/components/overview-icons/ov-splitter.vue b/docs/.vitepress/vitepress/components/overview-icons/ov-splitter.vue
new file mode 100644
index 0000000000..75a5859912
--- /dev/null
+++ b/docs/.vitepress/vitepress/components/overview-icons/ov-splitter.vue
@@ -0,0 +1,109 @@
+
+
+
diff --git a/docs/components.d.ts b/docs/components.d.ts
index 2ee1367b41..8ebfaf4372 100644
--- a/docs/components.d.ts
+++ b/docs/components.d.ts
@@ -103,6 +103,7 @@ declare module 'vue' {
OvSkeleton: typeof import('./.vitepress/vitepress/components/overview-icons/ov-skeleton.vue')['default']
OvSlider: typeof import('./.vitepress/vitepress/components/overview-icons/ov-slider.vue')['default']
OvSpace: typeof import('./.vitepress/vitepress/components/overview-icons/ov-space.vue')['default']
+ OvSplitter: typeof import('./.vitepress/vitepress/components/overview-icons/ov-splitter.vue')['default']
OvStatistic: typeof import('./.vitepress/vitepress/components/overview-icons/ov-statistic.vue')['default']
OvSteps: typeof import('./.vitepress/vitepress/components/overview-icons/ov-steps.vue')['default']
OvSwitch: typeof import('./.vitepress/vitepress/components/overview-icons/ov-switch.vue')['default']
diff --git a/docs/en-US/component/splitter.md b/docs/en-US/component/splitter.md
new file mode 100644
index 0000000000..fda092f1ec
--- /dev/null
+++ b/docs/en-US/component/splitter.md
@@ -0,0 +1,100 @@
+---
+title: Splitter
+lang: en-US
+---
+
+# Splitter ^(beta)
+
+Divide the area horizontally or vertically, and freely drag to adjust the size of each area.
+
+## Basic usage
+
+The most basic usage, if no default size is passed, it will be automatically divided equally.
+
+:::demo
+
+splitter/basic
+
+:::
+
+## Vertical
+
+Use vertical orientation.
+
+:::demo
+
+splitter/vertical
+
+:::
+
+## Collapsible
+
+Configuring `collapsible` provides quick shrinking capability. You can use the `min` property to prevent expanding through dragging after collapsing.
+
+:::demo
+
+splitter/collapsible
+
+:::
+
+## Disable drag
+
+When either panel disables `resizable`, dragging will be disabled.
+
+:::demo
+
+splitter/disableDrag
+
+:::
+
+## Panel size
+
+`v-model:size` can get the panel size.
+
+:::demo
+
+splitter/size
+
+:::
+
+## Splitter API
+
+### Splitter Attributes
+
+| Name | Description | Type | Default |
+| ------ | -------------------------------- | ----------------------------------- | ---------- |
+| layout | Layout direction of the splitter | ^[enum]`'horizontal' \| 'vertical'` | horizontal |
+
+### Splitter Events
+
+| Name | Description | type |
+| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------- |
+| resize-start | Triggered when starting to resize a panel, `index` is the drag bar index | ^[Function]`(index: number, sizes: number[]) => void` |
+| resize | Triggered while resizing a panel, `index` is the drag bar index | ^[Function]`(index: number, sizes: number[]) => void` |
+| resize-end | Triggered when panel resizing ends, `index` is the drag bar index | ^[Function]`(index: number, sizes: number[]) => void` |
+
+## SplitterPanel API
+
+### SplitterPanel Attributes
+
+| Name | Description | Type | Default |
+| ------------------- | --------------------------------------------------- | --------------------- | ------- |
+| size / v-model:size | Size of the panel (in pixels or percentage) | ^[string] / ^[number] | - |
+| min | Minimum size of the panel (in pixels or percentage) | ^[string] / ^[number] | - |
+| max | Maximum size of the panel (in pixels or percentage) | ^[string] / ^[number] | - |
+| resizable | Whether the panel can be resized | ^[boolean] | true |
+| collapsible | Whether the panel can be collapsed | ^[boolean] | false |
+
+### SplitterPanel Events
+
+| Name | Description | type |
+| ----------- | --------------------------------- | ----------------------------------- |
+| update:size | Triggered when panel size changes | ^[Function]`(size: number) => void` |
+
+### SplitterPanel Slots
+
+| Name | Description |
+| ----------------- | ----------------------------------------------- |
+| default | Default content of the panel |
+| start-collapsible | Custom content for the start collapsible button |
+| end-collapsible | Custom content for the end collapsible button |
diff --git a/docs/examples/splitter/basic.vue b/docs/examples/splitter/basic.vue
new file mode 100644
index 0000000000..5d5447a9af
--- /dev/null
+++ b/docs/examples/splitter/basic.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/docs/examples/splitter/collapsible.vue b/docs/examples/splitter/collapsible.vue
new file mode 100644
index 0000000000..2d0c11d361
--- /dev/null
+++ b/docs/examples/splitter/collapsible.vue
@@ -0,0 +1,36 @@
+
+
+
+
+ 1
+
+
+ 2
+
+
+ 3
+
+
+
+
+ 4
+
+
+ 5
+
+
+
+
+
+
+
+
diff --git a/docs/examples/splitter/disableDrag.vue b/docs/examples/splitter/disableDrag.vue
new file mode 100644
index 0000000000..e05cfbe032
--- /dev/null
+++ b/docs/examples/splitter/disableDrag.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+ 1
+
+
+
+ drag {{ resizable ? 'enable' : 'disable' }}
+
+
+
+ 3
+
+
+
+
+
+
+
+
diff --git a/docs/examples/splitter/size.vue b/docs/examples/splitter/size.vue
new file mode 100644
index 0000000000..7f09740b0f
--- /dev/null
+++ b/docs/examples/splitter/size.vue
@@ -0,0 +1,48 @@
+
+
+
+
+ 1
+
+
+ {{ size }}px
+
+
+ 3
+
+
+
+
+
+
+
+
diff --git a/docs/examples/splitter/vertical.vue b/docs/examples/splitter/vertical.vue
new file mode 100644
index 0000000000..84970c32df
--- /dev/null
+++ b/docs/examples/splitter/vertical.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/packages/components/index.ts b/packages/components/index.ts
index 040d676bf4..da4bd7f654 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -75,6 +75,7 @@ export * from './tour'
export * from './anchor'
export * from './segmented'
export * from './mention'
+export * from './splitter'
// plugins
export * from './infinite-scroll'
diff --git a/packages/components/splitter-panel/style/css.ts b/packages/components/splitter-panel/style/css.ts
new file mode 100644
index 0000000000..ecfc601fe9
--- /dev/null
+++ b/packages/components/splitter-panel/style/css.ts
@@ -0,0 +1,2 @@
+import '@element-plus/components/base/style/css'
+import '@element-plus/theme-chalk/splitter-panel.css'
diff --git a/packages/components/splitter-panel/style/index.ts b/packages/components/splitter-panel/style/index.ts
new file mode 100644
index 0000000000..5c4d36073b
--- /dev/null
+++ b/packages/components/splitter-panel/style/index.ts
@@ -0,0 +1,2 @@
+import '@element-plus/components/base/style'
+import '@element-plus/theme-chalk/src/splitter-panel.scss'
diff --git a/packages/components/splitter/__tests__/__mocks__/vueuse.ts b/packages/components/splitter/__tests__/__mocks__/vueuse.ts
new file mode 100644
index 0000000000..50939b490f
--- /dev/null
+++ b/packages/components/splitter/__tests__/__mocks__/vueuse.ts
@@ -0,0 +1,23 @@
+import { ref } from 'vue'
+import { vi } from 'vitest'
+
+export const useElementSize = vi.fn(() => ({
+ width: ref(400),
+ height: ref(400),
+}))
+
+export const isClient = true
+
+export const useResizeObserver = vi.fn((target, callback) => {
+ callback([{ contentRect: { width: 400, height: 400 } }])
+ return vi.fn()
+})
+
+export const useEventListener = vi.fn((target, event, handler) => {
+ if (typeof target === 'object' && target !== null) {
+ const el = target.value || target
+ el.addEventListener(event, handler)
+ return () => el.removeEventListener(event, handler)
+ }
+ return vi.fn()
+})
diff --git a/packages/components/splitter/__tests__/splitter.test.tsx b/packages/components/splitter/__tests__/splitter.test.tsx
new file mode 100644
index 0000000000..8761258db8
--- /dev/null
+++ b/packages/components/splitter/__tests__/splitter.test.tsx
@@ -0,0 +1,135 @@
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { ElSplitter, ElSplitterPanel } from '../index'
+
+// jsdom does not support useElementSize and useResizeObserver so mock
+vi.mock('@vueuse/core', () => {
+ return import('./__mocks__/vueuse')
+})
+
+describe('Splitter', () => {
+ const mockResizeObserver = vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ }))
+
+ beforeAll(() => {
+ window.ResizeObserver = mockResizeObserver
+ })
+
+ afterAll(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('should render correctly', () => {
+ const wrapper = mount(() => (
+
+ Left Panel
+ Right Panel
+
+ ))
+
+ expect(wrapper.find('.el-splitter').exists()).toBe(true)
+ expect(wrapper.findAll('.el-splitter-panel')).toHaveLength(2)
+ })
+
+ it('should render with vertical layout', () => {
+ const wrapper = mount(() => (
+
+ Top Panel
+ Bottom Panel
+
+ ))
+
+ expect(wrapper.find('.el-splitter__vertical').exists()).toBe(true)
+ })
+
+ it('should respect min and max size constraints', async () => {
+ const wrapper = mount(() => (
+
+
+
+ Left Panel
+
+ Right Panel
+
+
+ ))
+ await nextTick()
+ const panels = wrapper.findAll('.el-splitter-panel')
+
+ // default size
+ expect(panels[0].attributes('style')).toContain('flex-basis: 150px;')
+
+ // mock mouse event
+ const simulateDrag = async (startPos: number, endPos: number) => {
+ const splitBar = wrapper.find('.el-splitter-bar__dragger')
+
+ // Simulate mouse down
+ const mousedown = new MouseEvent('mousedown', { bubbles: true })
+ Object.defineProperty(mousedown, 'pageX', { value: startPos })
+ splitBar.element.dispatchEvent(mousedown)
+
+ // Simulate mouse move
+ const mousemove = new MouseEvent('mousemove', { bubbles: true })
+ Object.defineProperty(mousemove, 'pageX', { value: endPos })
+ window.dispatchEvent(mousemove)
+
+ // Simulate mouse up
+ const mouseup = new MouseEvent('mouseup', { bubbles: true })
+ Object.defineProperty(mouseup, 'pageX', { value: endPos })
+ window.dispatchEvent(mouseup)
+
+ await nextTick()
+ }
+
+ // Test min size constraint: drag left to minimum value 100px
+ await simulateDrag(150, 50)
+ expect(panels[0].attributes('style')).toContain('flex-basis: 100px;')
+
+ // Test max size constraint: drag right to maximum value 200px
+ await simulateDrag(50, 150)
+ expect(panels[0].attributes('style')).toContain('flex-basis: 200px;')
+ })
+
+ it('should handle collapse', async () => {
+ const wrapper = mount(() => (
+
+
+
+ Left Panel
+
+ Right Panel
+
+
+ ))
+ await nextTick()
+
+ const panels = wrapper.findAll('.el-splitter-panel')
+ const startCollapseButton = wrapper.find(
+ '.el-splitter-bar__horizontal-collapse-icon-start'
+ )
+ const endCollapseButton = wrapper.find(
+ '.el-splitter-bar__horizontal-collapse-icon-end'
+ )
+
+ // default size
+ expect(panels[0].attributes('style')).toContain('flex-basis: 150px;')
+
+ // Click collapse button
+ await startCollapseButton.trigger('click')
+ await nextTick()
+
+ // Panel should be collapsed (size = 0)
+ expect(panels[0].attributes('style')).toContain('flex-basis: 0px;')
+
+ // Click collapse button to expand
+ await endCollapseButton.trigger('click')
+ await nextTick()
+
+ // Panel should be restored to original size
+ expect(panels[0].attributes('style')).toContain('flex-basis: 150px;')
+ })
+})
diff --git a/packages/components/splitter/index.ts b/packages/components/splitter/index.ts
new file mode 100644
index 0000000000..3d89ae9ca6
--- /dev/null
+++ b/packages/components/splitter/index.ts
@@ -0,0 +1,17 @@
+import { withInstall, withNoopInstall } from '@element-plus/utils'
+import Splitter from './src/splitter.vue'
+import SplitPanel from './src/split-panel.vue'
+import type { SFCWithInstall } from '@element-plus/utils'
+
+export const ElSplitter: SFCWithInstall & {
+ SplitPanel: typeof SplitPanel
+} = withInstall(Splitter, {
+ SplitPanel,
+})
+export default ElSplitter
+
+export const ElSplitterPanel: SFCWithInstall =
+ withNoopInstall(SplitPanel)
+
+export * from './src/splitter'
+export * from './src/split-panel'
diff --git a/packages/components/splitter/src/hooks/index.ts b/packages/components/splitter/src/hooks/index.ts
new file mode 100644
index 0000000000..def2344f74
--- /dev/null
+++ b/packages/components/splitter/src/hooks/index.ts
@@ -0,0 +1,3 @@
+export { useContainer } from './useContainer'
+export { useResize } from './useResize'
+export { useSize, isPct, isPx, getPct, getPx } from './useSize'
diff --git a/packages/components/splitter/src/hooks/useContainer.ts b/packages/components/splitter/src/hooks/useContainer.ts
new file mode 100644
index 0000000000..7908483e02
--- /dev/null
+++ b/packages/components/splitter/src/hooks/useContainer.ts
@@ -0,0 +1,14 @@
+import { computed, ref } from 'vue'
+import { useElementSize } from '@vueuse/core'
+import type { Ref } from 'vue'
+
+export function useContainer(layout: Ref<'horizontal' | 'vertical'>) {
+ const containerEl = ref()
+ const { width, height } = useElementSize(containerEl)
+
+ const containerSize = computed(() => {
+ return layout.value === 'horizontal' ? width.value : height.value
+ })
+
+ return { containerEl, containerSize }
+}
diff --git a/packages/components/splitter/src/hooks/usePanel.ts b/packages/components/splitter/src/hooks/usePanel.ts
new file mode 100644
index 0000000000..232f7bb342
--- /dev/null
+++ b/packages/components/splitter/src/hooks/usePanel.ts
@@ -0,0 +1,33 @@
+import { isObject } from '@element-plus/utils'
+import type { PanelItemState } from '../type'
+
+export function getCollapsible(
+ collapsible: boolean | { start?: boolean; end?: boolean }
+) {
+ if (collapsible && isObject(collapsible)) {
+ return collapsible
+ }
+ return {
+ start: !!collapsible,
+ end: !!collapsible,
+ }
+}
+
+export function isCollapsible(
+ panel: PanelItemState | null | undefined,
+ size: number,
+ nextPanel: PanelItemState | null | undefined,
+ nextSize: number
+) {
+ // If the current panel is collapsible and has size, then it can be collapsed
+ if (panel?.collapsible.end && size > 0) {
+ return true
+ }
+
+ // If the next panel is collapsible and has no size, but the current panel has size, then it can be collapsed
+ if (nextPanel?.collapsible.start && nextSize === 0 && size > 0) {
+ return true
+ }
+
+ return false
+}
diff --git a/packages/components/splitter/src/hooks/useResize.ts b/packages/components/splitter/src/hooks/useResize.ts
new file mode 100644
index 0000000000..41f39c4ef9
--- /dev/null
+++ b/packages/components/splitter/src/hooks/useResize.ts
@@ -0,0 +1,134 @@
+import { computed, ref } from 'vue'
+import { getPct, getPx, isPct, isPx } from './useSize'
+import type { ComputedRef, Ref } from 'vue'
+import type { PanelItemState } from '../type'
+
+export function useResize(
+ panels: Ref,
+ containerSize: ComputedRef,
+ pxSizes: ComputedRef
+) {
+ const ptg2px = (ptg: number) => ptg * containerSize.value || 0
+
+ function getLimitSize(
+ str: string | number | undefined,
+ defaultLimit: number
+ ) {
+ if (isPct(str)) {
+ return ptg2px(getPct(str))
+ } else if (isPx(str)) {
+ return getPx(str)
+ }
+ return str ?? defaultLimit
+ }
+
+ const movingIndex = ref<{
+ index: number
+ confirmed: boolean
+ } | null>(null)
+
+ let cachePxSizes: number[] = []
+
+ const limitSizes = computed(() =>
+ panels.value.map((item) => [item.min, item.max])
+ )
+
+ const onMoveStart = (index: number) => {
+ movingIndex.value = { index, confirmed: false }
+ cachePxSizes = pxSizes.value
+ }
+
+ const onMoving = (index: number, offset: number) => {
+ let confirmedIndex: number | null = null
+
+ // When overlapping, find the nearest draggable index
+ if ((!movingIndex.value || !movingIndex.value.confirmed) && offset !== 0) {
+ if (offset > 0) {
+ confirmedIndex = index
+ movingIndex.value = { index, confirmed: true }
+ } else {
+ for (let i = index; i >= 0; i -= 1) {
+ if (cachePxSizes[i]! > 0) {
+ confirmedIndex = i
+ movingIndex.value = { index: i, confirmed: true }
+ break
+ }
+ }
+ }
+ }
+ const mergedIndex = confirmedIndex ?? movingIndex.value?.index ?? index
+
+ const numSizes = [...cachePxSizes]
+ const nextIndex = mergedIndex + 1
+
+ // Handle the maximum and minimum edge cases
+ const startMinSize = getLimitSize(limitSizes.value[mergedIndex]![0], 0)
+ const endMinSize = getLimitSize(limitSizes.value[nextIndex]![0], 0)
+ const startMaxSize = getLimitSize(
+ limitSizes.value[mergedIndex]![1],
+ containerSize.value || 0
+ )
+ const endMaxSize = getLimitSize(
+ limitSizes.value[nextIndex]![1],
+ containerSize.value || 0
+ )
+
+ let mergedOffset = offset
+
+ if (numSizes[mergedIndex]! + mergedOffset < startMinSize) {
+ mergedOffset = startMinSize - numSizes[mergedIndex]!
+ }
+ if (numSizes[nextIndex]! - mergedOffset < endMinSize) {
+ mergedOffset = numSizes[nextIndex]! - endMinSize
+ }
+ if (numSizes[mergedIndex]! + mergedOffset > startMaxSize) {
+ mergedOffset = startMaxSize - numSizes[mergedIndex]!
+ }
+ if (numSizes[nextIndex]! - mergedOffset > endMaxSize) {
+ mergedOffset = numSizes[nextIndex]! - endMaxSize
+ }
+
+ numSizes[mergedIndex]! += mergedOffset
+ numSizes[nextIndex]! -= mergedOffset
+
+ panels.value.forEach((panel, index) => {
+ panel.size = numSizes[index]
+ })
+ }
+
+ const onMoveEnd = () => {
+ movingIndex.value = null
+ cachePxSizes = []
+ }
+
+ const cacheCollapsedSize: number[] = []
+ const onCollapse = (index: number, type: 'start' | 'end') => {
+ const currentSizes = pxSizes.value
+
+ const currentIndex = type === 'start' ? index : index + 1
+ const targetIndex = type === 'start' ? index + 1 : index
+
+ const currentSize = currentSizes[currentIndex]!
+ const targetSize = currentSizes[targetIndex]!
+
+ if (currentSize !== 0 && targetSize !== 0) {
+ currentSizes[currentIndex] = 0
+ currentSizes[targetIndex]! += currentSize
+ cacheCollapsedSize[index] = currentSize
+ } else {
+ const totalSize = currentSize + targetSize
+
+ const targetCacheCollapsedSize = cacheCollapsedSize[index]!
+ const currentCacheCollapsedSize = totalSize - targetCacheCollapsedSize
+
+ currentSizes[targetIndex] = targetCacheCollapsedSize
+ currentSizes[currentIndex] = currentCacheCollapsedSize
+ }
+
+ panels.value.forEach((panel, index) => {
+ panel.size = currentSizes[index]
+ })
+ }
+
+ return { onMoveStart, onMoving, onMoveEnd, movingIndex, onCollapse }
+}
diff --git a/packages/components/splitter/src/hooks/useSize.ts b/packages/components/splitter/src/hooks/useSize.ts
new file mode 100644
index 0000000000..1390b6e3af
--- /dev/null
+++ b/packages/components/splitter/src/hooks/useSize.ts
@@ -0,0 +1,79 @@
+import { computed, ref, watch } from 'vue'
+import { isString } from '@element-plus/utils'
+import type { ComputedRef, Ref } from 'vue'
+import type { PanelItemState } from '../type'
+
+export function getPct(str: string) {
+ return Number(str.slice(0, -1)) / 100
+}
+
+export function getPx(str: string) {
+ return Number(str.slice(0, -2))
+}
+
+export function isPct(
+ itemSize: string | number | undefined
+): itemSize is string {
+ return isString(itemSize) && itemSize.endsWith('%')
+}
+
+export function isPx(
+ itemSize: string | number | undefined
+): itemSize is string {
+ return isString(itemSize) && itemSize.endsWith('px')
+}
+
+export function useSize(
+ panels: Ref,
+ containerSize: ComputedRef
+) {
+ const propSizes = computed(() => panels.value.map((i) => i.size))
+
+ const panelCounts = computed(() => panels.value.length)
+
+ const percentSizes = ref([])
+
+ watch([propSizes, panelCounts, containerSize], () => {
+ let ptgList: (number | undefined)[] = []
+ let emptyCount = 0
+
+ // Convert the passed props size to a percentage
+ for (let i = 0; i < panelCounts.value; i += 1) {
+ const itemSize = panels.value[i]?.size
+
+ if (isPct(itemSize)) {
+ ptgList[i] = getPct(itemSize)
+ } else if (isPx(itemSize)) {
+ ptgList[i] = getPx(itemSize) / containerSize.value
+ } else if (itemSize || itemSize === 0) {
+ const num = Number(itemSize)
+
+ if (!Number.isNaN(num)) {
+ ptgList[i] = num / containerSize.value
+ }
+ } else {
+ emptyCount += 1
+ ptgList[i] = undefined
+ }
+ }
+
+ const totalPtg = ptgList.reduce((acc, ptg) => acc + (ptg || 0), 0)
+
+ if (totalPtg > 1 || !emptyCount) {
+ // If it is greater than 1, the scaling ratio
+ const scale = 1 / totalPtg
+ ptgList = ptgList.map((ptg) => (ptg === undefined ? 0 : ptg * scale))
+ } else {
+ // If it is less than 1, the filling ratio
+ const avgRest = (1 - totalPtg) / emptyCount
+ ptgList = ptgList.map((ptg) => (ptg === undefined ? avgRest : ptg))
+ }
+
+ percentSizes.value = ptgList as number[]
+ })
+
+ const ptg2px = (ptg: number) => ptg * containerSize.value
+ const pxSizes = computed(() => percentSizes.value.map(ptg2px))
+
+ return { percentSizes, pxSizes }
+}
diff --git a/packages/components/splitter/src/split-bar.vue b/packages/components/splitter/src/split-bar.vue
new file mode 100644
index 0000000000..24ecfb3fd3
--- /dev/null
+++ b/packages/components/splitter/src/split-bar.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/splitter/src/split-panel.ts b/packages/components/splitter/src/split-panel.ts
new file mode 100644
index 0000000000..160de2ed82
--- /dev/null
+++ b/packages/components/splitter/src/split-panel.ts
@@ -0,0 +1,27 @@
+import { buildProps } from '@element-plus/utils'
+
+import type { ExtractPropTypes } from 'vue'
+import type SplitterPanel from './split-panel.vue'
+
+export const splitterPanelProps = buildProps({
+ min: {
+ type: [String, Number],
+ },
+ max: {
+ type: [String, Number],
+ },
+ size: {
+ type: [String, Number],
+ },
+ resizable: {
+ type: Boolean,
+ default: true,
+ },
+ collapsible: {
+ type: Boolean,
+ default: false,
+ },
+} as const)
+
+export type SplitterPanelProps = ExtractPropTypes
+export type SplitterPanelInstance = InstanceType & unknown
diff --git a/packages/components/splitter/src/split-panel.vue b/packages/components/splitter/src/split-panel.vue
new file mode 100644
index 0000000000..0edcfa45c4
--- /dev/null
+++ b/packages/components/splitter/src/split-panel.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/splitter/src/splitter.ts b/packages/components/splitter/src/splitter.ts
new file mode 100644
index 0000000000..a18e16d56e
--- /dev/null
+++ b/packages/components/splitter/src/splitter.ts
@@ -0,0 +1,15 @@
+import { buildProps } from '@element-plus/utils'
+
+import type { ExtractPropTypes } from 'vue'
+import type Splitter from './splitter.vue'
+
+export const splitterProps = buildProps({
+ layout: {
+ type: String,
+ default: 'horizontal',
+ values: ['horizontal', 'vertical'] as const,
+ },
+} as const)
+
+export type SplitterProps = ExtractPropTypes
+export type SplitterInstance = InstanceType & unknown
diff --git a/packages/components/splitter/src/splitter.vue b/packages/components/splitter/src/splitter.vue
new file mode 100644
index 0000000000..2e50c30aa8
--- /dev/null
+++ b/packages/components/splitter/src/splitter.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
diff --git a/packages/components/splitter/src/type.ts b/packages/components/splitter/src/type.ts
new file mode 100644
index 0000000000..88a555b6a2
--- /dev/null
+++ b/packages/components/splitter/src/type.ts
@@ -0,0 +1,34 @@
+import type { InjectionKey, UnwrapRef } from 'vue'
+
+export type Layout = 'horizontal' | 'vertical'
+
+export type PanelItemState = UnwrapRef<{
+ uid: number
+ el: HTMLElement
+ collapsible: { start?: boolean; end?: boolean }
+ max?: number | string
+ min?: number | string
+ resizable: boolean
+ size?: number | string
+ setIndex: (val: number) => void
+}>
+
+export interface SplitterRootContext {
+ panels: PanelItemState[]
+ layout: Layout
+ containerSize: number
+ movingIndex: { index: number; confirmed: boolean } | null
+ percentSizes: number[]
+ pxSizes: number[]
+ registerPanel: (pane: PanelItemState) => void
+ sortPanel: (pane: PanelItemState) => void
+ unregisterPanel: (uid: number) => void
+ onCollapse: (index: number, type: 'start' | 'end') => void
+ onMoveEnd: () => void
+ onMoveStart: (index: number) => void
+ onMoving: (index: number, offset: number) => void
+}
+
+export const splitterRootContextKey: InjectionKey = Symbol(
+ 'splitterRootContextKey'
+)
diff --git a/packages/components/splitter/style/css.ts b/packages/components/splitter/style/css.ts
new file mode 100644
index 0000000000..af007b1dfb
--- /dev/null
+++ b/packages/components/splitter/style/css.ts
@@ -0,0 +1,2 @@
+import '@element-plus/components/base/style/css'
+import '@element-plus/theme-chalk/el-splitter.css'
diff --git a/packages/components/splitter/style/index.ts b/packages/components/splitter/style/index.ts
new file mode 100644
index 0000000000..4d5b56bfda
--- /dev/null
+++ b/packages/components/splitter/style/index.ts
@@ -0,0 +1,2 @@
+import '@element-plus/components/base/style'
+import '@element-plus/theme-chalk/src/splitter.scss'
diff --git a/packages/element-plus/component.ts b/packages/element-plus/component.ts
index 313102213f..9d2d16e504 100644
--- a/packages/element-plus/component.ts
+++ b/packages/element-plus/component.ts
@@ -108,6 +108,7 @@ import { ElTour, ElTourStep } from '@element-plus/components/tour'
import { ElAnchor, ElAnchorLink } from '@element-plus/components/anchor'
import { ElSegmented } from '@element-plus/components/segmented'
import { ElMention } from '@element-plus/components/mention'
+import { ElSplitter, ElSplitterPanel } from '@element-plus/components/splitter'
import type { Plugin } from 'vue'
@@ -218,4 +219,6 @@ export default [
ElAnchorLink,
ElSegmented,
ElMention,
+ ElSplitter,
+ ElSplitterPanel,
] as Plugin[]
diff --git a/packages/theme-chalk/src/index.scss b/packages/theme-chalk/src/index.scss
index 6bf9dd6e82..00d95e00bc 100644
--- a/packages/theme-chalk/src/index.scss
+++ b/packages/theme-chalk/src/index.scss
@@ -109,3 +109,5 @@
@use './anchor-link.scss';
@use './segmented.scss';
@use './mention.scss';
+@use './splitter.scss';
+@use './splitter-panel.scss';
diff --git a/packages/theme-chalk/src/splitter-panel.scss b/packages/theme-chalk/src/splitter-panel.scss
new file mode 100644
index 0000000000..f6b6ce7559
--- /dev/null
+++ b/packages/theme-chalk/src/splitter-panel.scss
@@ -0,0 +1,8 @@
+@use 'mixins/mixins' as *;
+
+@include b(splitter-panel) {
+ flex-grow: 0;
+ overflow: auto;
+ scrollbar-width: thin;
+ box-sizing: border-box;
+}
diff --git a/packages/theme-chalk/src/splitter.scss b/packages/theme-chalk/src/splitter.scss
new file mode 100644
index 0000000000..ad1974856c
--- /dev/null
+++ b/packages/theme-chalk/src/splitter.scss
@@ -0,0 +1,129 @@
+@use 'mixins/mixins' as *;
+@use 'mixins/var' as *;
+@use 'common/var' as *;
+
+@include b(splitter) {
+ position: relative;
+ display: flex;
+ height: 100%;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ @include e(mask) {
+ position: absolute;
+ z-index: 999;
+ inset: 0;
+ }
+ @include e(mask-horizontal) {
+ cursor: col-resize;
+ }
+ @include e(mask-vertical) {
+ cursor: row-resize;
+ }
+
+ @include e(horizontal) {
+ flex-direction: row;
+ }
+
+ @include e(vertical) {
+ flex-direction: column;
+ }
+}
+
+@include b(splitter-bar) {
+ flex: none;
+ position: relative;
+ user-select: none;
+ @include e(disable) {
+ cursor: auto !important;
+ &::before {
+ background-color: getCssVar('border-color-light') !important;
+ }
+ }
+ @include e(dragger) {
+ z-index: 1;
+ position: absolute;
+ background: transparent;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: getCssVar('border-color-light');
+ }
+ &:hover {
+ &::before {
+ background-color: getCssVar('color-primary-light-5');
+ }
+ }
+ }
+ @include e(dragger-horizontal) {
+ &::before {
+ height: 100%;
+ width: 2px;
+ }
+ }
+ @include e(dragger-vertical) {
+ &::before {
+ height: 2px;
+ width: 100%;
+ }
+ }
+ @include e(dragger-active) {
+ &::before {
+ background-color: getCssVar('color-primary-light-3');
+ }
+ }
+ &:hover {
+ @include e(collapse-icon) {
+ opacity: 1;
+ }
+ }
+ @include e(collapse-icon) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ background: getCssVar('border-color-light');
+ border-radius: 2px;
+ cursor: pointer;
+ opacity: 0;
+ z-index: 9;
+ &:hover {
+ opacity: 1;
+ background-color: getCssVar('color-primary-light-5');
+ }
+ }
+ @include e(horizontal-collapse-icon-start) {
+ left: -12px;
+ top: 50%;
+ height: 24px;
+ width: 16px;
+ transform: translate(-50%, -50%);
+ }
+ @include e(horizontal-collapse-icon-end) {
+ left: 12px;
+ top: 50%;
+ height: 24px;
+ width: 16px;
+ transform: translate(-50%, -50%);
+ }
+ @include e(vertical-collapse-icon-start) {
+ top: -12px;
+ right: 50%;
+ height: 16px;
+ width: 24px;
+ transform: translate(50%, -50%);
+ }
+ @include e(vertical-collapse-icon-end) {
+ top: 12px;
+ right: 50%;
+ height: 16px;
+ width: 24px;
+ transform: translate(50%, -50%);
+ }
+}
diff --git a/typings/global.d.ts b/typings/global.d.ts
index 90f32fdf69..215d877a76 100644
--- a/typings/global.d.ts
+++ b/typings/global.d.ts
@@ -106,6 +106,8 @@ declare module 'vue' {
ElTourStep: typeof import('element-plus')['ElTourStep']
ElSegmented: typeof import('element-plus')['ElSegmented']
ElMention: typeof import('element-plus')['ElMention']
+ ElSplitter: typeof import('element-plus')['ElSplitter']
+ ElSplitterPanel: typeof import('element-plus')['ElSplitterPanel']
}
interface ComponentCustomProperties {