From d720e5c31cd253f04e5193ade63f82eeccc1a874 Mon Sep 17 00:00:00 2001 From: Gnalvin <31533594+Gnalvin@users.noreply.github.com> Date: Tue, 3 Jun 2025 23:01:28 +0800 Subject: [PATCH] feat(components): [splitter] new component (#20145) * feat(components): [splitter] new component * docs(components): [splitter] update docs * feat(components): [splitter] add export ElSplitter ElSplitterPanel types * feat(components): [splitter] support touch even * Update docs/.vitepress/crowdin/en-US/pages/component.json Co-authored-by: sea <45450994+warmthsea@users.noreply.github.com> * docs: update * docs: update * chore: update docs & code * chore: update docs & fix code type error * fix: use slot show error * refactor(components): [splitter] modify props use * refactor(components): [splitter] use native event listener to drag * fix(components): [splitter] panel bind attrs * refactor: use `useOrderedChildren` * chore: fix types * feat: add icon * chore: translate comments and add beta tag * docs: add default-size api * docs: remove demo lang="scss" * refactor: remove default-size api --------- Co-authored-by: kooriookami <38392315+kooriookami@users.noreply.github.com> Co-authored-by: sea <45450994+warmthsea@users.noreply.github.com> Co-authored-by: warmthsea <2586244885@qq.com> Co-authored-by: Dsaquel <291874700n@gmail.com> Co-authored-by: kooriookami --- .../crowdin/en-US/pages/component.json | 5 + .../vitepress/components/globals/overview.vue | 13 +- .../components/overview-icons/index.ts | 2 + .../components/overview-icons/ov-splitter.vue | 109 ++++++++++ docs/components.d.ts | 1 + docs/en-US/component/splitter.md | 100 +++++++++ docs/examples/splitter/basic.vue | 23 ++ docs/examples/splitter/collapsible.vue | 36 ++++ docs/examples/splitter/disableDrag.vue | 41 ++++ docs/examples/splitter/size.vue | 48 +++++ docs/examples/splitter/vertical.vue | 23 ++ packages/components/index.ts | 1 + .../components/splitter-panel/style/css.ts | 2 + .../components/splitter-panel/style/index.ts | 2 + .../splitter/__tests__/__mocks__/vueuse.ts | 23 ++ .../splitter/__tests__/splitter.test.tsx | 135 ++++++++++++ packages/components/splitter/index.ts | 17 ++ .../components/splitter/src/hooks/index.ts | 3 + .../splitter/src/hooks/useContainer.ts | 14 ++ .../components/splitter/src/hooks/usePanel.ts | 33 +++ .../splitter/src/hooks/useResize.ts | 134 ++++++++++++ .../components/splitter/src/hooks/useSize.ts | 79 +++++++ .../components/splitter/src/split-bar.vue | 161 ++++++++++++++ .../components/splitter/src/split-panel.ts | 27 +++ .../components/splitter/src/split-panel.vue | 200 ++++++++++++++++++ packages/components/splitter/src/splitter.ts | 15 ++ packages/components/splitter/src/splitter.vue | 87 ++++++++ packages/components/splitter/src/type.ts | 34 +++ packages/components/splitter/style/css.ts | 2 + packages/components/splitter/style/index.ts | 2 + packages/element-plus/component.ts | 3 + packages/theme-chalk/src/index.scss | 2 + packages/theme-chalk/src/splitter-panel.scss | 8 + packages/theme-chalk/src/splitter.scss | 129 +++++++++++ typings/global.d.ts | 2 + 35 files changed, 1515 insertions(+), 1 deletion(-) create mode 100644 docs/.vitepress/vitepress/components/overview-icons/ov-splitter.vue create mode 100644 docs/en-US/component/splitter.md create mode 100644 docs/examples/splitter/basic.vue create mode 100644 docs/examples/splitter/collapsible.vue create mode 100644 docs/examples/splitter/disableDrag.vue create mode 100644 docs/examples/splitter/size.vue create mode 100644 docs/examples/splitter/vertical.vue create mode 100644 packages/components/splitter-panel/style/css.ts create mode 100644 packages/components/splitter-panel/style/index.ts create mode 100644 packages/components/splitter/__tests__/__mocks__/vueuse.ts create mode 100644 packages/components/splitter/__tests__/splitter.test.tsx create mode 100644 packages/components/splitter/index.ts create mode 100644 packages/components/splitter/src/hooks/index.ts create mode 100644 packages/components/splitter/src/hooks/useContainer.ts create mode 100644 packages/components/splitter/src/hooks/usePanel.ts create mode 100644 packages/components/splitter/src/hooks/useResize.ts create mode 100644 packages/components/splitter/src/hooks/useSize.ts create mode 100644 packages/components/splitter/src/split-bar.vue create mode 100644 packages/components/splitter/src/split-panel.ts create mode 100644 packages/components/splitter/src/split-panel.vue create mode 100644 packages/components/splitter/src/splitter.ts create mode 100644 packages/components/splitter/src/splitter.vue create mode 100644 packages/components/splitter/src/type.ts create mode 100644 packages/components/splitter/style/css.ts create mode 100644 packages/components/splitter/style/index.ts create mode 100644 packages/theme-chalk/src/splitter-panel.scss create mode 100644 packages/theme-chalk/src/splitter.scss diff --git a/docs/.vitepress/crowdin/en-US/pages/component.json b/docs/.vitepress/crowdin/en-US/pages/component.json index b32f8e5a68..16ff3e689f 100644 --- a/docs/.vitepress/crowdin/en-US/pages/component.json +++ b/docs/.vitepress/crowdin/en-US/pages/component.json @@ -52,6 +52,11 @@ "link": "/space", "text": "Space" }, + { + "link": "/splitter", + "text": "Splitter", + "promotion": "2.10.0" + }, { "link": "/typography", "text": "Typography" diff --git a/docs/.vitepress/vitepress/components/globals/overview.vue b/docs/.vitepress/vitepress/components/globals/overview.vue index 74481483f4..edaac4397d 100644 --- a/docs/.vitepress/vitepress/components/globals/overview.vue +++ b/docs/.vitepress/vitepress/components/globals/overview.vue @@ -61,6 +61,14 @@ > @叨叨 + + @卡卡 +

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