Thành phần VueSplitter cho phép người dùng chia giao diện thành các khu vực độc lập có thể điều chỉnh kích thước, hỗ trợ nhiều tính năng linh hoạt như:
- Tích hợp nút thu gọn/hiển thị
- Đặt vị trí nút điều khiển ở các cạnh: trái, phải, trên, dưới
- Chỉnh độ rộng/mặc định khi mở rộng từ trạng thái thu gọn
- Tự động thu gọn khi kích thước nhỏ hơn ngưỡng xác định
- Hỗ trợ giới hạn kích thước tối thiểu và tối đa
- Tùy biến kiểu dáng nút: nền trắng, xanh dương, xanh đậm
- Điều chỉnh độ dày thanh phân cách
- Cho phép tắt chức năng kéo giãn
- Kích hoạt hiển thị nút bằng hover hoặc từ bên ngoài
- Ẩn chức năng nhấn đúp để thu gọn (nếu cần)
- Hiển thị văn bản trên nút điều hướng
Cấu trúc component chính
<template>
<div
class="vue-splitter"
:data-position="position"
@mousedown="attachDragEvents"
@dblclick="toggleOnDoubleClick"
:data-disabled="!isResizable"
:data-show-on-hover="showArrowOnHover"
>
<div
v-if="showToggle"
class="toggle-button"
@click="handleToggle"
@mousedown.stop=""
:data-style="buttonStyle"
:data-collapsed="isCollapsed"
:title="tooltip"
>
<span class="label-text" v-if="buttonLabel" v-html="buttonLabel" />
<i class="icon-left" v-if="position === 'left' && isCollapsed"></i>
<i class="icon-right" v-else-if="position === 'left'"></i>
<i class="icon-right" v-if="position === 'right' && isCollapsed"></i>
<i class="icon-left" v-else-if="position === 'right'"></i>
<i class="icon-up" v-if="position === 'top' && isCollapsed"></i>
<i class="icon-down" v-else-if="position === 'top'"></i>
<i class="icon-down" v-if="position === 'bottom' && isCollapsed"></i>
<i class="icon-up" v-else-if="position === 'bottom'"></i>
</div>
</div>
</template>
<script>
export default {
name: 'VueSplitter',
props: ['config'],
data() {
return {
isCollapsed: false,
showToggle: true,
position: 'right',
container: null,
defaultExpandSize: 200,
autoCollapseThreshold: 5,
minDimension: null,
maxDimension: null,
currentSize: null,
lastKnownSize: null,
buttonStyle: 'default',
buttonLabel: '',
splitterWidth: 2,
isResizable: true,
showArrowOnHover: false,
disableDoubleClick: false,
tooltip: ''
};
},
watch: {
config: {
handler(newVal) {
if (!newVal) return;
Object.assign(this.$data, this.deepMerge(this.$data, newVal));
this.$nextTick(() => {
this.$el.style.setProperty('--splitter-thickness', `${this.splitterWidth}px`);
});
},
deep: true,
immediate: true
},
currentSize(value) {
if (value <= this.autoCollapseThreshold) {
value = 0;
}
this.isCollapsed = value === 0;
this.$emit('resize', { size: value });
},
isCollapsed(value) {
this.$emit('collapse-state-change', { collapsed: value });
}
},
mounted() {
this.$nextTick(() => {
this.container = this.$el.parentNode;
this.initializeSizeBackup();
});
},
beforeUnmount() {
this.detachDragEvents();
},
methods: {
deepMerge(target, source) {
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
target[key] = this.deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
},
initializeSizeBackup() {
if (!this.container) return;
const rect = this.container.getBoundingClientRect();
this.lastKnownSize = ['left', 'right'].includes(this.position)
? rect.width
: rect.height;
},
enableTransition() {
const el = this.container;
if (el) {
el.dataset.transitioning = 'true';
setTimeout(() => { delete el.dataset.transitioning; }, 200);
}
},
handleToggle() {
this.toggleCollapse();
},
toggleOnDoubleClick() {
if (this.disableDoubleClick || !this.isResizable) return;
this.toggleCollapse();
},
toggleCollapse(forcedState) {
const shouldCollapse = forcedState !== undefined ? forcedState : !this.isCollapsed;
this.isCollapsed = shouldCollapse;
const restoreSize = this.lastKnownSize > this.autoCollapseThreshold
? this.lastKnownSize
: this.defaultExpandSize;
this.enableTransition();
this.currentSize = shouldCollapse ? 0 : restoreSize;
this.$emit('resize', { size: this.currentSize });
},
updateLastSize() {
this.lastKnownSize = this.currentSize;
},
attachDragEvents() {
if (!this.isResizable) return;
this.detachDragEvents();
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
},
detachDragEvents() {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
},
handleMouseMove(e) {
if (!this.isResizable || !this.container) return;
const { clientX, clientY } = e;
const rect = this.container.getBoundingClientRect();
let newSize;
switch (this.position) {
case 'left':
newSize = rect.right - clientX;
break;
case 'right':
newSize = clientX - rect.left;
break;
case 'top':
newSize = rect.bottom - clientY;
break;
case 'bottom':
newSize = clientY - rect.top;
break;
default:
return;
}
if (this.minDimension && newSize < this.minDimension) newSize = this.minDimension;
if (this.maxDimension && newSize > this.maxDimension) newSize = this.maxDimension;
this.currentSize = newSize;
this.updateLastSize();
},
handleMouseUp() {
this.detachDragEvents();
}
}
};
</script>
<style scoped>
.vue-splitter {
--splitter-thickness: 2px;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-color: #efefef;
user-select: none;
}
.toggle-button {
opacity: 0;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
margin: auto;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.2s;
font-size: 12px;
color: #409eff;
background: white;
border-radius: 4px;
}
.toggle-button:hover {
opacity: 0.9 !important;
}
.toggle-button[data-style="blue"] {
color: white;
background: #4f6bdf;
}
.toggle-button[data-style="dark"] {
color: white;
background: #1f2d3d;
}
.toggle-button[data-collapsed] {
opacity: 1;
pointer-events: auto;
}
/* Vị trí nút */
[data-position="left"] {
width: var(--splitter-thickness);
height: 100%;
cursor: col-resize;
}
[data-position="left"] .toggle-button {
flex-direction: column;
width: 20px;
padding: 10px 0;
right: var(--splitter-thickness);
left: auto;
border-radius: 8px 0 0 8px;
box-shadow: -5px 0 10px rgba(0,0,0,0.1);
}
[data-position="right"] {
width: var(--splitter-thickness);
height: 100%;
cursor: col-resize;
left: auto;
right: 0;
}
[data-position="right"] .toggle-button {
flex-direction: column;
width: 20px;
left: var(--splitter-thickness);
padding: 10px 0;
border-radius: 0 8px 8px 0;
box-shadow: 5px 0 10px rgba(0,0,0,0.1);
}
[data-position="top"] {
width: 100%;
height: var(--splitter-thickness);
cursor: row-resize;
}
[data-position="top"] .toggle-button {
height: 20px;
padding: 0 10px;
bottom: var(--splitter-thickness);
top: auto;
border-radius: 8px 8px 0 0;
box-shadow: 0 -5px 10px rgba(0,0,0,0.1);
}
[data-position="bottom"] {
width: 100%;
height: var(--splitter-thickness);
cursor: row-resize;
top: auto;
bottom: 0;
}
[data-position="bottom"] .toggle-button {
height: 20px;
padding: 0 10px;
top: var(--splitter-thickness);
border-radius: 0 0 8px 8px;
box-shadow: 0 5px 10px rgba(0,0,0,0.1);
}
/* Hiệu ứng hover */
[data-show-on-hover],
.vue-splitter:hover {
background-color: #b3d8ff;
}
[data-show-on-hover] .toggle-button,
.vue-splitter:hover .toggle-button {
opacity: 1;
pointer-events: auto;
}
/* Hiệu ứng kéo */
.vue-splitter::after {
content: '';
position: absolute;
background: rgba(64, 158, 255, 0.13);
opacity: 0;
transition: opacity 0.2s;
}
[data-position="left"],
[data-position="right"] {
--extend: 5px;
--total: calc(var(--splitter-thickness) + 2 * var(--extend));
&::after {
width: var(--total);
height: 100%;
left: calc(-1 * var(--extend));
}
}
[data-position="top"],
[data-position="bottom"] {
--extend: 5px;
--total: calc(var(--splitter-thickness) + 2 * var(--extend));
&::after {
width: 100%;
height: var(--total);
top: calc(-1 * var(--extend));
}
}
.vue-splitter:active {
background-color: #409eff;
&::after {
opacity: 1;
}
}
/* Tắt chế độ kéo */
[data-disabled] {
cursor: default;
background: transparent;
&::after { display: none; }
}
</style>
<style>
[vue-splitter-transitioning] {
transition: width 0.2s, height 0.2s;
}
</style>
Ví dụ sử dụng
<template>
<div class="layout-demo">
<div class="panel-left" :style="{ width: leftPanelWidth + 'px' }">
<VueSplitter
:config="{ position: 'right' }"
@resize="(e) => leftPanelWidth = e.size"
/>
</div>
<div class="panel-right">
<div class="panel-top" :style="{ height: topPanelHeight + 'px' }">
<VueSplitter
:config="{
position: 'bottom',
buttonStyle: 'blue',
buttonLabel: 'Menu'
}"
@resize="(e) => topPanelHeight = e.size"
/>
</div>
<div class="panel-bottom">
<div class="inner-bottom" :style="{ height: innerBottomHeight + 'px' }">
<VueSplitter
:config="{ position: 'top' }"
@resize="(e) => innerBottomHeight = e.size"
/>
</div>
<div class="inner-right" :style="{ width: rightSidebarWidth + 'px' }">
<VueSplitter
:config="{
position: 'left',
buttonStyle: 'dark',
buttonLabel: 'Công cụ',
showOnHover: true
}"
@resize="(e) => rightSidebarWidth = e.size"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import VueSplitter from './VueSplitter.vue';
export default {
components: { VueSplitter },
data() {
return {
leftPanelWidth: 200,
topPanelHeight: 200,
innerBottomHeight: 200,
rightSidebarWidth: 200
};
}
};
</script>
<style scoped>
.layout-demo {
display: flex;
height: 100vh;
}
.panel-left {
flex-shrink: 0;
position: relative;
border-right: 1px solid #eee;
}
.panel-right {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.panel-top {
flex-shrink: 0;
border-bottom: 1px solid #eee;
}
.panel-bottom {
flex-grow: 1;
display: flex;
}
.inner-bottom {
flex-shrink: 0;
border-top: 1px solid #eee;
}
.inner-right {
flex-shrink: 0;
border-left: 1px solid #eee;
}
</style>