表單、焦點與輸入法
為什麼輸入框會失焦、中文輸入法為什麼被打斷、autocomplete 為什麼重要,以及驗證訊息該怎麼寫。
1. 輸入框每打一個字就失焦
最常見的三個原因:
一、元件在 render 裡被重新定義
function Page() {
// 每次 render 都是一個「新的」元件型別 → React 卸載舊的、掛載新的
const Field = () => <input value={value} onChange={...} />;
return <Field />;
}
元件定義搬到外面。
二、key 綁到會變的值
{items.map((item, i) => <input key={`${item.name}-${i}`} />)}
item.name 隨著輸入改變 → key 改變 → DOM 節點被換掉 → 焦點沒了。key 要用穩定的 id。
三、整份清單被重建
state 更新後整個陣列換新物件,框架沒辦法對應舊節點。用穩定 id,並且只更新變動的那一筆。
2. 中文輸入法(IME)
注音、拼音打到一半時,input 事件會不斷觸發,值是還沒選字的組字狀態。此時做搜尋、驗證、格式化,會把使用者的輸入打斷。
let composing = false;
input.addEventListener('compositionstart', () => (composing = true));
input.addEventListener('compositionend', (event) => {
composing = false;
handleInput(event); // 組字結束才處理
});
input.addEventListener('input', (event) => {
if (composing) return;
handleInput(event);
});
需要這個處理的情境:即時搜尋、字數統計、自動格式化(電話、金額)、自動跳格。
這是全世界中日韓使用者都會遇到、而英文開發者不會想到的問題。 用 AI 生成的表單幾乎不會處理它。
3. autocomplete
沒有正確的 autocomplete,密碼管理員不會運作,使用者會開始用弱密碼、重複密碼。
| 欄位 | 值 |
|---|---|
| 帳號 / email 登入 | username |
| 登入密碼 | current-password |
| 註冊 / 更改密碼 | new-password |
| 簡訊驗證碼 | one-time-code |
| 姓名 | name(或 given-name / family-name) |
| 電話 | tel |
| 地址 | street-address postal-code country |
| 信用卡 | cc-number cc-exp cc-csc |
關掉自動填入用 autocomplete="off",但不要用在密碼欄位 —— 瀏覽器多半會忽略它,而你只是讓密碼管理員混亂。
4. 標籤
<!-- 對 -->
<label for="email">電子郵件</label>
<input id="email" type="email" />
<!-- 也對 -->
<label>電子郵件 <input type="email" /></label>
<!-- 錯:placeholder 不是標籤 -->
<input type="email" placeholder="電子郵件" />
用 placeholder 當標籤的三個問題:打字後標籤消失、對比通常不足、螢幕閱讀器不一定會讀。
視覺上不想顯示標籤時,用 .ds-sr 而不是刪掉它。
5. 說明與錯誤要「連結」到欄位
<label class="ds-label" for="pw">密碼</label>
<input class="ds-input" id="pw" type="password"
aria-describedby="pw-hint" aria-invalid="false" />
<p class="ds-hint" id="pw-hint">至少 10 個字元,包含一個數字。</p>
aria-describedby 讓螢幕閱讀器在唸欄位時一起唸出說明。只是把文字放在旁邊,看得到的人才知道。
出錯時把 aria-describedby 指向錯誤訊息,並設 aria-invalid="true"。
6. 驗證訊息怎麼寫
規則:說明哪裡錯 + 怎麼改。
✗ 無效的電子郵件
✗ 格式錯誤
✓ 這個電子郵件看起來不完整,要有 @ 和網域,例如 yijun@example.com
其他原則:
- 要求寫在前面,不要靠失敗來揭露。 密碼規則在欄位下方先講,不要等按下送出才說。
- 不要在打字時就報錯。 使用者才輸入
y就說「不是有效的 email」很煩。在 blur 時驗證,送出時再驗一次。 - 改好了要立刻消失。 修正後仍然報錯,使用者會以為自己還是錯的。
- 長表單要有頂部總結,列出所有錯誤並可點擊跳到該欄位。
7. 焦點管理
| 情境 | 該做的事 |
|---|---|
| 開啟彈窗 | 焦點移到彈窗內第一個可聚焦元素,並鎖在裡面 |
| 關閉彈窗 | 焦點回到觸發它的按鈕 |
| 刪除清單項目 | 焦點移到下一項(或上一項),不要掉回 body |
| 換頁 | 焦點移到 <main>(tabindex="-1")或 h1 |
| 表單送出失敗 | 焦點移到第一個錯誤欄位 |
原生 <dialog> + showModal() 免費提供焦點鎖定、Escape 關閉、背後 inert。自己刻的 modal 這四樣通常都缺。
8. 驗證碼輸入框
六格 OTP 是焦點管理的考題,四個必做:
inputmode="numeric"開數字鍵盤,但不要用type="number"(會有上下箭頭、貼上行為怪異)autocomplete="one-time-code"讓 iOS / Android 提供簡訊驗證碼- 貼上一整串六位數字時,要自動分配到六格。只處理逐字輸入的版本,使用者一貼上就只會填進一格
- 空格按 Backspace 要跳回前一格
function onInput(index: number, event: Event) {
const value = (event.target as HTMLInputElement).value.replace(/\D/g, '');
if (value.length > 1) {
// 貼上或自動填入:把它攤開
const chars = value.slice(0, LENGTH - index).split('');
digits = digits.map((d, i) =>
i >= index && i < index + chars.length ? chars[i - index] : d
);
focusBox(index + chars.length);
return;
}
digits[index] = value;
if (value) focusBox(index + 1);
}
參考模組:驗證碼輸入。
9. 行動裝置
- iOS 會在輸入框字體小於 16px 時自動放大整個頁面。輸入框字體 ≥ 16px,或設
maximum-scale(但那會傷害無障礙,寧可調字體) inputmode決定鍵盤類型:numericdecimaltelemailurlsearchenterkeyhint決定 Enter 鍵的樣子:searchsendnextdone- 表單裡的按鈕記得寫
type="button",否則預設是submit,按下去會送出整個表單
10. 檢查清單
- 每個輸入框都有真正的
<label> - 說明與錯誤用
aria-describedby連結 - autocomplete 都填了
- 有處理 IME 組字事件
- 錯誤訊息說得出「怎麼改」
- 不在打字時報錯,修好會立刻消失
- 彈窗開關的焦點有進有回
- 輸入框字體 ≥ 16px
- 非送出按鈕都有
type="button"