mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
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:
@@ -52,6 +52,11 @@
|
||||
"link": "/space",
|
||||
"text": "Space"
|
||||
},
|
||||
{
|
||||
"link": "/splitter",
|
||||
"text": "Splitter",
|
||||
"promotion": "2.10.0"
|
||||
},
|
||||
{
|
||||
"link": "/typography",
|
||||
"text": "Typography"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
1
docs/components.d.ts
vendored
1
docs/components.d.ts
vendored
@@ -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']
|
||||
|
||||
100
docs/en-US/component/splitter.md
Normal file
100
docs/en-US/component/splitter.md
Normal 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 |
|
||||
23
docs/examples/splitter/basic.vue
Normal file
23
docs/examples/splitter/basic.vue
Normal 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>
|
||||
36
docs/examples/splitter/collapsible.vue
Normal file
36
docs/examples/splitter/collapsible.vue
Normal 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>
|
||||
41
docs/examples/splitter/disableDrag.vue
Normal file
41
docs/examples/splitter/disableDrag.vue
Normal 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>
|
||||
48
docs/examples/splitter/size.vue
Normal file
48
docs/examples/splitter/size.vue
Normal 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>
|
||||
23
docs/examples/splitter/vertical.vue
Normal file
23
docs/examples/splitter/vertical.vue
Normal 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>
|
||||
@@ -75,6 +75,7 @@ export * from './tour'
|
||||
export * from './anchor'
|
||||
export * from './segmented'
|
||||
export * from './mention'
|
||||
export * from './splitter'
|
||||
|
||||
// plugins
|
||||
export * from './infinite-scroll'
|
||||
|
||||
2
packages/components/splitter-panel/style/css.ts
Normal file
2
packages/components/splitter-panel/style/css.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@element-plus/components/base/style/css'
|
||||
import '@element-plus/theme-chalk/splitter-panel.css'
|
||||
2
packages/components/splitter-panel/style/index.ts
Normal file
2
packages/components/splitter-panel/style/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@element-plus/components/base/style'
|
||||
import '@element-plus/theme-chalk/src/splitter-panel.scss'
|
||||
23
packages/components/splitter/__tests__/__mocks__/vueuse.ts
Normal file
23
packages/components/splitter/__tests__/__mocks__/vueuse.ts
Normal 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()
|
||||
})
|
||||
135
packages/components/splitter/__tests__/splitter.test.tsx
Normal file
135
packages/components/splitter/__tests__/splitter.test.tsx
Normal 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;')
|
||||
})
|
||||
})
|
||||
17
packages/components/splitter/index.ts
Normal file
17
packages/components/splitter/index.ts
Normal 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'
|
||||
3
packages/components/splitter/src/hooks/index.ts
Normal file
3
packages/components/splitter/src/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useContainer } from './useContainer'
|
||||
export { useResize } from './useResize'
|
||||
export { useSize, isPct, isPx, getPct, getPx } from './useSize'
|
||||
14
packages/components/splitter/src/hooks/useContainer.ts
Normal file
14
packages/components/splitter/src/hooks/useContainer.ts
Normal 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 }
|
||||
}
|
||||
33
packages/components/splitter/src/hooks/usePanel.ts
Normal file
33
packages/components/splitter/src/hooks/usePanel.ts
Normal 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
|
||||
}
|
||||
134
packages/components/splitter/src/hooks/useResize.ts
Normal file
134
packages/components/splitter/src/hooks/useResize.ts
Normal 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 }
|
||||
}
|
||||
79
packages/components/splitter/src/hooks/useSize.ts
Normal file
79
packages/components/splitter/src/hooks/useSize.ts
Normal 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 }
|
||||
}
|
||||
161
packages/components/splitter/src/split-bar.vue
Normal file
161
packages/components/splitter/src/split-bar.vue
Normal 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>
|
||||
27
packages/components/splitter/src/split-panel.ts
Normal file
27
packages/components/splitter/src/split-panel.ts
Normal 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
|
||||
200
packages/components/splitter/src/split-panel.vue
Normal file
200
packages/components/splitter/src/split-panel.vue
Normal 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>
|
||||
15
packages/components/splitter/src/splitter.ts
Normal file
15
packages/components/splitter/src/splitter.ts
Normal 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
|
||||
87
packages/components/splitter/src/splitter.vue
Normal file
87
packages/components/splitter/src/splitter.vue
Normal 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>
|
||||
34
packages/components/splitter/src/type.ts
Normal file
34
packages/components/splitter/src/type.ts
Normal 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'
|
||||
)
|
||||
2
packages/components/splitter/style/css.ts
Normal file
2
packages/components/splitter/style/css.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@element-plus/components/base/style/css'
|
||||
import '@element-plus/theme-chalk/el-splitter.css'
|
||||
2
packages/components/splitter/style/index.ts
Normal file
2
packages/components/splitter/style/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@element-plus/components/base/style'
|
||||
import '@element-plus/theme-chalk/src/splitter.scss'
|
||||
@@ -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[]
|
||||
|
||||
@@ -109,3 +109,5 @@
|
||||
@use './anchor-link.scss';
|
||||
@use './segmented.scss';
|
||||
@use './mention.scss';
|
||||
@use './splitter.scss';
|
||||
@use './splitter-panel.scss';
|
||||
|
||||
8
packages/theme-chalk/src/splitter-panel.scss
Normal file
8
packages/theme-chalk/src/splitter-panel.scss
Normal 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;
|
||||
}
|
||||
129
packages/theme-chalk/src/splitter.scss
Normal file
129
packages/theme-chalk/src/splitter.scss
Normal 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
2
typings/global.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user