前兩天已經完成:
Day 11 → Input
Day 12 → Field / Label / Description / Error
今天繼續擴充 CUI 的 Form Family,加入另一個很常見的輸入元件:
<textarea>
第一眼看起來,Textarea 好像只是「比較高的 Input」。
但實際使用時,它通常負責:
意見
備註
說明
留言
申請原因
...
這類較長的內容。
因此除了基本樣式之外,還會開始遇到:
今天就來完成第一版 Textarea。
目前 CUI 仍然以 shadcn/ui 作為元件起點:
npx shadcn@latest add textarea
產生:
src/
└─ components/
└─ ui/
└─ textarea.tsx
它的結構其實和 Input 很像:
function Textarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
className={cn(
"...",
className
)}
{...props}
/>
)
}
這也代表我們可以延續 Input 已經建立好的設計規則。
昨天的 Input 已經定義:
Border
Background
Radius
Typography
Placeholder
Focus
Invalid
Disabled
Textarea 不需要重新發明另一套視覺語言。
因此可以沿用相同的 Semantic Tokens:
"border-input"
"bg-background"
"text-foreground"
"placeholder:text-muted-foreground"
Focus:
"focus-visible:border-ring"
"focus-visible:ring-3"
"focus-visible:ring-ring/50"
Invalid:
"aria-invalid:border-destructive"
"aria-invalid:ring-destructive/20"
Disabled:
"disabled:cursor-not-allowed"
"disabled:opacity-50"
兩個元件放在一起時,應該很明顯屬於同一套 UI Kit。
Input 通常有固定高度:
┌──────────────────────────────┐
│ 單行內容 │
└──────────────────────────────┘
Textarea 則需要提供足夠的多行輸入空間:
┌──────────────────────────────┐
│ 第一行 │
│ 第二行 │
│ │
│ │
└──────────────────────────────┘
所以第一版可以設定:
"min-h-24"
而不是固定:
"h-24"
差別在於:
height
→ 固定高度
min-height
→ 至少這麼高,但仍保留擴充空間
對 Textarea 來說,min-height 通常比較有彈性。
瀏覽器預設通常允許使用者拖曳 Textarea 改變大小。
很容易為了版面整齊直接:
resize: none;
但第一版 CUI 我不打算直接禁止 Resize。
因為對需要輸入大量內容的使用者來說,能把輸入區域拉大其實很實用。
所以先保留瀏覽器原生行為。
如果某個產品真的有特殊 Layout 限制,再透過:
className="resize-none"
自行調整。
而不是由 Design System 一開始就拿掉這個能力。
和 Button、Input 一樣:
<textarea
data-cui-slot="textarea"
/>
因此目前已經慢慢形成:
<button data-cui-slot="button">
<input data-cui-slot="input">
<textarea data-cui-slot="textarea">
這些 Contract 未來也會成為 CDN / Legacy 版本辨識 Component 的基礎。
整理後可以先保持很單純:
import * as React from "react"
import { cn } from "cn"
function Textarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
data-cui-slot="textarea"
className={cn(
"min-h-24 w-full rounded-lg border border-input bg-background px-3 py-2",
"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:cursor-not-allowed",
"disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Textarea }
目前一樣沒有:
variant
size
shape
因為還沒有足夠的使用需求。
昨天建立 Field 後,Textarea 可以直接使用相同結構:
<Field>
<FieldLabel htmlFor="message">
意見內容
</FieldLabel>
<Textarea
id="message"
placeholder="請輸入您的意見"
aria-describedby="message-description"
/>
<FieldDescription id="message-description">
請簡單描述您的問題或建議。
</FieldDescription>
</Field>
這也是把 Field 和 Input 分開的好處。
Field 不需要知道裡面一定是:
<Input />
它也可以搭配:
<Textarea />
之後甚至還能搭配:
Select
Checkbox
Radio Group
Field 負責的是「欄位結構」,而不是某一種 Input。
長文字輸入常常會遇到:
最多 500 字
HTML 本身已經提供:
maxLength={500}
因此可以直接:
<Textarea
maxLength={500}
/>
而不是自己在 JavaScript 裡阻止第 501 個字輸入。
還是同一個原則:
原生 HTML 已經能處理的事情,優先使用原生能力。
只有:
maxLength={500}
雖然限制有效,但使用者不知道自己還剩多少空間。
所以 UI 常會顯示:
┌──────────────────────────────┐
│ 今天想反映一個問題…… │
│ │
└──────────────────────────────┘
12 / 500
這時需要知道目前輸入內容長度。
最簡單可以先:
const [message, setMessage] = useState("")
接著:
<Textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={500}
/>
<span>
{message.length} / 500
</span>
這個 Counter 是 使用 Textarea 的情境邏輯。
因此第一版我不會直接把它塞進:
<Textarea />
變成:
<Textarea
showCount
maxLength={500}
/>
因為不是每一個 Textarea 都需要顯示字數。
例如:
<Field>
<FieldLabel htmlFor="message">
意見內容
</FieldLabel>
<Textarea
id="message"
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={500}
aria-describedby="message-description message-count"
/>
<div className="flex justify-between gap-4">
<FieldDescription id="message-description">
請簡單描述您的問題或建議。
</FieldDescription>
<span
id="message-count"
className="text-sm text-muted-foreground"
>
{message.length} / 500
</span>
</div>
</Field>
現在:
Description
→ 說明要輸入什麼
Character Count
→ 說明目前輸入量
兩者都可以透過 aria-describedby 和 Textarea 建立關聯。
這裡有一個值得注意的 Accessibility 問題。
如果我們把每次:
1 / 500
2 / 500
3 / 500
4 / 500
都設定成:
aria-live="polite"
螢幕閱讀器可能在使用者每輸入一個字時就一直宣布字數。
那會非常吵 😂
所以第一版 不會把普通 Character Counter 設成 Live Region。
畫面上提供 Counter 即可。
如果未來真的需要在「快達到限制」時主動提醒,可以另外設計更合理的 Announcement Strategy。
ARIA 並不是加得越多越無障礙。
假設規則不只是最多 500 字,還要求至少輸入 10 字:
意見內容 *
┌──────────────────────────────┐
│ 太慢 │
│ │
└──────────────────────────────┘
請至少輸入 10 個字。 2 / 500
Textarea:
<Textarea
id="message"
aria-invalid="true"
aria-describedby="message-error message-count"
/>
Error:
<FieldError id="message-error">
請至少輸入 10 個字。
</FieldError>
Counter:
<span id="message-count">
2 / 500
</span>
這也延續 Day 12 的概念:
aria-invalid
→ 表達目前欄位有錯誤
aria-describedby
→ 指向進一步的說明
而不是只把 Textarea 框線變紅。
Textarea 也可能是必填:
<Textarea required />
搭配:
<FieldLabel htmlFor="message">
意見內容
<span aria-hidden="true"> *</span>
</FieldLabel>
和 Input 完全使用同一套規則。
這也是 Design System 開始成形後很重要的一件事:
不同元件遇到相同的狀態,應該遵循相同的設計原則。
而不是 Input 一套、Textarea 又重新發明一套。
最後可以準備幾個代表案例:
Default
With Description
Required
With Character Count
Invalid
Disabled
例如:
<div className="grid max-w-lg gap-8">
<Field>
<FieldLabel htmlFor="bio">
個人簡介
</FieldLabel>
<Textarea
id="bio"
placeholder="簡單介紹一下自己"
/>
</Field>
<Field>
<FieldLabel htmlFor="message">
意見內容
<span aria-hidden="true"> *</span>
</FieldLabel>
<Textarea
id="message"
required
maxLength={500}
placeholder="請輸入您的意見"
aria-describedby="message-description message-count"
/>
<div className="flex justify-between gap-4">
<FieldDescription id="message-description">
請簡單描述您的問題或建議。
</FieldDescription>
<span
id="message-count"
className="text-sm text-muted-foreground"
>
0 / 500
</span>
</div>
</Field>
<Field>
<FieldLabel htmlFor="invalid-message">
申請原因
</FieldLabel>
<Textarea
id="invalid-message"
aria-invalid="true"
aria-describedby="invalid-message-error"
value="太短"
readOnly
/>
<FieldError id="invalid-message-error">
請至少輸入 10 個字。
</FieldError>
</Field>
</div>
今天完成:
✓ 建立 Textarea
✓ 延續 Input 的 Design Token
✓ Default / Focus / Invalid / Disabled
✓ 使用 min-height 保留多行輸入彈性
✓ 預設保留 Resize
✓ 加入 data-cui-slot="textarea"
✓ Textarea 可以直接搭配 Field
✓ 使用原生 maxLength
✓ 建立 Character Count 使用方式
✓ Counter 可以透過 aria-describedby 關聯
✓ 不讓 Counter 每輸入一個字就透過 aria-live 朗讀
✓ Error / Description / Counter 可以共存
做到今天,Form Family 已經開始長出來了:
Form
│
├─ Field
│ ├─ FieldLabel
│ ├─ FieldDescription
│ └─ FieldError
│
├─ Input
│
└─ Textarea
而且 Input 和 Textarea 並不是兩個完全獨立的元件。
它們共享:
Design Tokens
Focus Style
Invalid State
Disabled State
Field Structure
Accessibility Rules
這才是做 UI Kit 真正想得到的效果:
不是每做一顆元件就重新設計一次,而是讓前面建立的規則可以一直被後面的元件重用。
下一篇 Day 14,會開始處理另一類完全不同的表單元件:
Checkbox、Radio、Switch——看起來都是「選或不選」,但它們其實代表完全不同的操作語意。