Button 終於在 Day 10 畢業了~ 🎓
接下來開始進入表單元件。
第一個要處理的是幾乎所有系統都會出現的:
<input />
乍看之下 Input 好像比 Button 簡單很多:
<Input />
一條框線、一個輸入區域,好像就結束了。
但實際開始整理 Design System 後,很快就會發現 Input 也有不少狀態需要統一:
Default
Hover
Focus
Disabled
Invalid
Placeholder
今天先把 Input 本體整理好。
Label、Hint、Error Message,以及它們之間的 ARIA 關係,留到下一篇 Field 再處理。
目前 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
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 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。
使用鍵盤 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。
否則鍵盤使用者會完全不知道自己目前在哪個欄位。
Input 也可以:
<Input
disabled
value="無法編輯"
/>
Disabled State 可以加入:
"disabled:pointer-events-none",
"disabled:cursor-not-allowed",
"disabled:opacity-50",
瀏覽器本身也會讓:
<input disabled />
無法被正常編輯。
因此我們不需要另外發明:
isDisabled
直接沿用原生 HTML API:
<Input disabled />
就足夠了。
能使用原生語意時,優先使用原生語意。
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 一起處理。
Input 很常看到:
<Input placeholder="請輸入姓名" />
然後畫面上就沒有其他文字。
但:
┌────────────────────────────┐
│ 請輸入姓名 │
└────────────────────────────┘
Placeholder 不應該拿來取代 Label。
因為使用者開始輸入:
┌────────────────────────────┐
│ 王小明 │
└────────────────────────────┘
原本提示「這個欄位是什麼」的文字就消失了。
所以 CUI 會把兩件事分開:
Label
→ 這個欄位是什麼?
Placeholder
→ 可以輸入什麼?格式可能長什麼樣子?
例如:
Email
┌──────────────────────────────┐
│ example@example.com │
└──────────────────────────────┘
Label 是 Email。
Placeholder 才是:
example@example.com
做到這裡很容易想:
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 可以先保持非常小:
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 保持簡單。
和 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,看看自己能不能立刻知道:
現在焦點到底在哪裡?
今天完成第一版 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!!