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 <bingshuanglingluo@163.com>
This commit is contained in:
Gnalvin
2025-06-03 23:01:28 +08:00
committed by GitHub
parent a48b496ba5
commit d720e5c31c
35 changed files with 1515 additions and 1 deletions

View File

@@ -52,6 +52,11 @@
"link": "/space",
"text": "Space"
},
{
"link": "/splitter",
"text": "Splitter",
"promotion": "2.10.0"
},
{
"link": "/typography",
"text": "Typography"

View File

@@ -61,6 +61,14 @@
>
@叨叨
</el-link>
<el-link
type="primary"
:underline="false"
href="https://github.com/zhiwendesign"
target="_blank"
>
@卡卡
</el-link>
</p>
</div>
</div>
@@ -189,7 +197,10 @@ onMounted(() => {
}
.designed-by {
text-align: right;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 5px;
font-size: 14px;
}
}

View File

@@ -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,

View File

@@ -0,0 +1,109 @@
<template>
<svg
width="280"
height="180"
viewBox="0 0 280 180"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="280" height="180" fill="var(--el-fill-color-light)" />
<g clip-path="url(#clip0_2477_1001)">
<mask id="path-3-inside-1_2477_1001" fill="white">
<path d="M80 56H120V124H80V56Z" />
</mask>
<path d="M80 56H120V124H80V56Z" fill="var(--el-fill-color-blank)" />
<path
d="M120 56H119.5V124H120H120.5V56H120Z"
fill="#CDD0D6"
mask="url(#path-3-inside-1_2477_1001)"
/>
<mask id="path-5-inside-2_2477_1001" fill="white">
<path d="M120 56H160V124H120V56Z" />
</mask>
<path d="M120 56H160V124H120V56Z" fill="var(--el-fill-color-blank)" />
<path
d="M160 56H159.5V124H160H160.5V56H160Z"
fill="#CDD0D6"
mask="url(#path-5-inside-2_2477_1001)"
/>
<rect
width="40"
height="68"
transform="translate(160 56)"
fill="var(--el-color-primary-light-8)"
/>
<rect
x="165"
y="63"
width="20"
height="4"
rx="2"
fill="var(--el-color-primary)"
/>
<rect
x="165"
y="70"
width="4"
height="4"
rx="2"
fill="var(--el-color-primary-light-5)"
/>
<rect
x="172"
y="70"
width="21"
height="4"
rx="2"
fill="var(--el-color-primary-light-5)"
/>
<rect
x="172"
y="77"
width="21"
height="4"
rx="2"
fill="var(--el-color-primary-light-5)"
/>
<path
d="M159.75 83.3335V96.6668"
stroke="var(--el-color-primary)"
stroke-width="1.25"
stroke-linecap="round"
/>
<path
d="M152.868 90.1179C152.803 90.0528 152.803 89.9472 152.868 89.8821L154.965 87.7845C155.07 87.6795 155.25 87.7539 155.25 87.9024V92.0976C155.25 92.2461 155.07 92.3205 154.965 92.2155L152.868 90.1179Z"
fill="var(--el-color-primary)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M154.25 89.375L160.25 89.375V90.625L154.25 90.625V89.375Z"
fill="var(--el-color-primary)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M158.583 89.375L164.583 89.375V90.625L158.583 90.625V89.375Z"
fill="var(--el-color-primary)"
/>
<path
d="M166.965 89.8821C167.03 89.9472 167.03 90.0528 166.965 90.1179L164.868 92.2155C164.763 92.3205 164.583 92.2461 164.583 92.0976V87.9024C164.583 87.7539 164.763 87.6795 164.868 87.7845L166.965 89.8821Z"
fill="var(--el-color-primary)"
/>
</g>
<rect
x="80.25"
y="56.25"
width="119.5"
height="67.5"
rx="3.75"
stroke="var(--el-border-color-dark)"
stroke-width="0.5"
/>
<defs>
<clipPath id="clip0_2477_1001">
<rect x="80" y="56" width="120" height="68" rx="4" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -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']

View File

@@ -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 |

View File

@@ -0,0 +1,23 @@
<template>
<div
style="height: 250px; box-shadow: var(--el-border-color-light) 0px 0px 10px"
>
<el-splitter>
<el-splitter-panel size="30%">
<div class="demo-panel">1</div>
</el-splitter-panel>
<el-splitter-panel :min="200">
<div class="demo-panel">2</div>
</el-splitter-panel>
</el-splitter>
</div>
</template>
<style scoped>
.demo-panel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<div
style="height: 250px; box-shadow: var(--el-border-color-light) 0px 0px 10px"
>
<el-splitter>
<el-splitter-panel collapsible min="50">
<div class="demo-panel">1</div>
</el-splitter-panel>
<el-splitter-panel collapsible>
<div class="demo-panel">2</div>
</el-splitter-panel>
<el-splitter-panel>
<div class="demo-panel">3</div>
</el-splitter-panel>
<el-splitter-panel collapsible>
<el-splitter layout="vertical">
<el-splitter-panel collapsible>
<div class="demo-panel">4</div>
</el-splitter-panel>
<el-splitter-panel collapsible>
<div class="demo-panel">5</div>
</el-splitter-panel>
</el-splitter>
</el-splitter-panel>
</el-splitter>
</div>
</template>
<style scoped>
.demo-panel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<el-switch
v-model="resizable"
active-text="enable"
inactive-text="disable"
inline-prompt
class="mb-2"
/>
<div
style="height: 250px; box-shadow: var(--el-border-color-light) 0px 0px 10px"
>
<el-splitter>
<el-splitter-panel>
<div class="demo-panel">1</div>
</el-splitter-panel>
<el-splitter-panel :resizable="resizable">
<div class="demo-panel">
drag {{ resizable ? 'enable' : 'disable' }}
</div>
</el-splitter-panel>
<el-splitter-panel>
<div class="demo-panel">3</div>
</el-splitter-panel>
</el-splitter>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const resizable = ref(false)
</script>
<style scoped>
.demo-panel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div
style="height: 250px; box-shadow: var(--el-border-color-light) 0px 0px 10px"
>
<el-splitter
@resize-start="handleResizeStart"
@resize-end="handleResizeEnd"
@resize="handleResize"
>
<el-splitter-panel>
<div class="demo-panel">1</div>
</el-splitter-panel>
<el-splitter-panel v-model:size="size" :max="200" :min="50">
<div class="demo-panel">{{ size }}px</div>
</el-splitter-panel>
<el-splitter-panel>
<div class="demo-panel">3</div>
</el-splitter-panel>
</el-splitter>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const size = ref(100)
const handleResizeStart = (index: number, sizes: number[]) => {
console.log('resizeStart', index, sizes)
}
const handleResize = (index: number, sizes: number[]) => {
console.log('resize', index, sizes)
}
const handleResizeEnd = (index: number, sizes: number[]) => {
console.log('resizeEnd', index, sizes)
}
</script>
<style scoped>
.demo-panel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<div
style="height: 250px; box-shadow: var(--el-border-color-light) 0px 0px 10px"
>
<el-splitter layout="vertical">
<el-splitter-panel>
<div class="demo-panel">1</div>
</el-splitter-panel>
<el-splitter-panel>
<div class="demo-panel">2</div>
</el-splitter-panel>
</el-splitter>
</div>
</template>
<style scoped>
.demo-panel {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@@ -75,6 +75,7 @@ export * from './tour'
export * from './anchor'
export * from './segmented'
export * from './mention'
export * from './splitter'
// plugins
export * from './infinite-scroll'

View File

@@ -0,0 +1,2 @@
import '@element-plus/components/base/style/css'
import '@element-plus/theme-chalk/splitter-panel.css'

View File

@@ -0,0 +1,2 @@
import '@element-plus/components/base/style'
import '@element-plus/theme-chalk/src/splitter-panel.scss'

View File

@@ -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()
})

View File

@@ -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(() => (
<ElSplitter>
<ElSplitterPanel>Left Panel</ElSplitterPanel>
<ElSplitterPanel>Right Panel</ElSplitterPanel>
</ElSplitter>
))
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(() => (
<ElSplitter layout="vertical">
<ElSplitterPanel>Top Panel</ElSplitterPanel>
<ElSplitterPanel>Bottom Panel</ElSplitterPanel>
</ElSplitter>
))
expect(wrapper.find('.el-splitter__vertical').exists()).toBe(true)
})
it('should respect min and max size constraints', async () => {
const wrapper = mount(() => (
<div style={{ width: '400px', height: '400px' }}>
<ElSplitter>
<ElSplitterPanel size={150} min={100} max={200}>
Left Panel
</ElSplitterPanel>
<ElSplitterPanel>Right Panel</ElSplitterPanel>
</ElSplitter>
</div>
))
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(() => (
<div style={{ width: '400px', height: '400px' }}>
<ElSplitter>
<ElSplitterPanel size={150} collapsible>
Left Panel
</ElSplitterPanel>
<ElSplitterPanel collapsible>Right Panel</ElSplitterPanel>
</ElSplitter>
</div>
))
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;')
})
})

View File

@@ -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<typeof Splitter> & {
SplitPanel: typeof SplitPanel
} = withInstall(Splitter, {
SplitPanel,
})
export default ElSplitter
export const ElSplitterPanel: SFCWithInstall<typeof SplitPanel> =
withNoopInstall(SplitPanel)
export * from './src/splitter'
export * from './src/split-panel'

View File

@@ -0,0 +1,3 @@
export { useContainer } from './useContainer'
export { useResize } from './useResize'
export { useSize, isPct, isPx, getPct, getPx } from './useSize'

View File

@@ -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<HTMLDivElement>()
const { width, height } = useElementSize(containerEl)
const containerSize = computed(() => {
return layout.value === 'horizontal' ? width.value : height.value
})
return { containerEl, containerSize }
}

View File

@@ -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
}

View File

@@ -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<PanelItemState[]>,
containerSize: ComputedRef<number>,
pxSizes: ComputedRef<number[]>
) {
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 }
}

View File

@@ -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<PanelItemState[]>,
containerSize: ComputedRef<number>
) {
const propSizes = computed(() => panels.value.map((i) => i.size))
const panelCounts = computed(() => panels.value.length)
const percentSizes = ref<number[]>([])
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<number>((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 }
}

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
} from '@element-plus/icons-vue'
import { useNamespace } from '@element-plus/hooks'
const ns = useNamespace('splitter-bar')
defineOptions({
name: 'ElSplitterBar',
})
const props = defineProps({
index: {
type: Number,
required: true,
},
layout: {
type: String,
values: ['horizontal', 'vertical'] as const,
default: 'horizontal',
},
resizable: {
type: Boolean,
default: true,
},
startCollapsible: {
type: Boolean,
},
endCollapsible: {
type: Boolean,
},
})
const emit = defineEmits(['moveStart', 'moving', 'moveEnd', 'collapse'])
const isHorizontal = computed(() => props.layout === 'horizontal')
const barWrapStyles = computed(() => {
if (isHorizontal.value) {
return { width: 0 }
}
return { height: 0 }
})
const draggerStyles = computed(() => {
return {
width: isHorizontal.value ? '16px' : '100%',
height: isHorizontal.value ? '100%' : '16px',
cursor: isHorizontal.value ? 'col-resize' : 'row-resize',
touchAction: 'none',
}
})
const draggerPseudoClass = computed(() => {
const prefix = ns.e('dragger')
let className = isHorizontal.value
? `${prefix}-horizontal`
: `${prefix}-vertical`
if (startPos.value) className += ` ${prefix}-active`
return className
})
const startPos = ref<[x: number, y: number] | null>(null)
// Start dragging
const onMousedown = (e: MouseEvent) => {
if (!props.resizable) return
startPos.value = [e.pageX, e.pageY]
emit('moveStart', props.index)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('mousemove', onMouseMove)
}
const onTouchStart = (e: TouchEvent) => {
if (props.resizable && e.touches.length === 1) {
e.preventDefault()
const touch = e.touches[0]
startPos.value = [touch.pageX, touch.pageY]
emit('moveStart', props.index)
window.addEventListener('touchend', onTouchEnd)
window.addEventListener('touchmove', onTouchMove)
}
}
// During dragging
const onMouseMove = (e: MouseEvent) => {
const { pageX, pageY } = e
const offsetX = pageX - startPos.value![0]
const offsetY = pageY - startPos.value![1]
const offset = isHorizontal.value ? offsetX : offsetY
emit('moving', props.index, offset)
}
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length === 1) {
e.preventDefault()
const touch = e.touches[0]
const offsetX = touch.pageX - startPos.value![0]
const offsetY = touch.pageY - startPos.value![1]
const offset = isHorizontal.value ? offsetX : offsetY
emit('moving', props.index, offset)
}
}
// End dragging
const onMouseUp = () => {
startPos.value = null
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('mousemove', onMouseMove)
emit('moveEnd', props.index)
}
const onTouchEnd = () => {
startPos.value = null
window.removeEventListener('touchend', onTouchEnd)
window.removeEventListener('touchmove', onTouchMove)
emit('moveEnd', props.index)
}
const StartIcon = computed(() => (isHorizontal.value ? ArrowLeft : ArrowUp))
const EndIcon = computed(() => (isHorizontal.value ? ArrowRight : ArrowDown))
</script>
<template>
<div :class="[ns.b()]" :style="barWrapStyles">
<div
v-if="startCollapsible"
:class="[ns.e('collapse-icon'), ns.e(`${layout}-collapse-icon-start`)]"
@click="emit('collapse', index, 'start')"
>
<slot name="start-collapsible">
<component :is="StartIcon" style="width: 12px; height: 12px" />
</slot>
</div>
<div
:class="[
ns.e('dragger'),
draggerPseudoClass,
resizable ? '' : ns.e('disable'),
]"
:style="draggerStyles"
@mousedown="onMousedown"
@touchstart="onTouchStart"
/>
<div
v-if="endCollapsible"
:class="[ns.e('collapse-icon'), ns.e(`${layout}-collapse-icon-end`)]"
@click="emit('collapse', index, 'end')"
>
<slot name="end-collapsible">
<component :is="EndIcon" style="width: 12px; height: 12px" />
</slot>
</div>
</div>
</template>

View File

@@ -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<typeof splitterPanelProps>
export type SplitterPanelInstance = InstanceType<typeof SplitterPanel> & unknown

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import {
computed,
getCurrentInstance,
inject,
nextTick,
onMounted,
onUnmounted,
reactive,
ref,
toRefs,
watch,
} from 'vue'
import { useNamespace } from '@element-plus/hooks'
import { throwError } from '@element-plus/utils'
import { getCollapsible, isCollapsible } from './hooks/usePanel'
import SplitBar from './split-bar.vue'
import { splitterPanelProps } from './split-panel'
import { getPct, getPx, isPct, isPx } from './hooks'
import { splitterRootContextKey } from './type'
const ns = useNamespace('splitter-panel')
const COMPONENT_NAME = 'ElSplitterPanel'
defineOptions({
name: COMPONENT_NAME,
})
const props = defineProps(splitterPanelProps)
const emits = defineEmits<{
(e: 'update:size', value: number): void
}>()
const splitterContext = inject(splitterRootContextKey)
if (!splitterContext)
throwError(
COMPONENT_NAME,
'usage: <el-splitter><el-splitter-panel /></el-splitter/>'
)
const { panels, layout, containerSize, pxSizes } = toRefs(splitterContext)
const {
registerPanel,
sortPanel,
unregisterPanel,
onCollapse,
onMoveEnd,
onMoveStart,
onMoving,
} = splitterContext
const panelEl = ref<HTMLDivElement>()
const uid = getCurrentInstance()!.uid
const index = ref(0)
const panel = computed(() => panels.value[index.value])
const setIndex = (val: number) => {
index.value = val
}
const panelSize = computed(() => {
if (!panel.value) return 0
return pxSizes.value[index.value] ?? 0
})
const nextSize = computed(() => {
if (!panel.value) return 0
return pxSizes.value[index.value + 1] ?? 0
})
const nextPanel = computed(() => {
if (panel.value) {
return panels.value[index.value + 1]
}
return null
})
const isResizable = computed(() => {
if (!nextPanel.value) return false
return (
props.resizable &&
nextPanel.value?.resizable &&
// If it is 0, it means it is collapsed => check if the minimum value is set
(panelSize.value !== 0 || !props.min) &&
(nextSize.value !== 0 || !nextPanel.value.min)
)
})
// The last panel doesn't need a drag bar
const isShowBar = computed(() => {
if (!panel.value) return false
return index.value !== panels.value.length - 1
})
const startCollapsible = computed(() =>
isCollapsible(panel.value, panelSize.value, nextPanel.value, nextSize.value)
)
const endCollapsible = computed(() =>
isCollapsible(nextPanel.value, nextSize.value, panel.value, panelSize.value)
)
function sizeToPx(str: string | number | undefined) {
if (isPct(str)) {
return getPct(str) * containerSize.value || 0
} else if (isPx(str)) {
return getPx(str)
}
return str ?? 0
}
// Two-way binding for size
let isSizeUpdating = false
watch(
() => props.size,
() => {
if (panel.value) {
const size = sizeToPx(props.size)
const maxSize = sizeToPx(props.max)
const minSize = sizeToPx(props.min)
// Ensure it is within the maximum and minimum value range
const finalSize = Math.min(Math.max(size, minSize || 0), maxSize || size)
if (finalSize !== size) {
isSizeUpdating = true
emits('update:size', finalSize)
}
panel.value.size = finalSize
nextTick(() => (isSizeUpdating = false))
}
}
)
watch(
() => panel.value?.size,
(val) => {
if (!isSizeUpdating && val !== props.size) {
emits('update:size', val as number)
}
}
)
watch(
() => props.resizable,
(val) => {
if (panel.value) {
panel.value.resizable = val
}
}
)
const _panel = reactive({
el: panelEl.value!,
uid,
setIndex,
...props,
collapsible: getCollapsible(props.collapsible),
})
registerPanel(_panel)
onMounted(() => {
sortPanel(_panel)
})
onUnmounted(() => unregisterPanel?.(uid))
</script>
<template>
<div
ref="panelEl"
:class="[ns.b()]"
:style="{ flexBasis: `${panelSize}px` }"
v-bind="$attrs"
>
<slot />
</div>
<SplitBar
v-if="isShowBar"
:index="index"
:layout="layout"
:resizable="isResizable"
:start-collapsible="startCollapsible"
:end-collapsible="endCollapsible"
@move-start="onMoveStart"
@moving="onMoving"
@move-end="onMoveEnd"
@collapse="onCollapse"
>
<template #start-collapsible>
<slot name="start-collapsible" />
</template>
<template #end-collapsible>
<slot name="end-collapsible" />
</template>
</SplitBar>
</template>

View File

@@ -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<typeof splitterProps>
export type SplitterInstance = InstanceType<typeof Splitter> & unknown

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { getCurrentInstance, provide, reactive, toRef, watch } from 'vue'
import { useNamespace, useOrderedChildren } from '@element-plus/hooks'
import { useContainer, useResize, useSize } from './hooks'
import { splitterProps } from './splitter'
import { type PanelItemState, splitterRootContextKey } from './type'
const ns = useNamespace('splitter')
defineOptions({
name: 'ElSplitter',
})
const emits = defineEmits<{
(e: 'resizeStart', index: number, sizes: number[]): void
(e: 'resize', index: number, sizes: number[]): void
(e: 'resizeEnd', index: number, sizes: number[]): void
}>()
const props = defineProps(splitterProps)
const { containerEl, containerSize } = useContainer(toRef(props, 'layout'))
const {
removeChild: unregisterPanel,
children: panels,
addChild: sortPanel,
} = useOrderedChildren<PanelItemState>(getCurrentInstance()!, 'ElSplitterPanel')
watch(panels, () => {
panels.value.forEach((instance: PanelItemState, index: number) => {
instance.setIndex(index)
})
})
const { percentSizes, pxSizes } = useSize(panels, containerSize)
const { onMoveStart, onMoving, onMoveEnd, onCollapse, movingIndex } = useResize(
panels,
containerSize,
pxSizes
)
const onResizeStart = (index: number) => {
onMoveStart(index)
emits('resizeStart', index, pxSizes.value)
}
const onResize = (index: number, offset: number) => {
onMoving(index, offset)
emits('resize', index, pxSizes.value)
}
const onResizeEnd = (index: number) => {
onMoveEnd()
emits('resizeEnd', index, pxSizes.value)
}
provide(
splitterRootContextKey,
reactive({
panels,
percentSizes,
pxSizes,
layout: props.layout,
movingIndex,
containerSize,
onMoveStart: onResizeStart,
onMoving: onResize,
onMoveEnd: onResizeEnd,
onCollapse,
registerPanel: (panel: PanelItemState) => {
panels.value.push(panel)
},
sortPanel,
unregisterPanel,
})
)
</script>
<template>
<div ref="containerEl" :class="[ns.b(), ns.e(layout)]">
<slot />
<!-- Prevent iframe touch events from breaking -->
<div v-if="movingIndex" :class="[ns.e('mask'), ns.e(`mask-${layout}`)]" />
</div>
</template>

View File

@@ -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<SplitterRootContext> = Symbol(
'splitterRootContextKey'
)

View File

@@ -0,0 +1,2 @@
import '@element-plus/components/base/style/css'
import '@element-plus/theme-chalk/el-splitter.css'

View File

@@ -0,0 +1,2 @@
import '@element-plus/components/base/style'
import '@element-plus/theme-chalk/src/splitter.scss'

View File

@@ -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[]

View File

@@ -109,3 +109,5 @@
@use './anchor-link.scss';
@use './segmented.scss';
@use './mention.scss';
@use './splitter.scss';
@use './splitter-panel.scss';

View File

@@ -0,0 +1,8 @@
@use 'mixins/mixins' as *;
@include b(splitter-panel) {
flex-grow: 0;
overflow: auto;
scrollbar-width: thin;
box-sizing: border-box;
}

View File

@@ -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%);
}
}

2
typings/global.d.ts vendored
View File

@@ -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 {