iT邦幫忙

2021 iThome 鐵人賽

DAY 6
1
Modern Web

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

【Day06】數據輸入元件 - FormControl

元件介紹

FormControl 讓我們可以將 form input 所需要的共同前後文特性獨立出來管理,使被 control 的子元件之間的樣式能夠保持一致性。例如在 form input 元件 TextField, Switch, Checkbox 當中,將 label, required, error ...等邏輯與樣式獨立出來藉由 FormControl 來管理。

參考設計 & 屬性分析

這邊指的 FormControl 靈感是取自 MUI 的元件,因為看到這個元件的概念很不錯,所以想要借來改成自己適用的元件。

表單輸入的時候有許多的狀況需要處理,例如:

  • 欄位是否為必填
  • 欄位標題的名稱、位置、間距
  • 限制輸入格式(ex: 只能輸入數字、email 格式、電話格式...)
  • 輸入錯誤(ex: 不符合格式、必填沒填)時的樣式以及警告訊息

以標題名稱為例,標題名稱相對於輸入元件的位置、距離,其實在各個元件都有雷同的地方,例如 Switch, Checkbox, Radio

這些部分真的看起來很像,所以如果在每個輸入元件裡面都把同樣的邏輯一模一樣再刻一次的話,想必這是違背了 Don't repeat yourself 原則。



另外有一些表單的處理,其實我覺得他沒有必要一定要跟輸入元件綁死在一起,以 TextField 來說,一個 TextField 最主要的功能就是讓人可以輸入 Text,如果他沒有 label,其實他還是一個 TextField;如果他沒有 error message,他仍然是一個 TextField。這些屬性就算沒有,也不會影響原本元件的功能,像這些部分我們就能夠另外把它獨立出來,讓 TextField 就是一個純粹的 TextField。

因此上述這些附加價值,我們就用 FormControl 來另外處理,一方面可以共用各種 form input 的共同樣式和屬性,另一方面也可以讓 form input 的功能更保持單純。

介面設計

屬性 說明 類型 默認值
label 標題內容 string
placement 標題位置 top-left, top, top-right, left, right, bottom-left, bottom, bottom-right top-left
children 要管理的 form 內容 TextField, Switch, Radio, Checkbox
isRequired 是否必填(樣式) boolean false
isError 是否錯誤(樣式) boolean false
errorMessage 顯示錯誤訊息 string
maxLength 限制最大輸入長度 number
onChange 狀態改變的 callback function function

元件實作

我們想像 FormControl 大概是像下面這樣的結構:

<FormControlWrapper>
  <Label />
  {children}
  {(isError && errorMessage) && <ErrorMessage value={errorMessage} />}
</FormControlWrapper>

placement

首先我們來實作 placement,我們來偷看一下 MUI 是怎麼做的:

我們可以發現,無論 label 的位置是放在 form input 的哪個方位,他的 html 結構基本上都是一樣的,示意結構如下:

<label>
  <span><Radio /></span>
  <span>{label}</span>
</label>

那既然都是同樣的結構,要如何做到不同位置的擺放呢?關鍵就在於他的 css 樣式,以 label 在上,Radio 在下的這個案例來說,他的 css 有一個值得注意的地方,就是使用 flex-direction: column-reverse;,看來這一切的謎團到這邊就差不多解開了。

flex 佈局的 flex-direction 屬性能幫我們指定 flex 容器當中元素的主軸方向,其中有四個我們可以選用的值

flex-direction: row | row-reverse | column | column-reverse;

row 以及 row-reverse 都是橫軸方向,佈局的起點與終點為互相相反;另外 column 與 column-reverse 是縱軸方向,一樣是起點與終點互為相反。

在 placement 傳入元件之後,可以根據傳進來的參數來決定要選用哪個 flex-direction 值,藉此在不改變 html 架構下能夠做到 label 與 input control 不同方位的佈局。

我們的招數一樣用同一招,用物件的 key-value 結構來對應我們想要選用的 css 樣式

const placementStyleMap = {
  'top-left': topLeftStyle,
  top: topStyle,
  'top-right': topRightStyle,
  left: leftStyle,
  right: rightStyle,
  'bottom-left': bottomLeftStyle,
  bottom: bottomStyle,
  'bottom-right': bottomRightStyle,
};

在各種不同方位的樣式當中,flex-direction: column | column-reverse; 來決定 label 在 input control 的上方還是下方,而至於是左上、右上、左下、右下,則我們搭配另外一個 flex 佈局的屬性 align-items: flex-end | center | flex-start; 來決定,我們就能夠做出不同方位的佈局:

Required

isRequired 這個 boolean 讓我們決定要不要在 label 上面顯示必填的樣式:

雖然這也是一個小功能,但是因為真的非常常被使用,因此我們也不想要每次用的時候都寫一次。

實作如下,當 isRequired 為 true 的時候,就顯示必填的星號 *,就是這麼單純。

const RequiredSign = styled.span`
  color: ${(props) => props.theme.color.error};
`;

<div className="form-control__label">
  {label}
  {isRequired && <RequiredSign>*</RequiredSign>}
</div>

Error Message

按下表單送出按鈕時,在表單送出前,同常會讓前端先做一次檢查,看看是否有不符合格式的欄位,是否有必填的欄位沒有填到等等,若有錯誤的欄位,我們會顯示如下的樣式:

這個樣式會需要 input border 變紅,並且顯示 Error Message。
Error Message 的處理,我們就是簡單的 if...else... 判斷,若 isError 為 true 並且也有傳入 Error Message,我們就顯示。

而 input border 變紅色,其實我們在 TextField 裡就有提供這樣的 props 可傳入,但是其實我們不太想要父層傳一次 isError 進去,然後子層又傳一次 isError,感覺有點重複,如下:

<FormControl isError={state.isError}>
  <TextField isError={state.isError} />
</FormControl>

因此這邊我們希望借助 React.cloneElement 這個方法,把父層傳入的 isError 直接往下傳,這樣就不用自己在外面手動把 isError 又傳入子層,因此 FormControl 的做法會如下示意:

<FormControlWrapper>
  <Label />
  {React.cloneElement(children, {
    isError,
  })}
  {(isError && errorMessage) && <ErrorMessage value={errorMessage} />}
</FormControlWrapper>

輸入字數長度限制

有時候我們 TextField 會需要限制輸入字數的長度,類似像這樣的概念我們在 LINE 的輸入暱稱當中也可以看得到:

其實我們並不是每一個 TextField 都會需要限制輸入字數的長度,所以把這部分的功能拉出來用 FormControl 做我個人覺得也是很不錯的,可以讓 TextField 的功能保持單純,下圖是我們希望做到的樣式:

我大概會想要這樣操作元件,在 FormControl 傳入一個最大長度限制,以及一個 onChange function:

<FormControl
  maxLength={12}
  onChange={...}
>
  <TextField />
</FormControl>

那要如何透過在 FormControl 這個父層傳入 onChange ,就能夠知道子層 TextField 的輸入字數呢?還是那個千篇一律的招數 React.cloneElement

React.cloneElement(children, {
  ...otherProps,
  onChange: handleOnChange,
})

在 FormControl 元件內部,我們用 handleOnChange 這個 handle function 來監聽 TextField 輸入的變化,藉此來取得當前的輸入值,這樣我們就能夠在 FormControl 這個元件內部的 state 來記錄輸入字數的長度啦!如下面程式碼示意:

const [childrenValue, setChildrenValue] = useState('');

const handleOnChange = (event) => {
  const targetValue = event?.target?.value;
  if (maxLength && targetValue.length > maxLength) return;

  setChildrenValue(targetValue);
  if (typeof onChange === 'function') {
    onChange(event);
  }
};

我們得到當前 TextField 輸入值 childrenValue 之後,在 UI 上面就能夠刻畫出我們預期的樣式:

<LabelWrapper className="form-control__label-wrapper">
  <div className="form-control__label">
    {label}
    {isRequired && <RequiredSign>*</RequiredSign>}
  </div>
  {maxLength && <MaxLength>{`${childrenValue?.length} / ${maxLength}`}</MaxLength>}
</LabelWrapper>

到目前為止我們就能夠完成一個簡易的 FormControl 了!
詳細程式碼以及 Demo 附在下方連結


FormControl 元件原始碼:
Source code

Storybook:
FormControl


上一篇
【Day05】數據輸入元件 - Input Text / Text Field
下一篇
【Day07】數據輸入元件 - Slider
系列文
30 天擁有一套自己手刻的 React UI 元件庫30

尚未有邦友留言

立即登入留言