Vue Splitter: Thành phần chia vùng tùy chỉnh với khả năng kéo giãn và thu gọn

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>

Thẻ: Vue.js Splitter Component Resizable Layout CSS Transitions Drag and Drop

Đăng vào ngày 22 tháng 6 lúc 04:56