Day 11 完成了第一版 Input:
<Input placeholder="請輸入 Email" />
Input 本身已經有 Default、Focus、Disabled、Invalid 等狀態。
但實際表單不可能只放一個 Input。
通常會長得像:
Email *
┌──────────────────────────────┐
│ example@example.com │
└──────────────────────────────┘
我們會使用這個 Email 寄送通知。
如果輸入錯誤:
Email *
┌──────────────────────────────┐
│ abc │
└──────────────────────────────┘
請輸入有效的 Email 格式
這時候問題就來了:
Label、Hint、Error 看起來都在 Input 附近,但瀏覽器與輔助科技真的知道它們彼此有關係嗎?
今天就來把這些東西真正連起來。
先把結構拆開:
Field
│
├─ Label
├─ Input
├─ Description / Hint
└─ Error Message
例如:
<div>
<label>Email</label>
<Input />
<p>我們會使用這個 Email 寄送通知。</p>
</div>
視覺上看起來沒什麼問題。
但這些元素目前只是「剛好放在一起」。
程式上還沒有建立完整關係。
最基本的關係是:
<label for="email">Email</label>
<input id="email" />
在 React 中:
<label htmlFor="email">
Email
</label>
<Input id="email" />
這樣 Label 才真正屬於這個 Input。
除了讓輔助科技知道欄位名稱之外,還有一個很實際的好處:
點擊 Label,也可以把 Focus 移到 Input。
所以不要只寫:
<label>Email</label>
<Input />
它們視覺上雖然靠在一起,語意上卻沒有建立關聯。
昨天提過:
<Input placeholder="請輸入 Email" />
不能取代:
<label>Email</label>
因為 Placeholder 會在輸入文字後消失。
因此比較完整的結構應該是:
<label htmlFor="email">
Email
</label>
<Input
id="email"
placeholder="example@example.com"
/>
兩者的責任不同:
Label
→ 這個欄位是什麼?
Placeholder
→ 可以輸入什麼?格式可能長什麼樣?
接著加入提示文字:
<label htmlFor="email">
Email
</label>
<Input id="email" />
<p>
我們會使用這個 Email 寄送通知。
</p>
視覺使用者可以看出這段文字位於 Input 下方。
但如果希望輔助科技也知道:
這段說明是在描述 Email Input。
就可以使用:
aria-describedby
先替 Hint 建立 ID:
<p id="email-description">
我們會使用這個 Email 寄送通知。
</p>
再讓 Input 指向它:
<Input
id="email"
aria-describedby="email-description"
/>
關係就變成:
Input
│
└── aria-describedby
│
▼
email-description
這樣 Description 就不只是「剛好排在 Input 下面」。
假設使用者輸入:
abc
我們顯示:
<p>
請輸入有效的 Email 格式
</p>
Day 11 已經讓 Input 支援:
<Input aria-invalid="true" />
因此 Invalid 時可以:
<Input
id="email"
aria-invalid="true"
aria-describedby="email-error"
/>
<p id="email-error">
請輸入有效的 Email 格式
</p>
現在 Input 同時具有:
aria-invalid="true"
→ 這個欄位目前無效
aria-describedby="email-error"
→ 這裡有進一步的錯誤說明
而 CUI Input 又會根據:
aria-invalid="true"
自動套用 Error Style。
這樣視覺狀態與語意狀態就使用同一個來源。
這也是實際表單很常遇到的情況:
Email
[ abc ]
我們會使用這個 Email 寄送通知。
請輸入有效的 Email 格式
aria-describedby 可以參考不只一個 ID:
<Input
id="email"
aria-invalid="true"
aria-describedby="email-description email-error"
/>
也就是:
aria-describedby
│
├── email-description
│
└── email-error
因此不需要在 Hint 和 Error 之間二選一。
必填欄位很常畫成:
Email *
但只放一個紅色 * 並不足以表達欄位是必填。
如果這是原生 Input,可以直接使用:
<Input required />
例如:
<label htmlFor="email">
Email
<span aria-hidden="true">*</span>
</label>
<Input
id="email"
required
/>
這裡星號只是視覺提示,因此:
aria-hidden="true"
避免它被當成欄位名稱的一部分反覆朗讀。
如果畫面需要更清楚,也可以直接顯示:
Email(必填)
重點仍然是:
不要只靠顏色或符號傳達 Required State。
做到這裡會發現,每一個表單欄位都一直重複:
<div>
<label />
<Input />
<p />
</div>
而且還要自己管理:
id
htmlFor
aria-describedby
aria-invalid
這就是值得開始抽成 Field 的地方。
第一版可以先建立幾個小元件:
Field
├─ FieldLabel
├─ FieldDescription
└─ FieldError
例如:
function Field({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-cui-slot="field"
className={cn(
"grid gap-2",
className
)}
{...props}
/>
)
}
Label:
function FieldLabel({
className,
...props
}: React.ComponentProps<"label">) {
return (
<label
data-cui-slot="field-label"
className={cn(
"text-sm font-medium",
className
)}
{...props}
/>
)
}
Description:
function FieldDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-cui-slot="field-description"
className={cn(
"text-sm text-muted-foreground",
className
)}
{...props}
/>
)
}
Error:
function FieldError({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-cui-slot="field-error"
className={cn(
"text-sm text-destructive",
className
)}
{...props}
/>
)
}
這些元件目前仍然非常單純。
Field 不負責資料驗證,也不需要知道 React Hook Form、Zod 或 API 怎麼運作。
它目前只處理:
表單欄位的結構、樣式與語意。
現在就可以:
<Field>
<FieldLabel htmlFor="email">
Email
</FieldLabel>
<Input
id="email"
type="email"
placeholder="example@example.com"
aria-describedby="email-description"
/>
<FieldDescription id="email-description">
我們會使用這個 Email 寄送通知。
</FieldDescription>
</Field>
Invalid:
<Field>
<FieldLabel htmlFor="email">
Email
</FieldLabel>
<Input
id="email"
type="email"
value="abc"
aria-invalid="true"
aria-describedby="email-description email-error"
readOnly
/>
<FieldDescription id="email-description">
我們會使用這個 Email 寄送通知。
</FieldDescription>
<FieldError id="email-error">
請輸入有效的 Email 格式
</FieldError>
</Field>
這時畫面與 Accessibility Tree 才真正描述同一件事情。
做到這裡可能會開始覺得:
id="email"
htmlFor="email"
aria-describedby="email-description email-error"
好多 ID,好麻煩。
很容易想直接做成:
<Field
label="Email"
description="..."
error="..."
>
<Input />
</Field>
然後讓 Field 全部自動處理。
這種 API 未來不是不能做,但第一版我暫時不急。
因為如果太早把所有東西藏起來,反而不容易看清楚:
Label 怎麼連 Input?
Description 怎麼被描述?
Error 怎麼被關聯?
目前先把 HTML / ARIA 關係建立正確。
等這套模式真的開始大量重複,再決定要不要利用:
useId()
或 Context 把這些關係自動化。
今天用了:
aria-describedby
aria-invalid
aria-hidden
但有一件事情很重要:
能用原生 HTML 解決的事情,還是優先使用 HTML。
例如:
<label htmlFor="email">
不要因為有 ARIA,就改成:
<div aria-label="Email">
必填也優先:
<Input required />
而不是所有事情都自己發明:
aria-required="true"
可以先記一個很簡單的原則:
Native HTML first,ARIA 在需要補充語意時再加入。
最後可以在 App.tsx 放幾個代表案例:
Normal
Required
With Description
Invalid
Disabled
例如:
<div className="grid max-w-md gap-8">
<Field>
<FieldLabel htmlFor="name">
姓名
</FieldLabel>
<Input
id="name"
placeholder="請輸入姓名"
/>
</Field>
<Field>
<FieldLabel htmlFor="email">
Email
<span aria-hidden="true"> *</span>
</FieldLabel>
<Input
id="email"
type="email"
required
placeholder="example@example.com"
aria-describedby="email-description"
/>
<FieldDescription id="email-description">
我們會使用這個 Email 寄送通知。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="invalid-email">
Email
</FieldLabel>
<Input
id="invalid-email"
value="abc"
readOnly
aria-invalid="true"
aria-describedby="invalid-email-error"
/>
<FieldError id="invalid-email-error">
請輸入有效的 Email 格式。
</FieldError>
</Field>
</div>
接著除了看畫面,也可以實際:
Tab
Shift + Tab
點擊 Label
輸入內容
確認 Label 點下去真的會 Focus 到對應的 Input。
今天完成:
✓ 建立 Field
✓ 建立 FieldLabel
✓ 建立 FieldDescription
✓ 建立 FieldError
✓ label + htmlFor + id
✓ aria-describedby
✓ aria-invalid
✓ required
✓ aria-hidden
✓ Hint 與 Error 可以同時描述 Input
✓ Placeholder 不取代 Label
✓ Required 不只靠紅色星號
✓ Field 暫時不負責表單驗證
✓ 暫時不過度自動化 ID 關係
昨天做 Input 時,我們處理的是:
「這個輸入框現在是什麼狀態?」
今天處理的則是:
「這個輸入框到底是什麼?使用者需要知道哪些資訊?」
這也是做 Accessible UI 時很容易忽略的一件事:
畫面上看起來有關聯,不代表程式上真的有關聯。
把 Label、Hint、Error 放在 Input 附近只是視覺設計;透過正確的 HTML 與 ARIA 把它們真正連起來,才是一個完整的表單欄位。
下一篇 Day 13
繼續擴充 Form Family:Textarea,以及字數提示、Required 等更完整的輸入情境!