iT邦幫忙

2021 iThome 鐵人賽

DAY 5
0
Modern Web

30 天擁有一套自己手刻的 React UI 元件庫系列 第 5

【Day05】數據輸入元件 - Input Text / Text Field

元件介紹

Input 是一個輸入元件。通常在我們希望用戶能夠輸入一些資訊的時候會需要用到它。由於原生 html 的 input 透過 type 這個屬性的改變,還可以是 text, button, checkbox, radio, file, image, password...等等,為了聚焦,我們本篇先討論純文字的輸入框。

Input 元件是一個我覺得還不認識他的時候會覺得是小菜一碟,但是開始慢慢仔細思考的時候,發現案情並不單純的元件,怎麼說呢?我們隨便打開一下 MUI、Antd、Bootstrap 對照來看,會發現,我們之前講的那幾個元件 Switch, Checkbox, Radio. Button 在不同 library 中樣式看起來大同小異,連 props 介面也大同小異。但是比對不同 library 的 Input 元件的時候,會發現其實差異還蠻大的,不管是樣式上的設計和程式介面的設計都有其各別獨自的特色。

看到這樣的差異一開始會還蠻驚訝的,但想一想也覺得可以理解的,畢竟讓使用者輸入的表單光是隨便舉例就能夠有幾十種甚至上百種,例如:輸入帳號、輸入密碼、輸入信用卡資訊、輸入網址、輸入地址、輸入日期、輸入金額、檔案上傳......等等。

並且 MUI 也很有意思,他特別為了文字輸入框另外做了一套元件叫做 TextField ,命名上也更聚焦在文字輸入,並且也在上面添加各種樣式的變化以及功能。我覺得這個命名還蠻好的,因為我們本篇也只先討論純文字輸入,所以我也先暫且將這個元件稱作 TextField 應該會跟我們要做的功能比較一致。

不同情境的網站可能對於同一種使用者資料的輸入需求也都不一樣,例如:

  • 各個國家的地址、姓名、電話格式不同,需要輸入的設計介面也會不同
  • 搜尋輸入框,有些搜尋框需要有載入狀態、有些搜尋框甚至有下拉選單

所以,到底要設計出什麼樣的 Text Field 還是需要因地制宜。不過在這些五花八門的 Text Field 功能當中,也是有幾個共通之處的介面及樣式,我們可以找一些有共識的介面及屬性來實踐,至於其他各自特色的功能,我們再依照自己的需要添加即可。

參考設計 & 屬性分析

基本外觀

外觀上面常見的一些變化如下:

  • border 的顏色,平常狀態顏色hover 時的顏色error 時的顏色,這個也是我們在 MUI 及 Antd 都可以看見的設計。
  • onFocus 時的樣式,MUI 是 border 加深變粗變顏色, Antd 及 Bootstrap 則是添加了 outline 的樣式。
  • disabled 時的樣式,MUI 是改變了 placeholder 的顏色,Antd 及 Bootstrap 則是改變背景顏色。

這些基本外觀通常也是設計師在決定這個網站的主題的時候就會需要決定的,比較少會遇到還需要再多設計一些 props 來特別改變這些性質的顏色或外觀(例如: borderColor, placeholderColor......之類的)。頂多就是讓我們傳入 className 來做一些微調,或是像 MUI 這樣可以傳入 primary, secondary 來決定他的主題。

輸入內容,Controlled 與 Uncontrolled

在 HTML 中,表單的 element 像是 inputtextareaselect 通常會維持它們自身的 state,並根據使用者的輸入來更新 state。然而,在 React 中,可變的 state 通常是被維持在 component 中的 state,並只能以 setState() 來更新。

為了維持「唯一真相來源」,防止資料不一致的錯誤,我們只會選擇一種方式來維持我們的 state,因此這樣的表單處理分成 Controlled 和 Uncontrolled 這兩種,唯一真相來源若是使用 React 中的 state 來維持的話,叫做 Controlled component,反之,若 state 不由 React 控制,而是由 HTML element 本身自行來控制,則稱為 Uncontrolled component。

在輸入框裡,要呈現的內容的屬性有 defaultValue 以及 value,因此,如果我們同時在一個 input element 給定這兩個 props ,則會跳出如下的警告:

Warning: [YourComponent] contains an input of type text with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props.

在 React 中,defaultValue 用於 Uncontrolled component,而 value 用於Controlled component。 它們不應該在表單元素中一起使用,這點會需要特別留意。

在 Uncontrolled component 當中,由於資料不會交給 React 來管理,因此我們需要透過 useRef 這個 Hook 來取得 input element 當前的 value。

而 Controlled component 的資料是由 React 的 state 來管理,並且當作 props 傳入 input element 的 value 當中,當我們要改變資料的時候,會透過 onChange 事件來取得當前的 value,並且透過 setState 更新到 React 元件中的 state。

另一方面,當我們的 input element 只給他 value 屬性,卻不給他 onChange 屬性的時候,會跳出如下警告,跳出警告還不打緊,此時你也會發現你在輸入框中無法輸入任何內容,這是由於 props value 已經覆寫了 input element 本身的資料狀態,因此我們無法改變 input element 本身的資料,也同時無法觸發 onChange 事件來透過 setState 來改變 React state,導致卡在那邊動彈不得。

簡而言之,Controlled component 只有兩種可能,一個是 value + onChange 同時出現;另一種,就是 value + readOnly ,這樣就允許不用給他 onChange 事件屬性。

Warning: Failed prop type: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

那我們應該如何決定使用 controlled component 和 uncontrolled component 的時機呢?透過官網上的建議,其實我們大部分的情境都可以用 Controlled component 來處理,如此我們能夠透過一個簡單的 JavaScript function 來處理資料驗證、表單提交或是改變 UI。

使用 Uncontrooled component 的時機可能有下面幾個,一個是當 <input type="file"> 的時候,因為該元素有安全性的疑慮,JavaScript 只能取值而不能改值,也就是透過 JavaScript 可以知道使用者選擇要上傳的檔案為何(取值),但不能去改變使用者要上傳的檔案(改值)。因此對於檔案上傳用的 <input type="file" /> 只能透過 Uncontrolled Components 的方式處理。

另一種情境是,有時候我們只是想要簡單的去取得某個 input element 的值,或是想要直接操作 DOM,或許就適合 Uncontrolled component 的操作方式,但需要特別注意的是,因為 Uncontrolled component 是直接操作 element,因此當資料有變動時,並不會觸發 React 的生命週期來進行重新渲染,因此,若有重新渲染畫面的需求,建議還是使用 Controlled Component 來處理。

裝飾屬性

我們常可以看到 Text Field 的前後會出現一些 Icon 來幫助使用者識別這個輸入框要填入的內容,例如有一個錢號在前面,我們就知道他要填金額,而不同國家的錢號可以幫助我們快速識別這個輸入框需要填入哪種幣別的金額。

那如果輸入框裡面出現了放大鏡的 Icon ,我們就可以知道是一個搜尋框,用來輸入我們要搜尋的關鍵字。那如果後面出現了單位,例如長度的單位「Km」,重量的單位「Kg」,也可以幫助我們快速理解這個輸入框需要我們輸入的內容。

我們可以來比較看看 MUI 及 Antd 是怎麼處理這些裝飾屬性。在 Antd 中,放在前面的前綴圖示叫做 prefix,放在後面的叫做 suffix,傳入的型別是 ReactNode;而在 MUI 中,這先前綴、後綴的裝飾 Icon 叫做 adorement,其中前面的叫做 startAdornment,後面的叫做 endAdornment,在 TextField 和 Input 兩個不同元件有不同的處理方式,在 TextField 中,是讓 startAdornment, endAdornment 以物件格式傳入一個叫做 InputProps 的 props 中,如下:

import TextField from '@material-ui/core/TextField';
import InputAdornment from '@material-ui/core/InputAdornment';

<TextField
  ...otherprops,
  InputProps={{
    startAdornment: <InputAdornment position="start">Kg</InputAdornment>,
  }}
/>

而 Input 元件則直接把 startAdorment, endAdorment 分為兩個 props ,範例如下:

import Input from '@material-ui/core/Input';
import InputAdornment from '@material-ui/core/InputAdornment';

<Input
  ...otherprops,
  startAdornment={<InputAdornment position="start">$</InputAdornment>}
/>

這邊看起來是為了讓 adornment 可以被更細膩和獨立的控制,因此把一些相關的屬性再特別拉出來獨立成 InputAdornment 元件,這個設計方式跟 FormControl 被獨立拉出來感覺也是有異曲同工之妙。

雖然 MUI 這樣的設計也有它巧妙之處,但我個人的感覺是覺得我還是比較偏好 Antd 這樣的 prefix, suffix 設計,因為他特別拉出 InputAdornment 並沒有讓我特別感興趣的功能,所以不如直接傳入一個 prefix, suffix 還比較直覺一點;但這可能是我當下的經驗與使用情境不需要那麼複雜,或許哪天我的情境改變了,我就會覺得獨立出 InputAdornment 會是還不錯的選擇。

介面設計

屬性 說明 類型 默認值
value 輸入框內容 string
defaultValue 預設輸入框內容 string
prefix 前綴元件 ReactNode
suffix 後綴元件 ReactNode
placeholder 佔位文字 string
isDisabled 禁用狀態 boolean false
isError 輸入錯誤狀態 boolean false
onChange 狀態改變的 callback function function(event: object) => void

元件實作

我們這次實作的 TextField 主要是希望幫 <input type="text" /> 元件做一些加值功能,幫助我們減少處理一些重複性的樣式,因此結構上不想規劃得太複雜:

const TextField = ({
  className,
  prefix, suffix,
  isError,
  isDisabled,
  ...props
}) => (
  <StyledTextField
    className={className}
    $isError={isError}
    $isDisabled={isDisabled}
  >
    {prefix}
    <Input type="text" {...props} className="text-field__input" disabled={isDisabled} />
    {suffix}
  </StyledTextField>
);

這一個 TextField 主要著重在樣式上的處理,例如傳入 prefix, suffix 的 Icon 進來之後做一些樣式上的對齊、間距離等等。

然後透過傳入 isError, isDisabled 來做對應的顏色、樣式變化。值得ㄧ提的是,我會把 isError 和 isDisabled 的樣式特別獨立出來寫,而不是只是寫在上述結構的 <StyledTextField /> 當中:

import styled, { css } from 'styled-components';

const errorStyle = css`
  // 輸入錯誤狀態時的樣式...
`;

const disabledStyle = css`
  // 禁用狀態時的樣式...
`;

const StyledTextField = styled.div`
  // default TextField style...

  ${(props) => (props.$isError ? errorStyle : null)}
  ${(props) => (props.$isDisabled ? disabledStyle : null)}
`;

這樣做的好處是,我們不會讓 errorStyle 和 disabledStyle 去跟 default TextField style 糾纏在一起,特別是如果又有一些 if...else... 的判斷來決定樣式的時候,邏輯上會糾纏得更嚴重。

再來有一個地方的處理比較特別,下面程式碼我把一些參數拿掉讓視覺上更聚焦:

const TextField = ({
  className,
  /*...省略...*/
  ...props
}) => (
  <StyledTextField
    className={className}
    /*...省略...*/
  >
    {prefix}
    <Input type="text" {...props} ... />
    {suffix}
  </StyledTextField>
);

就是我把從外面傳進來的 className 這個 props 留在 <StyledTextField /> 這一層,而其他的 props 我放在 <Input /> 這個元件上。

為什麼想要這麼做呢?我們看看剛剛實現出來的 TextField,雖然邏輯上我們知道這裡的 <input /> 是被包在一個 <div /> 下面,但從 UI 上來看其實他就是一個整體。

試想,當我們傳入一個想要客製化樣式的 className 進去 TextField 的時候,我們通常是想要客製化哪些樣式呢?不外乎就是這個 TextField 的 width, border, background ....等等外觀樣式為主,因此將 className 放在 <StyledTextField /> 上面是我覺得在修改這個元件的時候最直覺的。

但除了 className 以外,其他的 props 可能會是什麼呢?我認為應該最有機會是 value, onChange, placeholder...等等跟 input 有關的參數,因此這些 props 需要被放在 <input /> 上面,而不是他的父層 <StyledTextField />

透過這樣的調整,可以讓我感覺像是在操作一般的 <input /> 一樣,而不會讓我感受到他被一層 <div /> 包起來,導致使用起來卡卡的。


TextField 元件原始碼:
Source code

Storybook:
TextField


參考資料

React Controlled Component
https://zh-hant.reactjs.org/docs/forms.html#controlled-components

Controlled vs Uncontrolled
https://ithelp.ithome.com.tw/articles/10227866

React/ReactJS: Difference between defaultValue and value
https://scriptverse.academy/tutorials/reactjs-defaultvalue-value.html


上一篇
【Day04】數據輸入元件 - Checkbox
下一篇
【Day06】數據輸入元件 - FormControl
系列文
30 天擁有一套自己手刻的 React UI 元件庫30

尚未有邦友留言

立即登入留言