Vue 2 슬롯과 커스텀 지시문을 활용한 동적 상품 리스트 컴포넌트 구현

Vue 2에서 컴포넌트의 재사용성을 극대화하기 위해 슬롯(Slots)과 커스텀 지시문(Custom Directives)을 활용하는 방법은 매우 중요합니다. 이번 예제에서는 범용적인 테이블 컴포넌트와 편집 가능한 태그 컴포넌트를 설계하여 실제 상품 관리 리스트를 구현하는 과정을 살펴봅니다.

주요 학습 포인트

  • 컴포넌트 통신: props, $emit, 그리고 v-model을 이용한 양방향 데이터 바인딩
  • DOM 제어: $nextTick과 커스텀 지시문을 활용한 자동 포커스 구현
  • 유연한 구조: 이름이 있는 슬롯(Named Slots)과 범위가 지정된 슬롯(Scoped Slots)을 통한 레이아웃 커스터마이징

1. 전역 지시문 설정 (main.js)

입력 필드가 나타날 때 자동으로 포커스를 줄 수 있도록 커스텀 지시문 v-focus를 등록합니다. inserted 훅은 요소가 DOM에 삽입될 때 실행되므로 포커스 처리에 적합합니다.

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

// 자동 포커스를 위한 전역 지시문 등록
Vue.directive('focus', {
  inserted(el) {
    el.focus()
  }
})

new Vue({
  render: h => h(App),
}).$mount('#app')

2. 테이블 컴포넌트 설계 (CustomTable.vue)

데이터 구조에 종속되지 않는 범용 테이블을 만들기 위해 헤더와 본문 영역을 슬롯으로 처리합니다. 특히 본문 영역은 각 행의 데이터에 접근할 수 있도록 Scoped Slot을 사용합니다.

<template>
  <table class="generic-table">
    <thead>
      <tr>
        <!-- 헤더 영역 슬롯 -->
        <slot name="header"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, idx) in listData" :key="row.id">
        <!-- 본문 데이터 전달을 위한 Scoped Slot -->
        <slot name="body" :row="row" :index="idx"></slot>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    listData: {
      type: Array,
      required: true
    }
  }
}
</script>

<style scoped>
.generic-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 20px;
}
.generic-table th {
  background-color: #f8f9fa;
  border-bottom: 2px solid #333;
  padding: 12px;
}
.generic-table td {
  border-bottom: 1px solid #eee;
  padding: 10px;
  text-align: center;
}
</style>

3. 편집 가능한 태그 컴포넌트 (EditableTag.vue)

더블 클릭 시 입력 모드로 전환되며, 엔터 키를 누르거나 포커스를 잃으면 부모의 데이터를 업데이트합니다. v-model을 지원하도록 설계되었습니다.

<template>
  <div class="tag-container">
    <input
      v-if="editMode"
      v-focus
      class="tag-input"
      type="text"
      :value="value"
      @blur="editMode = false"
      @keyup.enter="confirmEdit"
    />
    <span
      v-else
      @dblclick="enableEdit"
      class="tag-text"
    >
      {{ value }}
    </span>
  </div>
</template>

<script>
export default {
  props: {
    value: String
  },
  data() {
    return {
      editMode: false
    }
  },
  methods: {
    enableEdit() {
      this.editMode = true
    },
    confirmEdit(e) {
      const newVal = e.target.value.trim()
      if (!newVal) return alert('내용을 입력해주세요.')
      
      this.$emit('input', newVal)
      this.editMode = false
    }
  }
}
</script>

<style scoped>
.tag-container {
  display: inline-block;
  min-width: 60px;
}
.tag-input {
  width: 80px;
  padding: 4px;
  border: 1px solid #007bff;
}
.tag-text {
  cursor: pointer;
  padding: 4px 8px;
  background: #e9ecef;
  border-radius: 4px;
}
</style>

4. 메인 애플리케이션 통합 (App.vue)

앞서 만든 컴포넌트들을 조립하여 최종 화면을 구성합니다. CustomTable의 슬롯에 실제 데이터를 매핑하고, EditableTag를 통해 상품 카테고리를 직접 수정할 수 있게 합니다.

<template>
  <div id="app" class="container">
    <CustomTable :listData="products">
      <!-- 헤더 정의 -->
      <template #header>
        <th>ID</th>
        <th>이미지</th>
        <th>상품명</th>
        <th>카테고리</th>
      </template>

      <!-- 본문 데이터 매핑 -->
      <template #body="{ row, index }">
        <td>{{ index + 1 }}</td>
        <td><img :src="row.image" class="prod-img" /></td>
        <td>{{ row.title }}</td>
        <td>
          <EditableTag v-model="row.category"></EditableTag>
        </td>
      </template>
    </CustomTable>
  </div>
</template>

<script>
import CustomTable from './components/CustomTable.vue'
import EditableTag from './components/EditableTag.vue'

export default {
  components: {
    CustomTable,
    EditableTag
  },
  data() {
    return {
      products: [
        { id: 1, image: 'https://picsum.photos/100?random=1', title: '빈티지 찻잔 세트', category: '주방용품' },
        { id: 2, image: 'https://picsum.photos/100?random=2', title: '고성능 등산화', category: '스포츠' },
        { id: 3, image: 'https://picsum.photos/100?random=3', title: '어린이 울 니트', category: '의류' }
      ]
    }
  }
}
</script>

<style>
.container {
  max-width: 900px;
  margin: 40px auto;
  font-family: sans-serif;
}
.prod-img {
  width: 60px;
  height: 60px;
  object-fit: cover;
}
</style>

태그: Vue.js vue-component slots custom-directives v-model

8월 4일 00:00에 게시됨