Vue: Modal Component (resize, drag, move)

calico·2025년 8월 12일

Vue

목록 보기
8/9

<template>
  <transition name="slide-floating">
    <div v-if="visible"
         class="floating-panel"
         :style="{
           left: pos.x + 'px',
           top: pos.y + 'px',
           width: size.width + 'px',
           height: size.height + 'px',
           opacity: opacity
         }">
      <!-- 헤더 -->
      <div class="floating-header" @mousedown="startDrag">
        <span class="floating-title">{{ title }}</span>
        <div class="floating-header-actions">
          <input type="range" min="0.3" max="1" step="0.01"
                 v-model.number="opacity"
                 class="opacity-slider"
                 @mousedown.stop />
          <button class="close-btn" @click="$emit('close')">×</button>
        </div>
      </div>

      <!-- 내용 -->
      <div class="floating-content">
        <!-- 요약 카드 -->
        <div v-if="summary" class="summary-card">
          <div class="summary-value">{{ summary.value }}</div>
          <div class="summary-label">{{ summary.label }}</div>
        </div>

        <!-- 데이터 리스트 -->
        <div class="data-list" :style="{ gridTemplateColumns: gridColumns }">
          <div v-for="(item, idx) in items" :key="idx" class="data-item">
            <div class="data-label">{{ item.label }}</div>
            <div class="data-value">
              {{ item.value }}
              <span v-if="item.unit" class="unit">{{ item.unit }}</span>
            </div>
          </div>
        </div>
      </div>

      <!-- 리사이즈 핸들 -->
      <div class="resize-handle" @mousedown="startResize"></div>
    </div>
  </transition>
</template>

<script>
export default {
  name: "FloatingPanel",
  props: {
    visible: { type: Boolean, default: false },
    title: { type: String, default: "상세 정보" },
    summary: { type: Object, default: null }, // { value: '82.4', label: '총 에너지 사용량' }
    items: { type: Array, default: () => [] }, // [{ label: '전력', value: 100, unit: 'kWh' }]
    columns: { type: Number, default: 2 } // 데이터 표시 열 개수
  },
  computed: {
    gridColumns() {
      return `repeat(${this.columns}, 1fr)`;
    }
  },
  data() {
    return {
      pos: { x: window.innerWidth / 2 - 200, y: window.innerHeight / 2 - 250 },
      size: { width: 420, height: 520 },
      opacity: 1,
      dragging: false,
      dragOffset: { x: 0, y: 0 },
      resizing: false,
      resizeStart: { x: 0, y: 0 },
      resizeSize: { width: 420, height: 520 }
    };
  },
  methods: {
    startDrag(e) {
      this.dragging = true;
      this.dragOffset = { x: e.clientX - this.pos.x, y: e.clientY - this.pos.y };
      window.addEventListener("mousemove", this.onDrag);
      window.addEventListener("mouseup", this.stopDrag);
    },
    onDrag(e) {
      if (!this.dragging) return;
      this.pos = { x: e.clientX - this.dragOffset.x, y: e.clientY - this.dragOffset.y };
    },
    stopDrag() {
      this.dragging = false;
      window.removeEventListener("mousemove", this.onDrag);
      window.removeEventListener("mouseup", this.stopDrag);
    },
    startResize(e) {
      this.resizing = true;
      this.resizeStart = { x: e.clientX, y: e.clientY };
      this.resizeSize = { ...this.size };
      window.addEventListener("mousemove", this.onResize);
      window.addEventListener("mouseup", this.stopResize);
      e.stopPropagation();
    },
    onResize(e) {
      if (!this.resizing) return;
      const dx = e.clientX - this.resizeStart.x;
      const dy = e.clientY - this.resizeStart.y;
      let w = this.resizeSize.width + dx;
      let h = this.resizeSize.height + dy;
      w = Math.max(320, Math.min(w, 980));
      h = Math.max(300, Math.min(h, 900));
      this.size = { width: w, height: h };
    },
    stopResize() {
      this.resizing = false;
      window.removeEventListener("mousemove", this.onResize);
      window.removeEventListener("mouseup", this.stopResize);
    }
  }
};
</script>

<style scoped>
.floating-panel {
  position: fixed;
  background: linear-gradient(135deg, #f0f4ff, #fef6f9);
  border-radius: 14px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
  z-index: 10000;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid rgba(255,255,255,0.4);
}

.floating-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: rgba(255,255,255,0.6);
  backdrop-filter: blur(8px);
  padding: 12px 16px;
  cursor: move;
  font-weight: bold;
  font-size: 16px;
  color: #23406d;
}

.floating-header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.close-btn {
  background: none;
  border: none;
  font-size: 20px;
  color: #666;
  cursor: pointer;
}
.close-btn:hover {
  color: #e74c3c;
}

.floating-content {
  flex: 1;
  padding: 20px;
  overflow-y: auto;
}

.summary-card {
  background: white;
  border-radius: 12px;
  padding: 16px;
  text-align: center;
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
  margin-bottom: 20px;
}
.summary-value {
  font-size: 28px;
  font-weight: bold;
  color: #2c3e50;
}
.summary-label {
  font-size: 14px;
  color: #888;
}

.data-list {
  display: grid;
  gap: 12px;
}
.data-item {
  background: white;
  border-radius: 10px;
  padding: 12px;
  box-shadow: 0 1px 6px rgba(0,0,0,0.05);
}
.data-label {
  font-size: 14px;
  color: #555;
}
.data-value {
  font-size: 18px;
  font-weight: bold;
  color: #23406d;
}
.unit {
  font-size: 12px;
  color: #888;
}

.resize-handle {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 20px;
  height: 20px;
  cursor: se-resize;
  background: linear-gradient(135deg, transparent 50%, #aab5d2 51%);
}

.opacity-slider {
  width: 50px;
}

.slide-floating-enter-active, .slide-floating-leave-active {
  transition: transform .3s, opacity .2s;
}
.slide-floating-enter-from, .slide-floating-leave-to {
  transform: translateY(20px);
  opacity: 0;
}
</style>



<FloatingPanel
  :visible="showPanel"
  title="학생 성적 요약"
  :summary="{ value: '87.5', label: '평균 점수' }"
  :items="[
    { label: '국어', value: 92, unit: '점' },
    { label: '영어', value: 85, unit: '점' },
    { label: '수학', value: 90, unit: '점' },
    { label: '과학', value: 83, unit: '점' }
  ]"
  :columns="2"
  @close="showPanel = false"
/>

profile
All views expressed here are solely my own and do not represent those of any affiliated organization.

0개의 댓글