iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

Button 終於在 Day 10 畢業了~ 🎓
接下來開始進入表單元件。

第一個要處理的是幾乎所有系統都會出現的:

<input />

乍看之下 Input 好像比 Button 簡單很多:

<Input />

一條框線、一個輸入區域,好像就結束了。
但實際開始整理 Design System 後,很快就會發現 Input 也有不少狀態需要統一:

Default
Hover
Focus
Disabled
Invalid
Placeholder

今天先把 Input 本體整理好。

Label、Hint、Error Message,以及它們之間的 ARIA 關係,留到下一篇 Field 再處理。


先加入 Input

目前 CUI 仍然以 shadcn/ui 作為元件的起點,因此先加入 Input:

npx shadcn@latest add input

產生:

src/
└─ components/
   └─ ui/
      └─ input.tsx

先看看 shadcn/ui 幫我們準備了什麼。

Input 本身其實沒有太複雜的 API:

function Input({
  className,
  type,
  ...props
}: React.ComponentProps<"input">) {
  return (
    <input
      type={type}
      className={cn(
        "...",
        className
      )}
      {...props}
    />
  )
}

和 Button 相比,它沒有 CVA,也沒有:

variant
size
shape

第一版不打算急著替 Input 加上一大堆 Variant


先建立 CUI 的 Component Contract

Button 已經有:

data-cui-slot="button"

Input 也延續同一套規則:

<input
  data-cui-slot="input"
  ...
/>

未來 DOM 就可以辨識:

<input
  data-cui-slot="input"
  type="text"
/>

目前不需要為了「看起來很完整」加入:

data-cui-variant="default"
data-cui-size="md"

因為 Input 現在根本還沒有這些 API。


Input 的 Default State

第一版先建立最基本的外觀:

<Input placeholder="請輸入姓名" />

Input 至少需要處理:

高度
Padding
Border
Background
文字
Placeholder
圓角
Transition

例如:

className={cn(
  "h-9 w-full rounded-lg border border-input bg-background px-3",
  "text-sm text-foreground",
  "placeholder:text-muted-foreground",
  "transition-[color,box-shadow]",
  className
)}

這裡盡量使用前面已經建立好的 Semantic Tokens:

background
foreground
input
muted-foreground

而不是:

border-gray-300
text-gray-900
placeholder:text-gray-400

這也是 Design Token 開始真正發揮作用的地方。


Focus:不要直接把 Outline 拿掉

表單元件最重要的狀態之一就是 Focus。

使用鍵盤 Tab 移動時,使用者必須能清楚知道目前焦點在哪裡。

因此可以延續 Button 的 Focus Style:

"outline-none",
"focus-visible:border-ring",
"focus-visible:ring-3",
"focus-visible:ring-ring/50",

組合起來:

<Input placeholder="按 Tab 移動到這裡" />

Focus 時會透過 Border + Ring 顯示目前位置。

這裡的重點不是:

outline: none;

而是:

如果移除瀏覽器預設 Outline,就必須提供另一個清楚可辨識的 Focus Indicator。

否則鍵盤使用者會完全不知道自己目前在哪個欄位。


Disabled:不能只把顏色變淡

Input 也可以:

<Input
  disabled
  value="無法編輯"
/>

Disabled State 可以加入:

"disabled:pointer-events-none",
"disabled:cursor-not-allowed",
"disabled:opacity-50",

瀏覽器本身也會讓:

<input disabled />

無法被正常編輯。

因此我們不需要另外發明:

isDisabled

直接沿用原生 HTML API:

<Input disabled />

就足夠了。

能使用原生語意時,優先使用原生語意。


Invalid:錯誤狀態不能只換紅色框線

Input 還有另一個很重要的狀態:

Invalid

例如:

<Input
  aria-invalid="true"
  value="abc"
/>

CUI 可以直接根據 ARIA Attribute 改變樣式:

"aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20",

因此:

<Input aria-invalid="true" />

同時做到兩件事:

ARIA
→ 表達目前欄位 Invalid

CSS
→ 顯示 Error Style

而不是另外建立:

<Input error />

然後又忘記補:

aria-invalid="true"

這也是我目前很喜歡的一個方向:

如果狀態本身已經有適合的 HTML / ARIA 語意,就盡量讓 Styling 跟著語意走。


但紅色框線還是不夠

雖然:

aria-invalid:border-destructive

可以讓欄位變成紅色,但這並不代表錯誤處理已經完成。

例如:

Email
┌──────────────────────────────┐
│ abc                          │  ← 紅框
└──────────────────────────────┘

使用者還是不知道:

到底錯在哪裡?

真正完整的欄位應該像:

Email
┌──────────────────────────────┐
│ abc                          │
└──────────────────────────────┘
請輸入有效的 Email 格式

而且 Error Message 還要和 Input 建立程式上的關聯。

這就會牽涉:

label
id
aria-describedby
aria-invalid
error message

這些我們留到 Day 12 的 Field 一起處理。


Placeholder 不是 Label

Input 很常看到:

<Input placeholder="請輸入姓名" />

然後畫面上就沒有其他文字。

但:

┌────────────────────────────┐
│ 請輸入姓名                 │
└────────────────────────────┘

Placeholder 不應該拿來取代 Label。

因為使用者開始輸入:

┌────────────────────────────┐
│ 王小明                     │
└────────────────────────────┘

原本提示「這個欄位是什麼」的文字就消失了。

所以 CUI 會把兩件事分開:

Label
→ 這個欄位是什麼?

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

例如:

Email

┌──────────────────────────────┐
│ example@example.com          │
└──────────────────────────────┘

Label 是 Email。

Placeholder 才是:

example@example.com

Input 要不要也做 Size?

做到這裡很容易想:

Button 都有:

size="sm"
size="md"
size="lg"

那 Input 是不是也應該:

<Input size="sm" />
<Input size="md" />
<Input size="lg" />

目前先不做。

因為「Button 有 Size」不代表所有 Component 都必須有完全相同的 API。

第一版先只有一個標準 Input 高度。

等之後真的出現:

Compact Table Filter
一般 Form
大型 Search Input

等不同使用情境,再決定 Input 是否需要 Size Variant。

Design System 的 API 不需要為了對稱而存在。


第一版 Input

整理後,Input 可以先保持非常小:

import * as React from "react"

import { cn } from "cn"

function Input({
  className,
  type,
  ...props
}: React.ComponentProps<"input">) {
  return (
    <input
      data-cui-slot="input"
      type={type}
      className={cn(
        "h-9 w-full rounded-lg border border-input bg-background px-3",
        "text-sm text-foreground",
        "placeholder:text-muted-foreground",
        "transition-[color,box-shadow]",
        "outline-none",

        "focus-visible:border-ring",
        "focus-visible:ring-3",
        "focus-visible:ring-ring/50",

        "aria-invalid:border-destructive",
        "aria-invalid:ring-3",
        "aria-invalid:ring-destructive/20",

        "disabled:pointer-events-none",
        "disabled:cursor-not-allowed",
        "disabled:opacity-50",

        className
      )}
      {...props}
    />
  )
}

export { Input }

目前沒有 CVA、沒有 Variant,也沒有額外的自訂 Props。

先讓 Input 保持簡單。


做一個 Input State Preview

和 Button 一樣,先把幾個主要狀態放在一起:

<div className="grid max-w-md gap-6">
  <Input placeholder="Default" />

  <Input
    value="已有內容"
    readOnly
  />

  <Input
    placeholder="Invalid"
    aria-invalid="true"
  />

  <Input
    placeholder="Disabled"
    disabled
  />
</div>

然後實際用:

Mouse
Keyboard Tab
輸入文字
Disabled
Invalid

逐一確認狀態。

尤其不要只用滑鼠測 Focus。

按幾次 Tab,看看自己能不能立刻知道:

現在焦點到底在哪裡?


Day 11 Done

今天完成第一版 Input:

✓ 建立 Input Component
✓ 加入 data-cui-slot="input"
✓ 使用 Semantic Design Tokens
✓ Default State
✓ Placeholder State
✓ Focus State
✓ Disabled State
✓ Invalid State
✓ 使用 aria-invalid 驅動錯誤樣式
✓ Placeholder 不取代 Label
✓ 暫時不增加不必要的 Variant / Size API

Button 做到 Day 10 後,再回頭看 Input,開始可以看到 CUI 的一些共同原則慢慢形成:

能使用原生 HTML → 不另外發明 API

已經有 ARIA 語意 → 讓 Style 跟著語意走

沒有實際需求 → 不急著增加 Variant

Component → 只負責自己該負責的事情

今天的 Input 還只是一個孤零零的輸入框

但實際表單不可能只有:

<input />

它還需要:

這是什麼欄位?
要輸入什麼?
是不是必填?
格式錯了嗎?
錯在哪裡?

下一篇 Day 12,就把 Label、Description、Error Message 與 Input 組成真正完整的 Field!!


上一篇
Day10: Button v1 完成:Shape、Loading 與 Component API
下一篇
Day 12: Field:Label、Hint、Error 怎麼連在一起?
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言