@qijenchen/design-system 0.1.0-beta.79 → 0.1.0-beta.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Field/field.d.ts +2 -0
- package/dist/components/Field/field.d.ts.map +1 -1
- package/dist/components/Field/field.js.map +1 -1
- package/dist/components/Field/index.js +3 -1
- package/dist/components/Field/index.js.map +1 -1
- package/dist/components/Field/use-form-validation.d.ts +88 -0
- package/dist/components/Field/use-form-validation.d.ts.map +1 -0
- package/dist/components/Field/use-form-validation.js +127 -0
- package/dist/components/Field/use-form-validation.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/node_modules/react-hook-form/dist/index.esm.js +1950 -0
- package/dist/node_modules/react-hook-form/dist/index.esm.js.map +1 -0
- package/ds-canonical/skills/deep-audit-cross-codex/SKILL.md +4 -1
- package/ds-story-manifest.json +4 -3
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/src/components/Field/field.spec.md +1 -1
- package/src/components/Field/field.stories.tsx +104 -1
- package/src/components/Field/field.tsx +4 -0
- package/src/components/Field/form-validation.spec.md +31 -10
- package/src/components/Field/use-form-validation.ts +242 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react'
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { Upload } from 'lucide-react'
|
|
5
|
-
import { Field, FieldLabel, FieldDescription, FieldError, FieldGroup } from './field'
|
|
5
|
+
import { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, useFormValidation } from './field'
|
|
6
6
|
import { Input } from '@/design-system/components/Input/input'
|
|
7
7
|
import { Checkbox } from '@/design-system/components/Checkbox/checkbox'
|
|
8
8
|
import { Switch } from '@/design-system/components/Switch/switch'
|
|
@@ -611,3 +611,106 @@ export const SliderWithLiveNumberInput: Story = {
|
|
|
611
611
|
)
|
|
612
612
|
},
|
|
613
613
|
}
|
|
614
|
+
|
|
615
|
+
// ── 表單驗證(useFormValidation)──────────────────────────────────────────
|
|
616
|
+
// form-validation.spec.md 可執行層 showcase:blur 驗證 / edit 清 error / Escape 回復 /
|
|
617
|
+
// submit 全驗 + anchor 第一個錯誤 / 業務錯誤同軌 / Create·Update 按鈕不對稱。
|
|
618
|
+
|
|
619
|
+
const EXISTING_PROJECT_NAMES = ['產品路線圖 Q3', '客服工單系統'] // 模擬「名稱重複」業務驗證(規則 9)
|
|
620
|
+
|
|
621
|
+
function UpdateProjectSettingsForm() {
|
|
622
|
+
const [saved, setSaved] = React.useState(false)
|
|
623
|
+
const form = useFormValidation({
|
|
624
|
+
initialValues: { name: '產品路線圖', ownerEmail: 'pm@acme.com' },
|
|
625
|
+
intent: 'update', // Update:disabled-until-dirty(沒改就不用存)
|
|
626
|
+
validate: {
|
|
627
|
+
name: (v) => (String(v).trim() ? undefined : '專案名稱必填'),
|
|
628
|
+
ownerEmail: (v) => (/^\S+@\S+\.\S+$/.test(String(v)) ? undefined : 'Email 格式不正確'),
|
|
629
|
+
},
|
|
630
|
+
onSubmit: (values) => {
|
|
631
|
+
if (EXISTING_PROJECT_NAMES.includes(String(values.name).trim())) {
|
|
632
|
+
return { name: '此專案名稱已存在' } // 業務驗證(規則 9)→ 自動 setError + anchor
|
|
633
|
+
}
|
|
634
|
+
setSaved(true)
|
|
635
|
+
},
|
|
636
|
+
})
|
|
637
|
+
return (
|
|
638
|
+
<form onSubmit={form.handleSubmit} className="w-80" aria-label="專案設定">
|
|
639
|
+
<FieldGroup>
|
|
640
|
+
<Field required invalid={!!form.errors.name}>
|
|
641
|
+
<FieldLabel>專案名稱</FieldLabel>
|
|
642
|
+
<Input {...form.getInputProps('name')} />
|
|
643
|
+
<FieldDescription>改成「產品路線圖 Q3」可觸發 submit 業務驗證(名稱重複)</FieldDescription>
|
|
644
|
+
<FieldError>{form.errors.name}</FieldError>
|
|
645
|
+
</Field>
|
|
646
|
+
<Field required invalid={!!form.errors.ownerEmail}>
|
|
647
|
+
<FieldLabel>負責人 Email</FieldLabel>
|
|
648
|
+
<Input {...form.getInputProps('ownerEmail')} />
|
|
649
|
+
<FieldError>{form.errors.ownerEmail}</FieldError>
|
|
650
|
+
</Field>
|
|
651
|
+
</FieldGroup>
|
|
652
|
+
{/* 規則 4:內容 → action button = --layout-space-bottom(48px,commitment 前留白) */}
|
|
653
|
+
<div className="mt-[var(--layout-space-bottom)] flex items-center gap-2">
|
|
654
|
+
<Button type="submit" variant="primary" disabled={form.submitDisabled}>儲存變更</Button>
|
|
655
|
+
{saved && <span className="text-caption text-fg-muted">已儲存 ✓</span>}
|
|
656
|
+
</div>
|
|
657
|
+
</form>
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function CreateProjectForm() {
|
|
662
|
+
const [created, setCreated] = React.useState(false)
|
|
663
|
+
const form = useFormValidation({
|
|
664
|
+
initialValues: { name: '', ownerEmail: '' },
|
|
665
|
+
intent: 'create', // Create:永遠 enabled(不讓使用者猜「為什麼按不了」)
|
|
666
|
+
validate: {
|
|
667
|
+
name: (v) => (String(v).trim() ? undefined : '專案名稱必填'),
|
|
668
|
+
ownerEmail: (v) => (/^\S+@\S+\.\S+$/.test(String(v)) ? undefined : 'Email 格式不正確'),
|
|
669
|
+
},
|
|
670
|
+
onSubmit: () => setCreated(true),
|
|
671
|
+
})
|
|
672
|
+
return (
|
|
673
|
+
<form onSubmit={form.handleSubmit} className="w-80" aria-label="建立專案">
|
|
674
|
+
<FieldGroup>
|
|
675
|
+
<Field required invalid={!!form.errors.name}>
|
|
676
|
+
<FieldLabel>專案名稱</FieldLabel>
|
|
677
|
+
<Input placeholder="例:結帳流程改版" {...form.getInputProps('name')} />
|
|
678
|
+
<FieldError>{form.errors.name}</FieldError>
|
|
679
|
+
</Field>
|
|
680
|
+
<Field required invalid={!!form.errors.ownerEmail}>
|
|
681
|
+
<FieldLabel>負責人 Email</FieldLabel>
|
|
682
|
+
<Input placeholder="pm@company.com" {...form.getInputProps('ownerEmail')} />
|
|
683
|
+
<FieldError>{form.errors.ownerEmail}</FieldError>
|
|
684
|
+
</Field>
|
|
685
|
+
</FieldGroup>
|
|
686
|
+
<div className="mt-[var(--layout-space-bottom)] flex items-center gap-2">
|
|
687
|
+
<Button type="submit" variant="primary">建立專案</Button>
|
|
688
|
+
{created && <span className="text-caption text-fg-muted">已建立 ✓</span>}
|
|
689
|
+
</div>
|
|
690
|
+
</form>
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export const FormValidation: Story = {
|
|
695
|
+
name: '表單驗證 — useFormValidation 可執行層',
|
|
696
|
+
render: () => (
|
|
697
|
+
<div className="flex flex-wrap items-start gap-[var(--layout-space-loose)]">
|
|
698
|
+
<div className="flex flex-col gap-[var(--layout-space-tight)]">
|
|
699
|
+
<h3 className="text-body font-bold">更新:專案設定(disabled-until-dirty)</h3>
|
|
700
|
+
<p className="text-caption text-fg-muted max-w-80">
|
|
701
|
+
按鈕沒改不亮;打字中不報錯(blur 才驗);已出錯欄位一編輯立即清 error;
|
|
702
|
+
Escape 回復原值;空 submit / 格式錯 → anchor 到第一個錯誤欄位。
|
|
703
|
+
</p>
|
|
704
|
+
<UpdateProjectSettingsForm />
|
|
705
|
+
</div>
|
|
706
|
+
<div className="flex flex-col gap-[var(--layout-space-tight)]">
|
|
707
|
+
<h3 className="text-body font-bold">建立:新專案(永遠 enabled)</h3>
|
|
708
|
+
<p className="text-caption text-fg-muted max-w-80">
|
|
709
|
+
Create 按鈕永遠可按 —— 點了才驗證全部並 scroll 到第一個錯誤,
|
|
710
|
+
不讓使用者對著 disabled 按鈕猜原因。
|
|
711
|
+
</p>
|
|
712
|
+
<CreateProjectForm />
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
),
|
|
716
|
+
}
|
|
@@ -536,3 +536,7 @@ export const fieldMeta = {
|
|
|
536
536
|
} as const
|
|
537
537
|
|
|
538
538
|
export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup }
|
|
539
|
+
|
|
540
|
+
// form-validation.spec.md 可執行層(per-component index gen 只 re-export 主檔,故經此公開)
|
|
541
|
+
export { useFormValidation } from './use-form-validation'
|
|
542
|
+
export type { UseFormValidationOptions, UseFormValidationReturn, FormFieldInputProps } from './use-form-validation'
|
|
@@ -53,16 +53,37 @@
|
|
|
53
53
|
|
|
54
54
|
兩者都透過 `error` prop / Field context 的 `invalid` 呈現,視覺上一致(紅框 + error message)。
|
|
55
55
|
|
|
56
|
-
###
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
### 可執行層:`useFormValidation`(2026-07-03,本 spec 的 executable arm)
|
|
57
|
+
|
|
58
|
+
上表 9 條方法論已編成 **`useFormValidation` hook 的不可配置預設**(`Field/use-form-validation.ts`,public export)——consumer 拿到就是 canonical 行為,**沒有 API 可以違反**(M17「SSOT 必可傳播」:方法論從 prose 變 executable)。
|
|
59
|
+
|
|
60
|
+
**實作基礎**:基於 react-hook-form(direct dependency,**完全 wrapped 不外露** —— consumer 不 install、不 import、看不到 RHF API;對齊 DS「基於 X」引擎慣例:DataTable 基於 TanStack / DatePicker 基於 react-day-picker / Toast 基於 sonner)。RHF 提供 state / dirty 深比對 / errors store;驗證**時機**由本 hook own(RHF 的 mode / reValidateMode 不外露 = 不可配錯)。世界級對照:Atlassian `@atlaskit/form` 包 react-final-form 同構;MUI 靠第三方 binding(react-hook-form-mui)達成,本 DS 做成第一方。 <!-- @benchmark-unverified -->
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
const form = useFormValidation({
|
|
64
|
+
initialValues: { name: '', email: '' },
|
|
65
|
+
intent: 'update', // 'create'(default)| 'update' → submitDisabled 規則
|
|
66
|
+
validate: { email: (v) => !v.includes('@') ? 'Email 格式不正確' : undefined }, // 格式層(blur)
|
|
67
|
+
onSubmit: async (values) => { // 業務層(submit);回傳 field-keyed errors = 規則 9
|
|
68
|
+
if (await nameTaken(values.name)) return { name: '名稱已存在' }
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
<Field invalid={!!form.errors.name}> {/* Field 層零耦合:一行接 context */}
|
|
72
|
+
<FieldLabel>名稱</FieldLabel>
|
|
73
|
+
<Input {...form.getInputProps('name')} />
|
|
74
|
+
<FieldError>{form.errors.name}</FieldError>
|
|
75
|
+
</Field>
|
|
76
|
+
<Button type="submit" disabled={form.submitDisabled}>儲存</Button>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| 功能(規則) | 實作位置 |
|
|
80
|
+
|---|---|
|
|
81
|
+
| Blur validation timing(1/2/6)+ Edit 清 error(5)+ Escape 回復(4) | **`useFormValidation` 內建(不可配置)** |
|
|
82
|
+
| Submit 全驗 + anchor 第一個錯誤(7/8)+ 業務/async 錯誤同軌(9) | **`useFormValidation.handleSubmit` 內建** |
|
|
83
|
+
| Dirty tracking + Submit button 狀態(Create/Update) | **`useFormValidation.submitDisabled`** |
|
|
84
|
+
| Field error visual(紅框 + FieldError) | Field 元件 `invalid` context(既有,hook 不侵入) |
|
|
85
|
+
|
|
86
|
+
**v1 邊界**(誠實 documented):(a) `getInputProps` 支援 value/onChange 型控件(Input / Textarea / NumberInput / Select / Combobox / DatePicker / TimePicker;onChange 收 event 或裸值皆可);Checkbox / Switch(onCheckedChange)用 `setFieldValue` 自接。(b) focus-first-error 以 DOM `name` 屬性定位 —— native input 生效;非 native 控件 focus 略過(error 視覺仍由 Field 紅框 + FieldError 呈現)。(c) 不用 hook 的 consumer 仍可全手動(Field `invalid` prop 是 engine-agnostic 的,見 field.spec.md 定位)。
|
|
66
87
|
|
|
67
88
|
---
|
|
68
89
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFormValidation — form-validation.spec.md 方法論的可執行層(SSOT executable arm)
|
|
3
|
+
*
|
|
4
|
+
* ── 定位 ──
|
|
5
|
+
* 把 `form-validation.spec.md` 的 9 條驗證方法論編成**不可配置的預設**——consumer 拿到就是
|
|
6
|
+
* canonical 行為,沒有 API 可以違反(M17「SSOT 必可傳播」:方法論從 prose 變 executable)。
|
|
7
|
+
*
|
|
8
|
+
* ── 實作基礎 ──
|
|
9
|
+
* 基於 react-hook-form(direct dependency,完全 wrapped 不外露——對齊 DS「基於 X」引擎慣例:
|
|
10
|
+
* DataTable 基於 TanStack / DatePicker 基於 react-day-picker / Toast 基於 sonner)。
|
|
11
|
+
* Consumer 不 install、不 import、看不到 RHF API。RHF 提供 values state / dirty 深比對 /
|
|
12
|
+
* errors store / resetField;驗證「時機」由本 hook own(RHF 的 mode/reValidateMode 不外露)。
|
|
13
|
+
*
|
|
14
|
+
* ── 與 Field 家族的關係(engine-agnostic 分層,field.spec.md「定位」段)──
|
|
15
|
+
* Field 保持純 layout + context(MUI FormControl 派,可用於 cell / display / 無引擎場景);
|
|
16
|
+
* 本 hook 住 form 層,錯誤經 consumer 一行 `<Field invalid={!!form.errors.x}>` 接入——
|
|
17
|
+
* Field 層零耦合。這是「A 派的自由 + B 派的 DX」混合位置。
|
|
18
|
+
*
|
|
19
|
+
* ── 方法論對應(form-validation.spec.md 規則 1-9)──
|
|
20
|
+
* 1 Focus 中不顯示錯誤 → 驗證只在 blur / submit 跑(無 onChange 驗證路徑)
|
|
21
|
+
* 2 Blur 時驗證 → getInputProps().onBlur 跑 validate[name]
|
|
22
|
+
* 3 Enter 等同 blur → form 內 Enter 觸發 submit(全驗,超集);單行控件原生行為
|
|
23
|
+
* 4 Escape 取消回復原值 → getInputProps().onKeyDown Escape → resetField + 清 error
|
|
24
|
+
* 5 開始編輯立即清除 error → onChange 先清 errors[name](不論新值合法與否)
|
|
25
|
+
* 6 Blur 重新驗證 → 同 2(離開時重判)
|
|
26
|
+
* 7 Submit 驗證全部 → handleSubmit 對所有 validate keys 全跑(不依賴 blur 狀態)
|
|
27
|
+
* 8 Anchor 到第一個錯誤 → focus + scrollIntoView({block:'center'});每次 submit 重算
|
|
28
|
+
* 9 Async / 跨欄位 defer 到 submit → onSubmit 回傳 field-keyed errors → 同 8 anchor
|
|
29
|
+
* + Submit button:Create 永遠 enabled / Update disabled-until-dirty → `submitDisabled`
|
|
30
|
+
*
|
|
31
|
+
* ── v1 邊界(spec「可執行層」段 documented)──
|
|
32
|
+
* - getInputProps 支援 value/onChange 型控件(Input / Textarea / NumberInput / Select /
|
|
33
|
+
* Combobox / DatePicker / TimePicker;onChange 收 event 或裸值皆可)。Checkbox / Switch
|
|
34
|
+
* (onCheckedChange)consumer 自接 setFieldValue。
|
|
35
|
+
* - focus-first-error 以 DOM `name` 屬性定位(native input 生效;非 native 控件 fallback
|
|
36
|
+
* scroll 略過,errors 視覺仍由 Field 紅框 + FieldError 呈現)。
|
|
37
|
+
*/
|
|
38
|
+
import * as React from 'react'
|
|
39
|
+
import { useForm } from 'react-hook-form'
|
|
40
|
+
import type { FieldValues, Path, PathValue, DefaultValues } from 'react-hook-form'
|
|
41
|
+
|
|
42
|
+
export interface UseFormValidationOptions<T extends FieldValues> {
|
|
43
|
+
/** 表單初始值(Update 場景 = 現有資料;dirty 比對基準) */
|
|
44
|
+
initialValues: T
|
|
45
|
+
/**
|
|
46
|
+
* 表單意圖,驅動 submit button 狀態(form-validation.spec.md「Submit Button 狀態」):
|
|
47
|
+
* - 'create'(default):submitDisabled 永遠 false(不讓使用者猜「為什麼按不了」)
|
|
48
|
+
* - 'update':submitDisabled = !isDirty(沒改就不用存;變更還原回 pristine 即再 disabled)
|
|
49
|
+
*/
|
|
50
|
+
intent?: 'create' | 'update'
|
|
51
|
+
/**
|
|
52
|
+
* 格式驗證(blur 層,規則 2):single-field 純 syntax(email 格式 / 必填 / URL)。
|
|
53
|
+
* 回傳 error 訊息字串 = 不合法;undefined = 合法。
|
|
54
|
+
* 業務 / async / 跨欄位驗證**不要**放這裡——放 onSubmit 回傳(規則 9)。
|
|
55
|
+
*/
|
|
56
|
+
validate?: Partial<Record<keyof T, (value: T[keyof T], values: T) => string | undefined>>
|
|
57
|
+
/**
|
|
58
|
+
* Submit handler(格式驗證全過後呼叫)。業務驗證(名稱重複 API / 跨欄位)在此判斷,
|
|
59
|
+
* 回傳 field-keyed error object(如 `{ name: '名稱已存在' }`)→ hook 自動 setError +
|
|
60
|
+
* anchor 到第一個錯誤(規則 9);回傳 undefined = 成功。
|
|
61
|
+
*/
|
|
62
|
+
onSubmit: (values: T) => void | Partial<Record<keyof T, string>> | Promise<void | Partial<Record<keyof T, string>>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface FormFieldInputProps<V = unknown> {
|
|
66
|
+
name: string
|
|
67
|
+
value: V
|
|
68
|
+
onChange: (eventOrValue: unknown) => void
|
|
69
|
+
onBlur: () => void
|
|
70
|
+
onKeyDown: (e: React.KeyboardEvent) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface UseFormValidationReturn<T extends FieldValues> {
|
|
74
|
+
/** 當前表單值(即時) */
|
|
75
|
+
values: T
|
|
76
|
+
/** field-keyed 錯誤訊息(餵 `<Field invalid>` + `<FieldError>`) */
|
|
77
|
+
errors: Partial<Record<keyof T, string>>
|
|
78
|
+
/** 任一欄位偏離 initialValues(深比對,還原回原值 = false) */
|
|
79
|
+
isDirty: boolean
|
|
80
|
+
/** Submit button disabled 狀態(intent 驅動,見 options.intent) */
|
|
81
|
+
submitDisabled: boolean
|
|
82
|
+
/** Spread 到 value/onChange 型控件:`<Input {...form.getInputProps('name')} />` */
|
|
83
|
+
getInputProps: <K extends keyof T & string>(name: K) => FormFieldInputProps<T[K]>
|
|
84
|
+
/** 接 `<form onSubmit={form.handleSubmit}>`(規則 7/8/9) */
|
|
85
|
+
handleSubmit: (e?: React.FormEvent) => Promise<void>
|
|
86
|
+
/** 整表重置回 initialValues(清 errors + dirty) */
|
|
87
|
+
reset: () => void
|
|
88
|
+
/** Escape hatch:非 value/onChange 控件(Checkbox/Switch)手動寫值 */
|
|
89
|
+
setFieldValue: (name: keyof T & string, value: unknown) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** onChange 收 event 或裸值皆可(對齊 Mantine getInputProps idiom):
|
|
93
|
+
* native input event → e.target.value;自訂控件裸值(string / number / Date / array)→ 原樣。 */
|
|
94
|
+
function extractValue(eventOrValue: unknown): unknown {
|
|
95
|
+
if (
|
|
96
|
+
eventOrValue &&
|
|
97
|
+
typeof eventOrValue === 'object' &&
|
|
98
|
+
'target' in eventOrValue &&
|
|
99
|
+
eventOrValue.target &&
|
|
100
|
+
typeof eventOrValue.target === 'object' &&
|
|
101
|
+
'value' in (eventOrValue.target as object)
|
|
102
|
+
) {
|
|
103
|
+
return (eventOrValue.target as HTMLInputElement).value
|
|
104
|
+
}
|
|
105
|
+
return eventOrValue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 規則 8:focus + scroll 到第一個錯誤欄位。以 DOM name 屬性定位(native input);
|
|
109
|
+
* 找不到(非 native 控件)→ 靜默略過,error 視覺仍由 Field 紅框呈現。 */
|
|
110
|
+
function focusFirstError(errorNames: string[]) {
|
|
111
|
+
for (const name of errorNames) {
|
|
112
|
+
const el = document.getElementsByName(name)[0] as HTMLElement | undefined
|
|
113
|
+
if (el) {
|
|
114
|
+
el.focus()
|
|
115
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function useFormValidation<T extends FieldValues>(
|
|
122
|
+
options: UseFormValidationOptions<T>,
|
|
123
|
+
): UseFormValidationReturn<T> {
|
|
124
|
+
const { initialValues, intent = 'create', validate, onSubmit } = options
|
|
125
|
+
|
|
126
|
+
// RHF 引擎(wrapped):驗證時機由本 hook own,故 RHF 自身 mode 鎖 onSubmit 且不掛 resolver
|
|
127
|
+
// (所有 setError/clearErrors 走手動,RHF 只當 state + dirty + errors store)。
|
|
128
|
+
const form = useForm<T>({
|
|
129
|
+
defaultValues: initialValues as DefaultValues<T>,
|
|
130
|
+
mode: 'onSubmit',
|
|
131
|
+
shouldFocusError: false, // 規則 8 自己 focus(RHF 依賴 register ref,本 hook 不走 register)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// 訂閱全表(表單尺度 re-render 可接受;formState.isDirty 深比對 vs defaultValues)
|
|
135
|
+
const values = form.watch()
|
|
136
|
+
const { errors: rhfErrors, isDirty } = form.formState
|
|
137
|
+
|
|
138
|
+
const errors = React.useMemo(() => {
|
|
139
|
+
const out: Partial<Record<keyof T, string>> = {}
|
|
140
|
+
for (const key of Object.keys(rhfErrors)) {
|
|
141
|
+
const msg = (rhfErrors as Record<string, { message?: string } | undefined>)[key]?.message
|
|
142
|
+
if (msg) out[key as keyof T] = msg
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}, [rhfErrors])
|
|
146
|
+
|
|
147
|
+
/** 規則 2/6:blur 驗證單一欄位 */
|
|
148
|
+
const validateField = React.useCallback(
|
|
149
|
+
(name: keyof T & string) => {
|
|
150
|
+
const fn = validate?.[name]
|
|
151
|
+
if (!fn) return
|
|
152
|
+
const current = form.getValues()
|
|
153
|
+
const message = fn(current[name], current)
|
|
154
|
+
if (message) form.setError(name as Path<T>, { type: 'format', message })
|
|
155
|
+
else form.clearErrors(name as Path<T>)
|
|
156
|
+
},
|
|
157
|
+
[form, validate],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const getInputProps = React.useCallback(
|
|
161
|
+
<K extends keyof T & string>(name: K): FormFieldInputProps<T[K]> => {
|
|
162
|
+
// 泛型 K 窄化到 Path<T> 需經 unknown(RHF Path 是 template-literal type,K 不直接 overlap)
|
|
163
|
+
const path = name as unknown as Path<T>
|
|
164
|
+
return {
|
|
165
|
+
name,
|
|
166
|
+
value: form.watch(path) as T[K],
|
|
167
|
+
onChange: (eventOrValue: unknown) => {
|
|
168
|
+
// 規則 5:開始編輯立即清除 error(不論新值合法與否,給修正空間)
|
|
169
|
+
if (form.getFieldState(path).error) form.clearErrors(path)
|
|
170
|
+
form.setValue(path, extractValue(eventOrValue) as PathValue<T, Path<T>>, {
|
|
171
|
+
shouldDirty: true,
|
|
172
|
+
})
|
|
173
|
+
},
|
|
174
|
+
// 規則 2:blur 驗證(focus 中永不驗 = 規則 1 自然成立)
|
|
175
|
+
onBlur: () => validateField(name),
|
|
176
|
+
// 規則 4:Escape 回復原值,不觸發驗證
|
|
177
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
178
|
+
if (e.key === 'Escape') {
|
|
179
|
+
form.resetField(path)
|
|
180
|
+
form.clearErrors(path)
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[form, validateField],
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
/** 規則 7/8/9:submit 全驗 + anchor 第一個錯誤 + 業務錯誤同軌 */
|
|
189
|
+
const handleSubmit = React.useCallback(
|
|
190
|
+
async (e?: React.FormEvent) => {
|
|
191
|
+
e?.preventDefault()
|
|
192
|
+
const current = form.getValues()
|
|
193
|
+
// 規則 7:對所有 validate keys 全跑(不依賴個別 blur 狀態);每次 submit 重算(規則 8)
|
|
194
|
+
const formatErrors: string[] = []
|
|
195
|
+
if (validate) {
|
|
196
|
+
for (const name of Object.keys(validate)) {
|
|
197
|
+
const fn = validate[name as keyof T]
|
|
198
|
+
if (!fn) continue
|
|
199
|
+
const message = fn(current[name as keyof T], current)
|
|
200
|
+
if (message) {
|
|
201
|
+
form.setError(name as Path<T>, { type: 'format', message })
|
|
202
|
+
formatErrors.push(name)
|
|
203
|
+
} else {
|
|
204
|
+
form.clearErrors(name as Path<T>)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (formatErrors.length > 0) {
|
|
209
|
+
focusFirstError(formatErrors)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
// 規則 9:業務 / async / 跨欄位驗證 defer 到 submit(onSubmit 回傳 field-keyed errors)
|
|
213
|
+
const businessErrors = await onSubmit(current)
|
|
214
|
+
if (businessErrors && typeof businessErrors === 'object') {
|
|
215
|
+
const names = Object.keys(businessErrors).filter(
|
|
216
|
+
(k) => businessErrors[k as keyof T] != null,
|
|
217
|
+
)
|
|
218
|
+
for (const name of names) {
|
|
219
|
+
form.setError(name as Path<T>, {
|
|
220
|
+
type: 'business',
|
|
221
|
+
message: businessErrors[name as keyof T] as string,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
if (names.length > 0) focusFirstError(names)
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
[form, validate, onSubmit],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
values,
|
|
232
|
+
errors,
|
|
233
|
+
isDirty,
|
|
234
|
+
// Submit Button 狀態 canonical:Create 永遠 enabled / Update disabled-until-dirty
|
|
235
|
+
submitDisabled: intent === 'update' ? !isDirty : false,
|
|
236
|
+
getInputProps,
|
|
237
|
+
handleSubmit,
|
|
238
|
+
reset: () => form.reset(),
|
|
239
|
+
setFieldValue: (name, value) =>
|
|
240
|
+
form.setValue(name as Path<T>, value as PathValue<T, Path<T>>, { shouldDirty: true }),
|
|
241
|
+
}
|
|
242
|
+
}
|