b34b0dd7d6
Validators (Pushes and Pull Requests) / UI & Storage Validation (pull_request) Failing after 12s
Validators (Pushes and Pull Requests) / Database & Schema Validation (pull_request) Successful in 13s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (pull_request) Failing after 28s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (pull_request) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (pull_request) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (pull_request) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (pull_request) Has been skipped
Validators (Pushes and Pull Requests) / CI Workflow Lint (pull_request) Failing after 10s
Validators (Pushes and Pull Requests) / Security & Secrets (pull_request) Successful in 12s
Validators (Pushes and Pull Requests) / Notify PR Results (pull_request) Successful in 2s
Frontend CI Pipeline / ci-frontend-8-steps (pull_request) Failing after 2m50s
219 lines
5.8 KiB
Vue
219 lines
5.8 KiB
Vue
<template>
|
|
<div class="form-group">
|
|
<label :for="fieldId" class="form-label">
|
|
{{ label }}
|
|
<span v-if="required" class="text-danger" aria-label="required">(필수)</span>
|
|
</label>
|
|
<input
|
|
v-if="inputComponent === 'input'"
|
|
:id="fieldId"
|
|
:value="modelValue"
|
|
:type="htmlInputType"
|
|
:class="['form-control', { 'is-invalid': !!error }]"
|
|
:aria-invalid="!!error"
|
|
:aria-describedby="error ? `${fieldId}-error` : undefined"
|
|
:required="required"
|
|
:placeholder="placeholder"
|
|
:min="min"
|
|
:max="max"
|
|
:pattern="pattern"
|
|
@blur="$emit('blur')"
|
|
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
/>
|
|
<input
|
|
v-else-if="inputComponent === 'checkbox'"
|
|
:id="fieldId"
|
|
type="checkbox"
|
|
:checked="modelValue"
|
|
:class="['form-check-input', { 'is-invalid': !!error }]"
|
|
:aria-invalid="!!error"
|
|
:aria-describedby="error ? `${fieldId}-error` : undefined"
|
|
:required="required"
|
|
@blur="$emit('blur')"
|
|
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
|
/>
|
|
<select
|
|
v-else-if="inputComponent === 'select'"
|
|
:id="fieldId"
|
|
:value="modelValue"
|
|
:class="['form-control', { 'is-invalid': !!error }]"
|
|
:aria-invalid="!!error"
|
|
:aria-describedby="error ? `${fieldId}-error` : undefined"
|
|
:required="required"
|
|
@blur="$emit('blur')"
|
|
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<slot name="options" />
|
|
</select>
|
|
<textarea
|
|
v-else
|
|
:id="fieldId"
|
|
:value="modelValue"
|
|
:class="['form-control', { 'is-invalid': !!error }]"
|
|
:aria-invalid="!!error"
|
|
:aria-describedby="error ? `${fieldId}-error` : undefined"
|
|
:required="required"
|
|
:placeholder="placeholder"
|
|
@blur="$emit('blur')"
|
|
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
|
/>
|
|
<small v-if="displayHint" :id="`${fieldId}-hint`" class="form-text text-muted">
|
|
{{ displayHint }}
|
|
</small>
|
|
<div v-if="error" :id="`${fieldId}-error`" class="invalid-feedback d-block">
|
|
{{ error }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { schemaTypeToInputType, type InputType, getHintText } from '@/utils/fieldTypes'
|
|
|
|
interface Props {
|
|
modelValue: string | number | boolean
|
|
label: string
|
|
fieldId?: string
|
|
type?: InputType | 'password' | 'select' | 'textarea'
|
|
schemaType?: string // JSON schema type (auto-infers input type)
|
|
schemaFormat?: string // JSON schema format (e.g., 'email', 'date')
|
|
enumValues?: any[] // For select inputs
|
|
error?: string
|
|
hint?: string
|
|
placeholder?: string
|
|
required?: boolean
|
|
min?: number
|
|
max?: number
|
|
step?: number // For number inputs (e.g., 0.01 for currency)
|
|
pattern?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
required: false
|
|
})
|
|
|
|
defineEmits<{
|
|
'update:modelValue': [value: string | number | boolean]
|
|
blur: []
|
|
input: []
|
|
}>()
|
|
|
|
const fieldId = computed(() => props.fieldId || `field-${Math.random().toString(36).slice(7)}`)
|
|
|
|
// 자동 타입 추론: 명시적 type이 없으면 schemaType/schemaFormat에서 추론
|
|
const resolvedType = computed<InputType | 'password' | 'select' | 'textarea'>(() => {
|
|
if (props.type) {
|
|
return props.type
|
|
}
|
|
|
|
// schemaType/schemaFormat에서 자동 추론
|
|
const inferred = schemaTypeToInputType(props.schemaType, props.schemaFormat, props.enumValues)
|
|
return inferred
|
|
})
|
|
|
|
const inputComponent = computed(() => {
|
|
switch (resolvedType.value) {
|
|
case 'select': return 'select'
|
|
case 'textarea': return 'textarea'
|
|
case 'checkbox': return 'checkbox'
|
|
default: return 'input'
|
|
}
|
|
})
|
|
|
|
// 힌트 자동 생성 (props.hint가 없으면)
|
|
const displayHint = computed(() => {
|
|
if (props.hint) return props.hint
|
|
return getHintText(resolvedType.value as InputType, {
|
|
min: props.min,
|
|
max: props.max,
|
|
pattern: props.pattern
|
|
})
|
|
})
|
|
|
|
// 타입별 HTML input type 결정
|
|
const htmlInputType = computed(() => {
|
|
const type = resolvedType.value
|
|
// HTML input type과 Vue용 type 구분
|
|
if (type === 'select' || type === 'textarea' || type === 'password') {
|
|
return 'text' // placeholder만 사용
|
|
}
|
|
return type === 'checkbox' ? 'checkbox' : (type as any)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.form-group {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.text-danger {
|
|
color: #dc3545;
|
|
margin-left: 0.25rem;
|
|
}
|
|
|
|
.form-control {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 1rem;
|
|
line-height: 1.5;
|
|
color: #495057;
|
|
background-color: #fff;
|
|
border: 1px solid #ced4da;
|
|
border-radius: 0.25rem;
|
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
}
|
|
|
|
.form-control:focus {
|
|
color: #495057;
|
|
background-color: #fff;
|
|
border-color: #80bdff;
|
|
outline: 0;
|
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
|
}
|
|
|
|
.form-control.is-invalid {
|
|
border-color: #dc3545;
|
|
}
|
|
|
|
.form-control.is-invalid:focus {
|
|
border-color: #dc3545;
|
|
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
|
|
}
|
|
|
|
.form-text {
|
|
display: block;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.875rem;
|
|
color: #6c757d;
|
|
}
|
|
|
|
.invalid-feedback {
|
|
display: block;
|
|
color: #dc3545;
|
|
font-size: 0.875rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
textarea.form-control {
|
|
resize: vertical;
|
|
min-height: 6rem;
|
|
}
|
|
|
|
select.form-control {
|
|
appearance: none;
|
|
padding-right: 1.5rem;
|
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
|
|
background-repeat: no-repeat;
|
|
background-position: right 0.75rem center;
|
|
background-size: 16px 12px;
|
|
}
|
|
</style>
|