表單元件
:::info 內容來源
本頁內容源自 appfuse-web/lib/form/README.md,如有差異以 README 為準。
:::
AppFuse Web 提供完整的表單元件庫,整合 react-hook-form 進行表單狀態管理和驗證。
設計理念
- Modifiers(修飾符):使用者傳入的配置參數(如
onChange、type、multiple) - Hook:自定義 Hook,使用
useController處理表單邏輯 - Hints(提示):傳遞給基礎 UI 組件的計算屬性(如翻譯後的
label、error) - Component:基礎 UI 元件渲染
基本用法
建立表單
import { useForm } from 'react-hook-form';
import { Input, Select, Checkbox } from '@appfuse/appfuse-web/form';
import { schema, validatorResolver } from '@appfuse/appfuse-web/form/validator';
// 1. 定義 Schema(使用框架內建的輕量驗證器)
const userSchema = schema.object({
name: schema.string().required(),
email: schema.string().required(),
role: schema.string().required(),
active: schema.boolean(),
});
// 2. 建立表單
function UserForm() {
const { control, handleSubmit } = useForm({
resolver: validatorResolver(userSchema),
defaultValues: {
name: null,
email: null,
role: null,
active: true,
},
});
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input name="name" control={control} label="Name" />
<Input name="email" control={control} label="Email" type="email" />
<Select
name="role"
control={control}
label="Role"
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
/>
<Checkbox name="active" control={control} label="Active" />
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
);
}
:::warning 驗證策略
AppFuse Web 採用「前端輕量、後端完整」的驗證策略。前端僅驗證類型和必填,格式驗證(Email、長度等)由後端處理。禁止使用 Zod,請使用框架內建的 schema 驗證器(~5KB vs Zod ~60KB)。
:::
表單元件一覽
文字輸入
| 元件 | 用途 | 特性 |
|---|---|---|
Input | 單行文字 | 支援 text, number, date, email, password |
Textarea | 多行文字 | 自動調整高度、字數統計 |
TagInput | 標籤輸入 | 多值輸入、可創建新標籤 |
RichTextEditor | 富文本 | 基於 Tiptap v3 / ProseMirror,值為 HTML 字串 |
OtpInput | 驗證碼 | 分格視覺、底層單一輸入欄;支援貼上與自動填入,onComplete 可接自動送出 |
選擇輸入
| 元件 | 用途 | 特性 |
|---|---|---|
Select | 下拉選單 | 單選/多選、依賴欄位聯動過濾 |
Checkbox | 核取方塊 | 布林/單選/多選模式 |
CheckboxGroup | 核取群組 | 宣告式多選 |
Radio | 單選按鈕 | 單一選擇 |
RadioGroup | 單選群組 | 宣告式單選 |
Switch | 開關 | 布林/單選/多選模式 |
DatePicker | 日期選擇 | local-date 使用 ISO 日期字串;instant 使用 Date 並須指定日期補時策略;選日會依該策略重建 Instant |
TimePicker | 時間選擇 | 表單值為 LocalTime 形狀的無時區字串 |
DateTimePicker | 日期時間選擇 | local-date-time 使用無時區字串;instant 使用 Date;編輯後精度為分鐘或秒,不保留毫秒 |
以上是元件值契約;欄位的語意型別與時區政策須先在應用設計層決定。日期時間欄位預設為
Instant;改用 LocalDate/LocalTime/LocalDateTime,或讓前端 Instant 改採後端業務
時區,必須已有需求依據,或由 AI 建議並經人類確認。前端 Instant 一般預設採瀏覽器時區;
業務時區模式須取得後端公布的 IANA zone,並在 UI 明確標示。完整規範見
JPA Entity 欄位型別規範。
DatePicker type="instant" 的 instantTime 預設為 start-of-day。改用 end-of-day 或自訂鐘面時間,
必須由人類指定,或由 AI 建議並經人類確認。字面鐘面時間會由 TypeScript 檢查;JS 或設定來源的
動態字串若無效,元件顯示 inline error 且不會退回預設值。非法 IANA zone 與不符合
YYYY-MM-DD 的 DayStatus.date 也走相同錯誤出口。form wrapper 的 name 依模式限制到相符值型別;
執行期值若仍不匹配會顯示 inline contract error,不會從 render 丟出例外、改寫原始表單值,或跨型別
轉換;傳給 base picker 的顯示投影暫時為空,直到使用者輸入合法值。
檔案輸入
| 元件 | 用途 | 特性 |
|---|---|---|
FileInput | 檔案上傳 | 拖放、多檔、驗證 |
MediaInput | 媒體上傳 | 圖片/影片預覽、裁切、旋轉 |
元件屬性
共用屬性(FormFieldProps)
所有表單元件都接收以下屬性:
interface FormFieldProps {
name: string; // 欄位名稱(必填)
control: Control; // react-hook-form control(必填)
label?: string; // 標籤(自動翻譯)
}
:::tip 基礎 UI 屬性
variant、density、color、disabled、readOnly 等外觀屬性由基礎 UI 元件提供,透過 ...rest 傳遞。所有表單元件都支援對應基礎元件的全部屬性。
:::
Input 屬性
<Input
name="price"
control={control}
label="Price"
type="number" // text | number | date | email | password | tel | url | datetime-local
min={0} // 最小值(number 類型)
max={9999} // 最大值(number 類型)
step={0.01} // 步進值(number 類型)
onChange={(value, event) => {
console.log('新值:', value) // 已轉換的類型(number | null)
}}
/>
Select 屬性
<Select
name="category"
control={control}
label="Category"
options={categories}
multiple={false} // 是否多選
creatable={false} // 是否可創建新選項(繼承自基礎元件)
dependency="parentField" // 依賴欄位(連動過濾)
filter={(option, dependencyValue) => option.parentId === dependencyValue}
onChange={(value) => {
console.log('選擇的值:', value)
}}
/>
:::info 搜尋功能
Select 的搜尋功能由基礎元件自動控制,當選項數量超過 searchThreshold(預設 10)時自動啟用,無需手動設定。
:::
Checkbox 屬性
// 布林模式(預設)
<Checkbox
name="agree"
control={control}
label="I agree to terms"
/>
// 單選模式(有 value,可取消選擇)
<Checkbox
name="role"
control={control}
label="Admin"
value="admin"
/>
// 多選模式(有 value + multiple)
<Checkbox name="features" control={control} label="WiFi" value="wifi" multiple />
<Checkbox name="features" control={control} label="Parking" value="parking" multiple />
OtpInput 屬性
分格驗證碼輸入(OTP / PIN / 交易代碼)。視覺為多個分格,但底層是單一輸入欄——貼上、
autocomplete="one-time-code" 自動填入與螢幕閱讀器因此全部沿用原生行為。
<OtpInput
name="code"
control={control}
label="Verification code"
length={6} // 位數(預設 6)
type="numeric" // numeric | alphanumeric(後者預設轉大寫)
variant="filled" // filled | bordered(分格外觀)
onComplete={(code) => void verify(code)} // 輸滿即觸發
/>
:::tip 自動送出屬應用層決策
框架只提供 onComplete 接縫,不內建送出。需要「輸滿即驗證、畫面不放送出按鈕」時,在
onComplete 送出並自行以送出中狀態防止重複觸發;驗證失敗時清空欄位讓使用者重打。
:::
:::info 從郵件貼上
貼上含前後文的整段文字(如「您的驗證碼為 482731,10 分鐘內有效」)只會取出獨立的代碼,
不會把其他數字串接進來;帶分隔符的 482-731、482 731 亦可正確還原。
:::
標籤自動翻譯
所有表單元件會自動翻譯以下 props,傳入 i18n key(英文)即可:
| Prop | 適用元件 | 說明 |
|---|---|---|
label | 所有表單元件 | 欄位標籤 |
aria-label | Input, Select, TagInput 等 | 無障礙標籤 |
placeholder | TagInput, Select(搜尋框) | 提示文字 |
// ✅ 正確:傳入 i18n key
<Input name="email" control={control} label="Email" />
<Select name="role" control={control} label="Role" options={roles} />
// ❌ 錯誤:不要先翻譯再傳入(會造成雙重翻譯)
<Input name="email" control={control} label={t('Email')} />
:::info Input / Textarea 的 placeholder
Input 和 Textarea 為了實現浮動標籤效果,placeholder 被硬編碼為 " ",傳入的 placeholder prop 不會顯示。
:::
表單值設計
空值處理
AppFuse Web 使用 null 表示空值(非空字串 ''):
// ✅ 正確的預設值
const defaultValues = {
name: null, // 字串欄位用 null
category: null, // 選擇欄位用 null
price: null, // 數字欄位用 null
date: null, // 日期欄位用 null
agree: false, // Boolean 永遠有值
tags: [], // 陣列使用 []
};
// ❌ 錯誤
const defaultValues = {
category: '', // Select 不應使用空字串
price: 0, // 0 是有意義的值,不是空值
};
為什麼用 null 而非 ''?
- 與 API 一致:RESTful API 回傳
null表示缺失值,使用null可直接對應 - 語義清晰:
null= 未填寫,''= 刻意填寫空白 - 型別安全:
string | null明確表達可為空,string則語義不清 - 自動轉換:表單元件自動處理
null↔''的轉換,開發者無需關心
型別轉換
表單元件會自動處理型別轉換:
| 元件 | HTML 值 | 表單值 |
|---|---|---|
| Input (text) | '' | '' |
| Input (number) | '' | null |
| Input (number) | '123' | 123 |
| Input (date) | '' | null |
| Select | undefined | null |
驗證
使用框架內建驗證器
import { schema, validatorResolver } from '@appfuse/appfuse-web/form/validator';
const customerSchema = schema.object({
// 必填字串
name: schema.string().required(),
// 必填選擇
category: schema.string().required(),
// 必填數字
price: schema.number().required(),
// 選填
note: schema.string(),
// 選填數字(nullable)
discount: schema.number().nullable(),
});
// 搭配 react-hook-form 使用
const { control, handleSubmit } = useForm({
resolver: validatorResolver(customerSchema),
});
:::tip 前端驗證範圍
前端只驗證類型和必填。格式驗證(Email 格式、字串長度等)由後端 @Email、@Length 等 Bean Validation 處理。
:::
後端違例處理
後端回傳的驗證錯誤可自動映射到表單欄位:
import { mapViolationsToFormErrors, createServerErrorHandler } from '@appfuse/appfuse-web/form/validator';
const onSubmit = async (data) => {
try {
await api.post('/customers', data);
prompt.success('Customer created');
} catch (error) {
// 自動將後端 violations 映射到 react-hook-form 錯誤
const handler = createServerErrorHandler(setError);
handler(error);
}
};
錯誤訊息翻譯
錯誤訊息會自動翻譯,並替換 ${field} 為欄位的翻譯後標籤:
驗證器生成錯誤 → 訊息模板 + 參數 → Hook 翻譯 → 顯示本地化錯誤
在 src/nls/message/ 中定義錯誤訊息翻譯:
// src/nls/message/zh-TW.ts
export default {
'${field} is required': '${field}為必填',
'${field} must be ${type}': '${field}必須為${type}類型',
'${field} must be an array': '${field}必須為陣列',
'${field} must not be empty': '${field}不可為空',
}
效果:假設 label="Name" 且 i18n 定義 Name → 姓名,驗證失敗時顯示「姓名為必填」。
後端回傳的 violation 錯誤也使用相同格式,前端自動處理:
{
"violations": [{
"format": "${field} is already taken",
"params": { "field": "email" }
}]
}
進階用法
連動選單
// 縣市 → 區域連動
<Select
name="city"
control={control}
label="City"
options={cities}
/>
<Select
name="district"
control={control}
label="District"
options={districts}
dependency="city"
filter={(option, cityValue) => option.cityId === cityValue}
/>
:::info 連動行為
當 dependency 指定的欄位值改變時,Select 會自動重設當前欄位值為 null,並根據 filter 函式過濾可選選項。
:::
動態表單
import { useFieldArray } from 'react-hook-form';
function OrderForm() {
const { control } = useForm();
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<Input
name={`items.${index}.name`}
control={control}
label="Item Name"
/>
<Input
name={`items.${index}.quantity`}
control={control}
label="Quantity"
type="number"
/>
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '', quantity: 1 })}>
Add Item
</button>
</div>
);
}
唯讀模式
// 單一欄位
<Input name="id" control={control} label="ID" readOnly />
// 整個表單
function ViewForm({ data }) {
const { control } = useForm({ defaultValues: data });
return (
<fieldset disabled>
<Input name="name" control={control} label="Name" readOnly />
<Select name="status" control={control} label="Status" options={statuses} readOnly />
</fieldset>
);
}
外觀變體
Variant(外框樣式)
// Bordered(預設)- 有邊框
<Input name="name" control={control} variant="bordered" />
// Ghost - 無邊框,僅底線
<Input name="name" control={control} variant="ghost" />
Density(密度)
// Comfortable(預設)- 標準間距
<Input name="name" control={control} density="comfortable" />
// Compact - 緊湊間距
<Input name="name" control={control} density="compact" />
Color(顏色)
<Input name="name" control={control} color="primary" />
<Input name="name" control={control} color="error" />
className 的分層(版面 vs 排版)
帶浮動標籤的欄位內部是三層結構——外層 wrapper 包住「內層控制項 + 浮動標籤 + notch 邊框缺口」。
className→ 外層 wrapper:版面用途(寬度、間距、對齊){內層元素}ClassName→ 內層控制項:排版用途(對齊、字距、字級、字體)
| 元件 | 內層屬性 | 套用對象 |
|---|---|---|
Input | inputClassName | 內層 <input> |
Textarea | textareaClassName | 內層 <textarea> |
TagInput | inputClassName | 內層輸入 <input>(標籤 chip 用 badgeClassName) |
Select | controlClassName | 觸發框(多選 chip 用 badgeClassName) |
:::warning 排版屬性不要寫在 className
text-center、tracking-*、text-* 字級寫在 className 會經 CSS 繼承散到浮動標籤與 notch
legend 上——標籤被字距撐開、邊框缺口與標籤對不齊;字級則因控制項自帶 text-base 而不生效。
:::
// ❌ 排版屬性寫在 className
<Input name="code" control={control} label="Verification code"
className="text-center tracking-[0.4em] text-xl" />
// ✅ 版面歸 className、排版歸 inputClassName
<Input name="code" control={control} label="Verification code"
className="max-w-xs"
inputClassName="text-center tracking-[0.4em] text-xl" />
內層屬性以 tailwind-merge 接在元件內建 class 之後,可直接覆寫內建的字級等衝突 class,不需
!important。Checkbox / Radio / Switch 的 className 本就直接套在控制項上,不需此屬性;
DatePicker / TimePicker / DateTimePicker 以 Input 為外殼,直接支援
inputClassName。
最佳實踐
- 使用框架內建驗證器 - 使用
schema+validatorResolver,禁止使用 Zod - 前端輕量驗證 - 只驗證類型和必填,格式驗證交給後端
- 正確的預設值 - 選擇和數字欄位使用
null,布林欄位使用false,陣列使用[] - 標籤自動翻譯 - label 傳入英文 key,框架自動翻譯
- 保持一致性 - 同一表單使用相同的 variant 和 density