---
name: vue-component-gen
description: |
  트리거: "vue 컴포넌트", "vue component gen", "vue 컴포넌트 만들어줘", "뷰 컴포넌트 생성"
  수행: Vue 3 Composition API + <script setup> + TypeScript 기반 컴포넌트 생성. Props, Emits, defineExpose, 접근성 포함
  출력: .vue SFC 파일, 타입 정의 파일, 사용 예시
---

# Vue Component Generator

## 목적

Vue 3 Composition API의 `<script setup>` 문법과 TypeScript를 활용하여 재사용 가능한 SFC(Single File Component)를 생성한다.
Props 검증, Emits 정의, defineExpose로 외부 참조 가능한 메서드까지 완결된 컴포넌트를 제공한다.

## 실행 절차

1. **컴포넌트 요구사항 분석**: 이름, 용도, 필요 Props, 발생 이벤트(Emits), 노출 메서드 파악
2. **타입 정의**: Props 인터페이스, Emits 타입, expose 타입 설계
3. **`<script setup>` 구현**: defineProps, defineEmits, defineExpose, composable 활용
4. **`<template>` 작성**: 접근성(aria) 속성, v-bind/$attrs 전달 처리
5. **`<style scoped>` 또는 Tailwind**: 스코프 스타일 또는 Tailwind 유틸리티 적용
6. **사용 예시 제공**: 부모 컴포넌트에서의 사용 코드 포함

## 출력 형식

### 파일 구조
```
components/
  ComponentName/
    ComponentName.vue    ← 메인 SFC
    types.ts             ← Props/Emits 타입 (복잡한 경우)
    useComponentName.ts  ← composable (로직 분리 필요 시)
    index.ts             ← re-export
```

### 컴포넌트 예시 (Modal)

```vue
<!-- Modal.vue -->
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue';

export interface ModalProps {
  /** 모달 표시 여부 */
  modelValue: boolean;
  /** 모달 제목 */
  title: string;
  /** 모달 크기 */
  size?: 'sm' | 'md' | 'lg' | 'xl';
  /** ESC 키로 닫기 허용 */
  closeOnEsc?: boolean;
  /** 배경 클릭으로 닫기 허용 */
  closeOnBackdrop?: boolean;
  /** 닫기 버튼 표시 */
  showClose?: boolean;
}

export type ModalEmits = {
  'update:modelValue': [value: boolean];
  close: [];
  open: [];
};

const props = withDefaults(defineProps<ModalProps>(), {
  size: 'md',
  closeOnEsc: true,
  closeOnBackdrop: true,
  showClose: true,
});

const emit = defineEmits<ModalEmits>();

const modalRef = ref<HTMLDivElement | null>(null);
const previousFocus = ref<HTMLElement | null>(null);

const sizeClasses = {
  sm: 'max-w-sm',
  md: 'max-w-md',
  lg: 'max-w-lg',
  xl: 'max-w-xl',
};

function close() {
  emit('update:modelValue', false);
  emit('close');
}

function handleBackdropClick(e: MouseEvent) {
  if (props.closeOnBackdrop && e.target === e.currentTarget) {
    close();
  }
}

function handleKeydown(e: KeyboardEvent) {
  if (props.closeOnEsc && e.key === 'Escape') {
    close();
  }
  // 포커스 트랩
  if (e.key === 'Tab' && modalRef.value) {
    const focusable = modalRef.value.querySelectorAll<HTMLElement>(
      'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }
}

watch(
  () => props.modelValue,
  async (isOpen) => {
    if (isOpen) {
      previousFocus.value = document.activeElement as HTMLElement;
      document.body.style.overflow = 'hidden';
      await nextTick();
      modalRef.value?.focus();
      emit('open');
    } else {
      document.body.style.overflow = '';
      previousFocus.value?.focus();
    }
  }
);

onMounted(() => {
  document.addEventListener('keydown', handleKeydown);
});

onUnmounted(() => {
  document.removeEventListener('keydown', handleKeydown);
  document.body.style.overflow = '';
});

// 외부에서 프로그래매틱 제어 가능하도록 노출
defineExpose({ close });
</script>

<template>
  <Teleport to="body">
    <Transition name="modal">
      <div
        v-if="modelValue"
        class="fixed inset-0 z-50 flex items-center justify-center p-4"
        role="dialog"
        aria-modal="true"
        :aria-labelledby="`modal-title-${title}`"
        @click="handleBackdropClick"
      >
        <!-- 배경 오버레이 -->
        <div class="absolute inset-0 bg-black/50" aria-hidden="true" />

        <!-- 모달 패널 -->
        <div
          ref="modalRef"
          tabindex="-1"
          :class="[
            'relative z-10 w-full rounded-xl bg-white shadow-2xl outline-none',
            sizeClasses[size],
          ]"
        >
          <!-- 헤더 -->
          <div class="flex items-center justify-between border-b px-6 py-4">
            <h2
              :id="`modal-title-${title}`"
              class="text-lg font-semibold text-gray-900"
            >
              {{ title }}
            </h2>
            <button
              v-if="showClose"
              type="button"
              class="rounded-md p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
              aria-label="모달 닫기"
              @click="close"
            >
              <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
              </svg>
            </button>
          </div>

          <!-- 본문 -->
          <div class="px-6 py-4">
            <slot />
          </div>

          <!-- 푸터 (슬롯) -->
          <div v-if="$slots.footer" class="border-t px-6 py-4">
            <slot name="footer" />
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.modal-enter-active,
.modal-leave-active {
  transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
  opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
  transition: transform 0.2s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
  transform: scale(0.95);
}
</style>
```

```ts
// index.ts
export { default as Modal } from './Modal.vue';
export type { ModalProps, ModalEmits } from './Modal.vue';
```

### 부모 컴포넌트 사용 예시

```vue
<script setup lang="ts">
import { ref } from 'vue';
import { Modal } from '@/components/Modal';

const isOpen = ref(false);
const modalRef = ref<InstanceType<typeof Modal> | null>(null);
</script>

<template>
  <button @click="isOpen = true">모달 열기</button>

  <Modal
    v-model="isOpen"
    title="확인"
    size="md"
    @close="console.log('닫힘')"
    ref="modalRef"
  >
    <p>내용이 여기에 들어갑니다.</p>
    <template #footer>
      <div class="flex justify-end gap-2">
        <button @click="modalRef?.close()">취소</button>
        <button @click="isOpen = false">확인</button>
      </div>
    </template>
  </Modal>
</template>
```

## 사용 예시

**입력:**
> "드롭다운 셀렉트 컴포넌트 만들어줘. 다중 선택 지원하고 검색 기능도 있어야 해."

**출력:**
- `SelectDropdown.vue` - v-model 바인딩, 다중 선택, 키보드 탐색, 검색 필터링
- `useSelectDropdown.ts` - 필터링·선택 로직 composable 분리
- `index.ts` - re-export

## 주의사항

- `Options API` 사용 금지 — `<script setup>` 문법만 사용
- `defineProps`에는 반드시 TypeScript 인터페이스 타입 사용 (`withDefaults`로 기본값 처리)
- `v-model`은 `modelValue` prop + `update:modelValue` emit 패턴 준수
- `$attrs` 자동 상속이 필요 없는 경우 `inheritAttrs: false` + `v-bind="$attrs"` 수동 적용
- 포커스 트랩과 Teleport는 모달·오버레이 컴포넌트에 필수 적용
- Tailwind 사용 시 동적 클래스는 객체/배열 바인딩 사용 (문자열 템플릿 리터럴 지양)
