iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
自我挑戰組

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

Day 13: Textarea:不是把 Input 拉高就結束

  • 分享至 

  • xImage
  •  

前兩天已經完成:

Day 11 → Input
Day 12 → Field / Label / Description / Error

今天繼續擴充 CUI 的 Form Family,加入另一個很常見的輸入元件:

<textarea>

第一眼看起來,Textarea 好像只是「比較高的 Input」。

但實際使用時,它通常負責:

意見
備註
說明
留言
申請原因
...

這類較長的內容。

因此除了基本樣式之外,還會開始遇到:

  • 多行輸入
  • Resize
  • 最低高度
  • 字數限制
  • 字數提示
  • Error Message

今天就來完成第一版 Textarea。


先加入 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 和 Textarea 應該長得像一家人

昨天的 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。


Textarea 需要自己的高度

Input 通常有固定高度:

┌──────────────────────────────┐
│ 單行內容                     │
└──────────────────────────────┘

Textarea 則需要提供足夠的多行輸入空間:

┌──────────────────────────────┐
│ 第一行                       │
│ 第二行                       │
│                              │
│                              │
└──────────────────────────────┘

所以第一版可以設定:

"min-h-24"

而不是固定:

"h-24"

差別在於:

height
→ 固定高度

min-height
→ 至少這麼高,但仍保留擴充空間

對 Textarea 來說,min-height 通常比較有彈性。


Resize 要不要保留?

瀏覽器預設通常允許使用者拖曳 Textarea 改變大小。

很容易為了版面整齊直接:

resize: none;

但第一版 CUI 我不打算直接禁止 Resize。

因為對需要輸入大量內容的使用者來說,能把輸入區域拉大其實很實用。

所以先保留瀏覽器原生行為。

如果某個產品真的有特殊 Layout 限制,再透過:

className="resize-none"

自行調整。

而不是由 Design System 一開始就拿掉這個能力。


加入 CUI Component Contract

和 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 的基礎。


第一版 Textarea

整理後可以先保持很單純:

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

昨天建立 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。


Textarea 很常需要字數限制

長文字輸入常常會遇到:

最多 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

例如:

<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 建立關聯。


Character Count 要不要一直被朗讀?

這裡有一個值得注意的 Accessibility 問題。

如果我們把每次:

1 / 500
2 / 500
3 / 500
4 / 500

都設定成:

aria-live="polite"

螢幕閱讀器可能在使用者每輸入一個字時就一直宣布字數。

那會非常吵 😂

所以第一版 不會把普通 Character Counter 設成 Live Region。

畫面上提供 Counter 即可。

如果未來真的需要在「快達到限制」時主動提醒,可以另外設計更合理的 Announcement Strategy。

ARIA 並不是加得越多越無障礙。


Error 和 Counter 可以同時存在

假設規則不只是最多 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 框線變紅。


Required 一樣使用原生語意

Textarea 也可能是必填:

<Textarea required />

搭配:

<FieldLabel htmlFor="message">
  意見內容
  <span aria-hidden="true"> *</span>
</FieldLabel>

和 Input 完全使用同一套規則。

這也是 Design System 開始成形後很重要的一件事:

不同元件遇到相同的狀態,應該遵循相同的設計原則。

而不是 Input 一套、Textarea 又重新發明一套。


做一個 Textarea Preview

最後可以準備幾個代表案例:

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>

Day 13 Done

今天完成:

✓ 建立 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——看起來都是「選或不選」,但它們其實代表完全不同的操作語意。


上一篇
Day 12: Field:Label、Hint、Error 怎麼連在一起?
下一篇
Day 14: Checkbox、Radio、Switch:都是選擇,但意思不一樣
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言