iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 12 篇

Day 12: Field:Label、Hint、Error 怎麼連在一起?

  • 分享至 

  • xImage
  •  

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 其實不只是一個 Input

先把結構拆開:

Field
│
├─ Label
├─ Input
├─ Description / Hint
└─ Error Message

例如:

<div>
  <label>Email</label>

  <Input />

  <p>我們會使用這個 Email 寄送通知。</p>
</div>

視覺上看起來沒什麼問題。

但這些元素目前只是「剛好放在一起」。

程式上還沒有建立完整關係。


Label 要真的指向 Input

最基本的關係是:

<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 />

它們視覺上雖然靠在一起,語意上卻沒有建立關聯。


Placeholder 仍然不是 Label

昨天提過:

<Input placeholder="請輸入 Email" />

不能取代:

<label>Email</label>

因為 Placeholder 會在輸入文字後消失。

因此比較完整的結構應該是:

<label htmlFor="email">
  Email
</label>

<Input
  id="email"
  placeholder="example@example.com"
/>

兩者的責任不同:

Label
→ 這個欄位是什麼?

Placeholder
→ 可以輸入什麼?格式可能長什麼樣?

Hint 怎麼和 Input 建立關係?

接著加入提示文字:

<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 下面」。


Error Message 也需要被連起來

假設使用者輸入:

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。

這樣視覺狀態與語意狀態就使用同一個來源。


Hint 和 Error 同時存在呢?

這也是實際表單很常遇到的情況:

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 之間二選一。


Required 又怎麼處理?

必填欄位很常畫成:

Email *

但只放一個紅色 * 並不足以表達欄位是必填。

如果這是原生 Input,可以直接使用:

<Input required />

例如:

<label htmlFor="email">
  Email
  <span aria-hidden="true">*</span>
</label>

<Input
  id="email"
  required
/>

這裡星號只是視覺提示,因此:

aria-hidden="true"

避免它被當成欄位名稱的一部分反覆朗讀。

如果畫面需要更清楚,也可以直接顯示:

Email(必填)

重點仍然是:

不要只靠顏色或符號傳達 Required State。


開始建立 Field Component

做到這裡會發現,每一個表單欄位都一直重複:

<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

現在就可以:

<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 才真正描述同一件事情。


Field 要不要自動產生 ID?

做到這裡可能會開始覺得:

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 不是拿來取代 HTML

今天用了:

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 在需要補充語意時再加入。


做一個 Field Preview

最後可以在 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。


Day 12 Done

今天完成:

✓ 建立 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 等更完整的輸入情境!


上一篇
Day 11: Input:輸入框不只有一條框線
下一篇
Day 13: Textarea:不是把 Input 拉高就結束
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言