v-model
管理欄位,搭配 自訂驗證(onInput
/onBlur
)顯示即時錯誤
disabled
)與 Loading 狀態aria-invalid
、aria-describedby
、錯誤訊息關聯aria-invalid="true"
、aria-describedby="err-id"
。pristine / dirty
、touched / untouched
、valid / invalid
。touched
(是否觸碰)、errors
(錯誤訊息)。src/utils/validators.ts
src/components/Toast.vue
Contact.vue
:欄位狀態、事件、錯誤顯示、送出流程src/utils/validators.ts
// 簡單可讀的規則函式,回傳字串代表錯誤訊息;null 代表通過
export function required(label = '此欄位') {
return (v: string) => v?.trim() ? null : `${label}為必填`
}
export function minLen(n: number, label = '此欄位') {
return (v: string) => v?.trim().length >= n ? null : `${label}至少 ${n} 個字`
}
export function maxLen(n: number, label = '此欄位') {
return (v: string) => v?.trim().length <= n ? null : `${label}不可超過 ${n} 個字`
}
export function email(label = 'Email') {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return (v: string) => re.test(v) ? null : `${label}格式不正確`
}
// 串接多個規則,回傳第一個錯誤
export function composeValidators<T extends string>(...rules: Array<(v: string) => string | null>) {
return (value: string) => {
for (const rule of rules) {
const err = rule(value)
if (err) return err
}
return null
}
}
src/components/Toast.vue
<template>
<transition name="toast-fade">
<div v-if="open" class="toast" role="status" aria-live="polite">
<slot>已送出!感謝你的來信。</slot>
</div>
</transition>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
defineProps<{ open: boolean }>()
</script>
<style scoped>
.toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
background: #111827; color: #fff; padding: 10px 14px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.2); z-index: 1000;
}
.toast-fade-enter-active, .toast-fade-leave-active { transition: opacity .2s, transform .2s; }
.toast-fade-enter-from, .toast-fade-leave-to { opacity: 0; transform: translate(-50%, 8px); }
</style>
真的要更漂亮可以放到 shared/ui/Toast.vue,今天先用最小版。
Contact.vue
v-model
綁定:name
, email
, message
, agree
(是否同意隱私條款)touched
與 errors
:blur 後才顯示錯誤;送出時全檢aria-invalid
、aria-describedby
與 <small class="error">
關聯pending = true
,模擬 await fetch('/api')
,成功後彈出 Toast、resetsrc/components/Contact.vue
<template>
<section id="contact" class="container section" aria-labelledby="contact-title">
<h2 id="contact-title">聯絡我</h2>
<form novalidate @submit.prevent="onSubmit">
<!-- 姓名 -->
<div class="field">
<label for="name">姓名</label>
<input
id="name" name="name" type="text" placeholder="王小明"
v-model.trim="form.name"
@blur="touch('name')"
:aria-invalid="Boolean(showError('name'))"
:aria-describedby="showError('name') ? 'err-name' : undefined"
/>
<small v-if="showError('name')" id="err-name" class="error">{{ errors.name }}</small>
</div>
<!-- Email -->
<div class="field">
<label for="email">Email</label>
<input
id="email" name="email" type="email" placeholder="name@example.com"
v-model.trim="form.email"
@blur="touch('email')"
:aria-invalid="Boolean(showError('email'))"
:aria-describedby="showError('email') ? 'err-email' : undefined"
/>
<small v-if="showError('email')" id="err-email" class="error">{{ errors.email }}</small>
</div>
<!-- 訊息 -->
<div class="field">
<label for="message">訊息</label>
<textarea
id="message" name="message" rows="4" placeholder="想合作的內容…(至少 10 個字)"
v-model.trim="form.message"
@blur="touch('message')"
:aria-invalid="Boolean(showError('message'))"
:aria-describedby="showError('message') ? 'err-message' : 'hint-message'"
/>
<small id="hint-message" class="muted">已輸入 {{ form.message.length }} 字</small>
<small v-if="showError('message')" id="err-message" class="error">{{ errors.message }}</small>
</div>
<!-- 條款 -->
<div class="field">
<label>
<input type="checkbox" v-model="form.agree" @blur="touch('agree')" />
我已閱讀並同意隱私權條款
</label>
<small v-if="showError('agree')" id="err-agree" class="error">{{ errors.agree }}</small>
</div>
<div class="actions">
<button class="btn" type="submit" :disabled="!canSubmit || pending">
{{ pending ? '送出中…' : '送出' }}
</button>
<button class="btn btn-outline" type="button" @click="reset" :disabled="pending">清除</button>
</div>
</form>
<!-- 側邊補充 -->
<aside class="contact-aside">
<h3>其他聯絡方式</h3>
<address>
Email:<a href="mailto:hotdanton08@hotmail.com">hotdanton08@hotmail.com</a><br />
GitHub:<a href="https://github.com/你的帳號" target="_blank">github.com/你的帳號</a><br />
LinkedIn:<a href="https://linkedin.com/in/你的帳號" target="_blank">linkedin.com/in/你的帳號</a>
</address>
</aside>
<!-- 成功提示 -->
<Toast :open="toastOpen">表單已送出,感謝你的來信!</Toast>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import Toast from './Toast.vue'
import { required, minLen, maxLen, email as emailRule, composeValidators } from '@/utils/validators'
type Fields = 'name' | 'email' | 'message' | 'agree'
const form = reactive({
name: '',
email: '',
message: '',
agree: false
})
const touched = reactive<Record<Fields, boolean>>({
name: false, email: false, message: false, agree: false
})
const errors = reactive<Record<Fields, string | null>>({
name: null, email: null, message: null, agree: null
})
// 規則
const validateName = composeValidators(
required('姓名'), minLen(2, '姓名'), maxLen(20, '姓名')
)
const validateEmail = composeValidators(
required('Email'), emailRule('Email')
)
const validateMessage = composeValidators(
required('訊息'), minLen(10, '訊息'), maxLen(500, '訊息')
)
const validateAgree = (v: boolean) => v ? null : '請勾選同意條款'
// 單欄位驗證
function validateField(field: Fields) {
switch (field) {
case 'name': errors.name = validateName(form.name); break
case 'email': errors.email = validateEmail(form.email); break
case 'message': errors.message = validateMessage(form.message); break
case 'agree': errors.agree = validateAgree(form.agree); break
}
}
// blur 後才顯示錯誤
function touch(field: Fields) {
touched[field] = true
validateField(field)
}
// 是否顯示錯誤(已觸碰或送出後)
const submitted = ref(false)
function showError(field: Fields) {
return (touched[field] || submitted.value) && errors[field]
}
// 任何輸入變動時,若已 touched 就即時驗證
function autoValidate(field: Fields) {
if (touched[field]) validateField(field)
}
// 監看 v-model(最簡版:在 input/textarea 綁 @input 呼叫 autoValidate;此處用 computed 讓可提交狀態更新)
const allValid = computed(() => {
// 先跑一輪驗證
(['name','email','message','agree'] as Fields[]).forEach(validateField)
return !errors.name && !errors.email && !errors.message && !errors.agree
})
const canSubmit = computed(() => allValid.value)
const pending = ref(false)
const toastOpen = ref(false)
async function onSubmit() {
submitted.value = true
;(['name','email','message','agree'] as Fields[]).forEach(validateField)
if (!allValid.value) return
pending.value = true
try {
// 模擬呼叫 API
await new Promise(r => setTimeout(r, 900))
toastOpen.value = true
reset()
// 自動關閉 toast
setTimeout(() => (toastOpen.value = false), 2000)
} catch (e) {
// 可在此顯示錯誤 toast 或訊息
console.error(e)
} finally {
pending.value = false
}
}
function reset() {
form.name = ''
form.email = ''
form.message = ''
form.agree = false
;(['name','email','message','agree'] as Fields[]).forEach(f => {
touched[f] = false
errors[f] = null
})
submitted.value = false
}
</script>
小技巧
- 顯示錯誤條件:
(touched[field] || submitted) && errors[field]
可避免「一開始就滿天紅字」。- 送出按鈕:
disabled="!canSubmit || pending"
,避免重複送出。- a11y:欄位錯誤時設定
aria-invalid="true"
並用aria-describedby
指向錯誤<small>
的id
。
npm i vee-validate yup
<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
name: yup.string().required().min(2).max(20),
email: yup.string().required().email(),
message: yup.string().required().min(10).max(500),
agree: yup.boolean().oneOf([true], '請勾選同意條款')
})
const { handleSubmit, isSubmitting } = useForm({ validationSchema: schema })
const { value: name, errorMessage: nameErr } = useField<string>('name')
const { value: email, errorMessage: emailErr } = useField<string>('email')
const { value: message, errorMessage: messageErr } = useField<string>('message')
const { value: agree, errorMessage: agreeErr } = useField<boolean>('agree')
const submit = handleSubmit(async (vals) => {
await new Promise(r => setTimeout(r, 800))
alert('OK')
})
</script>
套件優點:規則重用、表單狀態齊全、跨頁一致;缺點:多學一層抽象。小專案先用原生法就很好,日後再抽換。
aria-invalid
、錯誤訊息與欄位以 aria-describedby
關聯errors.xxx
touched
與 submitted
狀態控制時機aria-invalid
aria-invalid
+ aria-describedby
utils/validators.ts
集中維護把 Projects 改成 非同步載入(fetch
/axios)並加入 Loading / Error 與 簡易快取;詳情頁在未載入時能先抓資料、找不到 slug 顯示 404/提示。