【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


<<:  Day 04. Zabbix 可监控的服务、设备、应用

>>:  问这个问题会不会被当笨蛋?到底什麽才叫对的问题?

防止常见的Web攻击开发方法

概述 讨论一些应用程序常见漏洞类别: 建议 Clickjacking 发生在攻击者使用 iframe...

[PM日常001] 爱上Event

因为完美不可能 因为知道要办到的事有多难,所以绝对不会认为次次达标是件好事 完美是在范围(Scope...

[Day 45] 留言板後台及前台(一) - 新增资料库栏位

留言板後台及前台 新增资料库栏位 接下来终於进入到留言板的部分, 之前的心情随笔是记录自己的情况, ...

响应式设计

元件自动侦测改变外观 现在网页应用程序越来越朝向「mobile first」设计,代表网站都要能支援...

Build your own environment in Visual Studio Code for Python

Introduction As a python coder, you want to find a...