React-材质-UI-秘籍-四-

45 阅读32分钟

React 材质 UI 秘籍(四)

原文:zh.annas-archive.org/md5/c4e5ed8c3a8a54c4065e4c907829dab6

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:按钮 - 启动操作

在本章中,您将学习以下主题:

  • 按钮变体

  • 按钮强调

  • 链接按钮

  • 浮动操作

  • 图标按钮

  • 按钮大小

简介

在 Material-UI 应用程序中,按钮用于启动操作。用户点击按钮,就会发生某些事情。按钮被激活时会发生什么完全取决于您。Material-UI 按钮的复杂度从简单的文本按钮到浮动操作按钮不等。

按钮变体

Material-UI 的 Button 组件存在三种变体之一。这些如下所示:

  • 文本

  • Outlined

  • Filled

如何操作...

下面是一些渲染三个 Button 组件的代码,每个组件都明确设置了它们的 variant 属性:

import React from 'react';

import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';

const styles = theme => ({
  container: {
    margin: theme.spacing(1)
  }
});

const ButtonVariants = withStyles(styles)(({ classes }) => (
  <Grid
    container
    direction="column"
    spacing={2}
    className={classes.container}
  >
    <Grid item>
      <Button variant="text">Text</Button>
    </Grid>
    <Grid item>
      <Button variant="outlined">Outlined</Button>
    </Grid>
    <Grid item>
      <Button variant="contained">Contained</Button>
    </Grid>
  </Grid>
));

export default ButtonVariants;

当您加载屏幕时,您将看到以下内容:

它是如何工作的...

variant 属性控制渲染的按钮类型。这三个变体可以根据您的需要用于不同的场景或上下文。例如,如果这是您需要的,TEXT 按钮的注意力较少。相反,CONTAINED 按钮试图成为用户明显的交互点。

默认变体是 text。我发现当您明确包含变体时,Button 标记更容易阅读。这样,您或任何阅读代码的人都不必记住默认的 variant 是什么。

相关内容

按钮强调

Buttoncolordisabled 属性允许您控制按钮相对于其周围环境的强调程度。例如,您可以指定按钮应使用 primary 颜色值。按钮的强调是 variantcolor 属性累积的结果。您可以调整这两个属性,直到按钮具有适当的强调。

没有正确的强调级别。请使用适合您应用程序上下文的内容。

如何操作...

下面是一些显示您可以应用于 Button 组件的不同颜色值的代码:

import React from 'react';

import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';

const styles = theme => ({
  container: {
    margin: theme.spacing(1)
  }
});

const ButtonEmphasis = withStyles(styles)(({ classes, disabled }) => (
  <Grid
    container
    direction="column"
    spacing={16}
    className={classes.container}
  >
    <Grid item>
      <Typography variant="h6">Default</Typography>
    </Grid>
    <Grid item>
      <Grid container spacing={16}>
        <Grid item>
          <Button variant="text" disabled={disabled}>
            Text
          </Button>
        </Grid>
        <Grid item>
          <Button variant="outlined" disabled={disabled}>
            Outlined
          </Button>
        </Grid>
        <Grid item>
          <Button variant="contained" disabled={disabled}>
            Contained
          </Button>
        </Grid>
      </Grid>
    </Grid>
    <Grid item>
      <Typography variant="h6">Primary</Typography>
    </Grid>
    <Grid item>
      <Grid container spacing={16}>
        <Grid item>
          <Button variant="text" color="primary" disabled={disabled}>
            Text
          </Button>
        </Grid>
        <Grid item>
          <Button
            variant="outlined"
            color="primary"
            disabled={disabled}
          >
            Outlined
          </Button>
        </Grid>
        <Grid item>
          <Button
            variant="contained"
            color="primary"
            disabled={disabled}
          >
            Contained
          </Button>
        </Grid>
      </Grid>
    </Grid>
    <Grid item>
      <Typography variant="h6">Secondary</Typography>
    </Grid>
    <Grid item>
      <Grid container spacing={16}>
        <Grid item>
          <Button
            variant="text"
            color="secondary"
            disabled={disabled}
          >
            Text
          </Button>
        </Grid>
        <Grid item>
          <Button
            variant="outlined"
            color="secondary"
            disabled={disabled}
          >
            Outlined
          </Button>
        </Grid>
        <Grid item>
          <Button
            variant="contained"
            color="secondary"
            disabled={disabled}
          >
            Contained
          </Button>
        </Grid>
      </Grid>
    </Grid>
  </Grid>
));

export default ButtonEmphasis;

当屏幕首次加载时,您将看到以下内容:

如果 disabled 属性为 true,您将看到以下内容:

它是如何工作的...

此示例旨在说明 variantcolor 属性的组合结果。或者,您可以完全禁用按钮,同时仍然控制其强调方面的 variantcolor 属性对禁用按钮没有影响)。

最强调到最不强调的 variant 值的顺序如下:

  1. filled

  2. 概述

  3. text

最强调到最不强调的 color 值的顺序如下:

  1. primary

  2. secondary

  3. 默认

通过结合这两个属性值,你可以控制按钮的强调。有时,你确实需要一个按钮非常突出,因此你可以将containedprimary结合使用:

如果你希望你的按钮完全不突出,你可以将text变体与default颜色结合使用:

更多内容...

如果你的按钮放置在另一个 Material-UI 组件中,确保正确的颜色选择可能会很困难。例如,假设你有一个AppBar组件中的按钮,如下所示:

<AppBar color={appBarColor}>
  <Toolbar>
    <Grid container spacing={16}>
      <Grid item>
        <Button variant="text" disabled={disabled}>
          Text
        </Button>
      </Grid>
      <Grid item>
        <Button variant="outlined" disabled={disabled}>
          Outlined
        </Button>
      </Grid>
      <Grid item>
        <Button variant="contained" disabled={disabled}>
          Contained
        </Button>
      </Grid>
    </Grid>
  </Toolbar>
</AppBar>

如果AppBar颜色值是default,你会看到以下内容:

这实际上看起来并不太糟糕,因为按钮本身正在使用默认颜色。但是,如果你将AppBar颜色更改为primary会发生什么呢:

contained变体是唯一一个看起来几乎像是属于应用栏的按钮。让我们修改按钮,使它们都使用inherit颜色属性值,如下所示:

<AppBar color={appBarColor}>
  <Toolbar>
    <Grid container spacing={16}>
      <Grid item>
        <Button
          variant="text"
          disabled={disabled}
          color="inherit"
        >
          Text
        </Button>
      </Grid>
      <Grid item>
        <Button
          variant="outlined"
          disabled={disabled}
          color="inherit"
        >
          Outlined
        </Button>
      </Grid>
      <Grid item>
        <Button
          variant="contained"
          disabled={disabled}
          color="inherit"
        >
          Contained
        </Button>
      </Grid>
    </Grid>
  </Toolbar>
</AppBar>

现在,你的应用栏和按钮看起来是这样的:

文本和轮廓按钮现在看起来好多了。它们已经从其父组件继承了主题字体颜色。实际上,包含按钮现在看起来更糟,因为它正在使用继承作为其字体颜色。这是因为当继承颜色时,包含按钮的背景颜色不会改变。因此,相反,你必须自己更改包含按钮的颜色。

让我们看看我们是否可以通过实现一个返回要使用颜色的函数来自动设置包含按钮的颜色,基于其父元素的色彩:

function buttonColor(parentColor) {
  if (parentColor === 'primary') {
    return 'secondary';
  }

  if (parentColor === 'secondary') {
    return 'primary';
  }

  return 'default';
}

现在,当你设置你包含按钮的颜色时,你可以使用这个函数。只需确保你以参数的形式传递父元素的颜色,如下所示:

<Button
  variant="contained"
  disabled={disabled}
  color={buttonColor(appBarColor)}
>
  Contained
</Button>

现在,如果你将应用栏颜色更改为primary,你的按钮看起来是这样的:

如果你将应用栏颜色更改为secondary,你的按钮看起来是这样的:

快速回顾:TEXT 和 OUTLINED 按钮可以安全地使用inherit作为颜色。如果你正在处理包含按钮,你需要采取额外步骤来使用正确的颜色,就像你使用buttonColor()函数所做的那样。

参见

链接按钮

Material-UI 的 Button 组件也可以用作链接,指向应用中的其他位置。最常见的例子是将按钮用作通过 react-router 声明的路由的链接。

如何实现...

假设你的应用有三个页面,你需要三个按钮将它们链接到每个页面。随着应用的扩展,你可能还需要从任意位置链接到它们。以下是实现这一点的代码:

import React from 'react';
import { Switch, Route, Link } from 'react-router-dom';

import { withStyles } from '@material-ui/core/styles';
import Grid from '@material-ui/core/Grid';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';

const styles = theme => ({
  content: {
    margin: theme.spacing(2)
  }
});

const LinkButtons = withStyles(styles)(({ classes }) => (
  <Grid container direction="column" className={classes.container}>
    <Grid item>
      <Grid container>
        <Grid item>
          <Button component={Link} to="/">
            Home
          </Button>
        </Grid>
        <Grid item>
          <Button component={Link} to="/page1">
            Page 1
          </Button>
        </Grid>
        <Grid item>
          <Button component={Link} to="/page2">
            Page 2
          </Button>
        </Grid>
      </Grid>
    </Grid>
    <Grid item className={classes.content}>
      <Switch>
        <Route
          exact
          path="/"
          render={() => <Typography>home content</Typography>}
        />
        <Route
          path="/page1"
          render={() => <Typography>page 1 content</Typography>}
        />
        <Route
          path="/page2"
          render={() => <Typography>page 2 content</Typography>}
        />
      </Switch>
    </Grid>
  </Grid>
));

export default LinkButtons;

设置此示例以运行的 Storybook 代码包括一个 BrowserRouter 组件。在你的代码中,你需要将此组件包含为任何 Route 组件的父组件。

当屏幕首次加载时,你会看到以下内容:

如果你点击“页面 2”按钮,你将被带到 /page2,内容将相应更新:

它是如何工作的...

当你使用 react-router 作为应用的路由器时,你可以使用来自 react-router-domLink 组件来渲染链接。由于你想要渲染 Material-UI 按钮以获得一致的 Material-UI 主题和用户交互行为,你不能直接渲染 Link 组件。相反,你可以将底层的 Button 组件变成一个 Link 组件,如下所示:

<Button component={Link} to="/">
  Home
</Button>

通过使用 component 属性,你可以告诉 Button 组件将样式和事件处理逻辑应用到该组件而不是默认样式。然后,任何你通常传递给 Link 的附加属性都设置在 Button 组件上——并将它们转发给 Link。例如,to 属性不是 Button 的属性,所以它被传递给 Link,这是它工作所必需的。

还有更多...

这个示例的一个问题是,没有视觉指示按钮链接到当前 URL。例如,当应用首次加载 / URL 时,主页按钮应该从其他按钮中突出出来。一种方法是将 color 属性更改为 primary,如果按钮被认为是活动的。

你可以使用来自 react-router-domNavLink 组件。这个组件允许你设置仅在链接活动时应用的样式或类名。挑战在于,你只需要在活动时更改一个简单的 Button 属性。维护活动按钮的样式似乎有点多,尤其是如果你想使你的 UI 容易主题化。

相反,你可以创建一个按钮抽象,使用 react-router 工具在活动时渲染适当的 Button 属性,如下所示:

const NavButton = ({ color, ...props }) => (
  <Switch>
    <Route
      exact
      path={props.to}
      render={() => (
        <Button color="primary" component={Link} {...props} />
      )}
    />
    <Route
      path="/"
      render={() => <Button component={Link} {...props} />}
    />
  </Switch>
);

NavButton组件使用SwitchRoute组件来确定活动路由。它是通过比较传递给NavButtonto属性与当前 URL 来做到这一点的。如果找到匹配项,则渲染带有color属性设置为primaryButton组件。否则,不指定颜色(如果Switch中的第一个Route不匹配,则第二个Route匹配一切)。以下是新组件在操作中的样子:

<Grid container>
  <Grid item>
    <NavButton to="/">Home</NavButton>
  </Grid>
  <Grid item>
    <NavButton to="/page1">Page 1</NavButton>
  </Grid>
  <Grid item>
    <NavButton to="/page2">Page 2</NavButton>
  </Grid>
</Grid>

这是屏幕首次加载时的样子:

图片

因为初始 URL 是/,并且第一个NavButton组件有一个to属性为/,所以主页按钮颜色被标记为primary

参见

浮动操作

你的应用程序中的某些屏幕将有一个主要操作。例如,如果你在一个列出项目的屏幕上,主要操作可能是添加新项目。如果你在一个项目详情页面上,主要操作可能是编辑项目。Material-UI 提供了一个Fab组件(浮动操作按钮),以突出显示主要屏幕操作。

如何实现...

浮动操作按钮的常见情况是向用户展示一个带有表示要执行的操作的图标、位于屏幕右下角的圆形按钮。此外,浮动操作按钮的位置是固定的,这意味着当用户滚动页面时,主要操作始终可见。

让我们编写一些代码来定位一个位于屏幕右下角的浮动操作按钮,以指示添加操作,如下所示:

import React, { Fragment } from 'react';

import { withStyles } from '@material-ui/core/styles';
import Fab from '@material-ui/core/Fab';
import AddIcon from '@material-ui/icons/Add';

const styles = theme => ({
  fab: {
    margin: 0,
    top: 'auto',
    left: 'auto',
    bottom: 20,
    right: 20,
    position: 'fixed'
  }
});

const FloatingActions = withStyles(styles)(({ classes, fabColor }) => (
  <Fragment>
    <Fab className={classes.fab} color={fabColor}>
      <AddIcon />
    </Fab>
  </Fragment>
));

export default FloatingActions;

当你加载屏幕时,你会在屏幕右下角看到以下内容:

图片

该屏幕组件有一个fabColor属性,用于设置Fab组件的颜色。以下是primary颜色的样子:

图片

最后,这是以secondary颜色为背景的浮动操作按钮的样子:

图片

它是如何工作的...

Fab组件与Button组件非常相似。实际上,你过去使用Button来渲染浮动操作按钮,使用fab变体。按钮的圆角样式由Fab处理。你只需要支持图标和任何其他按钮属性,例如onClick处理程序。此外,你可以在浮动操作按钮中包含文本。如果你这样做,你应该使用extended变体,以便正确地样式化按钮的形状(顶部和底部是平的而不是圆角)。

还有更多...

让我们为Fab组件创建一个小型的抽象,它应用fab样式并使用正确的变体。由于extended变体仅在按钮中有文本时才有用,因此你不需要每次使用时都记住设置它。如果你的应用程序既有图标又有图标加文本的浮动操作按钮,这可能会特别令人困惑。

这是实现新Fab组件的代码:

const ExtendedFab = withStyles(styles)(({ classes, ...props }) => {
  const isExtended = React.Children.toArray(props.children).find(
    child => typeof child === 'string'
  );

  return (
    <Fab
      className={classes.fab}
      variant={isExtended && 'extended'}
      {...props}
    />
  );
});

className属性设置方式与之前相同。当isExtendedtrue时,variant属性设置为extended。为了找出这一点,它使用React.Children.toArray()函数将children属性转换为普通数组。然后,find()方法查找任何文本元素。如果找到了一个,isExtended将为true,并使用extended变体。

这是如何使用新的ExtendedFab按钮的:

export default ({ fabColor }) => (
  <ExtendedFab color={fabColor}>
    Add
    <AddIcon />
  </ExtendedFab>
);

添加文本放置在AddIcon组件之前。这个ExtendedFab组件有两个子组件,其中一个是有文本的,这意味着将使用extended变体。以下是它的样子:

参见

图标按钮

有时,你需要一个仅是图标的按钮。这就是IconButton组件派上用场的地方。你可以传递任何图标组件作为子组件,然后你就有了一个图标按钮。

如何做到这一点...

图标按钮在你处理受限的屏幕空间或想要直观地显示某物的切换状态时特别有用。例如,如果启用/禁用状态表示实际的麦克风,用户切换麦克风的开关可能更容易。

让我们在此基础上构建,并在应用程序中使用图标按钮实现麦克风和音量的切换控制。以下是代码:

import React, { useState } from 'react';

import IconButton from '@material-ui/core/IconButton';
import Grid from '@material-ui/core/Grid';

import MicIcon from '@material-ui/icons/Mic';
import MicOffIcon from '@material-ui/icons/MicOff';
import VolumeUpIcon from '@material-ui/icons/VolumeUp';
import VolumeOffIcon from '@material-ui/icons/VolumeOff';

export default function IconButtons({ iconColor }) {
  const [mic, setMic] = useState(true);
  const [volume, setVolume] = useState(true);

  return (
    <Grid container>
      <Grid item>
        <IconButton color={iconColor} onClick={() => setMic(!mic)}>
          {mic ? <MicIcon /> : <MicOffIcon />}
        </IconButton>
      </Grid>
      <Grid item>
        <IconButton
          color={iconColor}
          onClick={() => setVolume(!volume)}
        >
          {volume ? <VolumeUpIcon /> : <VolumeOffIcon />}
        </IconButton>
      </Grid>
    </Grid>
  );
}

当你首次加载屏幕时,你会看到以下内容:

如果你点击两个图标按钮,你会看到以下内容:

无论麦克风或音量的状态如何,用户仍然可以有一个对项目及其状态的视觉指示。

它是如何工作的...

此屏幕的组件维护两个状态:micvolume。这两个都是布尔值,用于控制IconButton组件中显示的图标:

const [mic, setMic] = useState(true);
const [volume, setVolume] = useState(true);

然后,基于这些状态,当状态改变时,图标会相应交换,为用户提供有用的视觉反馈:

<Grid item>
  <IconButton color={iconColor} onClick={() => setMic(!mic)}>
    {mic ? <MicIcon /> : <MicOffIcon />}
  </IconButton>
</Grid>
<Grid item>
  <IconButton
    color={iconColor}
    onClick={() => setVolume(!volume)}
  >
    {volume ? <VolumeUpIcon /> : <VolumeOffIcon />}
  </IconButton>
</Grid>

此外,此屏幕的组件接受一个iconColor属性,它可以是defaultprimarysecondary。以下是primary颜色的样子:

参见

按钮尺寸

Material-UI 按钮支持 T 恤式尺寸。与其试图为您的按钮找到完美的尺寸,您可以使用最接近您需求的预定义尺寸之一。

如何做到这一点...

如果您需要调整按钮的大小,可以使用smallmedium(默认值)或large。以下是如何设置Button组件的size的示例:

import React from 'react';

import Button from '@material-ui/core/Button';

export default function ButtonSizes({ size, color }) {
  return (
    <Button variant="contained" size={size} color={color}>
      Add
    </Button>
  );
}

下面是各种尺寸的外观:

它是如何工作的...

中等尺寸和大型尺寸之间的区别最大。使用大型按钮,结合其他Button属性,如colorIcons,可以使按钮真正脱颖而出。

还有更多...

使用带按钮的 T 恤尺寸的一个缺点是,当结合文本和图标图像时。图标不会像文本一样缩放,所以按钮看起来永远不太对劲,除非使用中等默认尺寸。

让我们实现一个按钮抽象,使其更容易使用可一致调整大小的文本按钮或图标按钮。以下是代码:

import React from 'react';

import Grid from '@material-ui/core/Grid';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import Fab from '@material-ui/core/Fab';

import AddIcon from '@material-ui/icons/Add';

const MyButton = ({ fab, ...props }) => {
  const [child] = React.Children.toArray(props.children);
  let ButtonComponent;

  if (React.isValidElement(child) && fab) {
    ButtonComponent = Fab;
  } else if (React.isValidElement(child)) {
    ButtonComponent = IconButton;
  } else {
    ButtonComponent = Button;
  }

  return <ButtonComponent {...props} />;
};

export default function ButtonSizes({ size, color }) {
  return (
    <Grid container spacing={16} alignItems="center">
      <Grid item>
        <MyButton variant="contained" size={size} color={color}>
          Add
        </MyButton>
      </Grid>
      <Grid item>
        <MyButton size={size} color={color}>
          <AddIcon />
        </MyButton>
      </Grid>
      <Grid item>
        <MyButton fab size={size} color={color}>
          <AddIcon />
        </MyButton>
      </Grid>
    </Grid>
  );
}

size属性设置为small时,屏幕上三个按钮的外观如下:

下面是large尺寸的外观:

让我们分析MyButton组件中正在发生的事情。它期望一个单一的child节点,它通过将children属性转换为数组并将第一个元素分配给child常量来获取:

const [child] = React.Children.toArray(props.children);

理念是根据child元素和fab属性渲染适当的Button元素。以下是正确组件分配给ButtonComponent的方式:

if (React.isValidElement(child) && fab) {
  ButtonComponent = Fab;
} else if (React.isValidElement(child)) {
  ButtonComponent = IconButton;
} else {
  ButtonComponent = Button;
}

如果child是一个元素且fab属性为真,则使用Fab组件。如果child是一个元素且fab为假,则使用IconButton。否则,使用Button。这意味着您可以将有效的图标元素或文本作为子元素传递给MyButton。使用此组件渲染的任何按钮的大小设置都将保持一致。

参见

第十一章:文本 - 收集文本输入

在本章中,你将了解以下主题:

  • 使用状态控制输入

  • 占位符和辅助文本

  • 验证和错误显示

  • 密码字段

  • 多行输入

  • 输入装饰

  • 输入掩码

简介

Material-UI 有一个灵活的文本输入组件,可以以多种方式使用来收集用户输入。它的用法范围从收集简单的单行文本输入到带有图标的掩码输入。

使用状态控制输入

TextField组件可以通过 React 组件的state来控制,就像常规 HTML 文本输入元素一样。与其他类型的表单控件一样,实际值通常是起点——随着更多功能的添加,每个表单控件的状态会变得更加复杂。

如何做到这一点...

就像任何其他文本输入元素一样,你需要为TextField组件提供一个onChange事件处理器来更新输入的状态。没有这个处理器,用户输入时输入的值不会改变。让我们看看一个例子,其中渲染了三个文本字段,并且它们各自由自己的状态控制:

import React, { useState } from 'react';

import { makeStyles } from '@material-ui/styles';
import TextField from '@material-ui/core/TextField';
import Grid from '@material-ui/core/Grid';

const useStyles = makeStyles(theme => ({
  container: { margin: theme.spacing.unit * 2 }
}));

export default function ControllingInputWithState() {
  const classes = useStyles();
  const [first, setFirst] = useState('');
  const [second, setSecond] = useState('');
  const [third, setThird] = useState('');

  return (
    <Grid container spacing={4} className={classes.container}>
      <Grid item>
        <TextField
          id="first"
          label="First"
          value={first}
          onChange={e => setFirst(e.target.value)}
        />
      </Grid>
      <Grid item>
        <TextField
          id="second"
          label="Second"
          value={second}
          onChange={e => setSecond(e.target.value)}
        />
      </Grid>
      <Grid item>
        <TextField
          id="third"
          label="Third"
          value={third}
          onChange={e => setThird(e.target.value)}
        />
      </Grid>
    </Grid>
  );
}

当你首次加载屏幕时,你会看到以下内容:

如果你输入每个文本字段,你将更新屏幕上组件的状态:

它是如何工作的...

使用useState()创建的设置函数:setFirst()setSecond()setThird(),通过改变组件在onChange事件中使用的状态来改变TextField组件的值。

TextField组件是一个方便的抽象,它建立在其他 Material-UI 组件(如FormControlInput)之上。你可以通过用这些组件中的任何一个替换TextField来达到完全相同的结果。但你会得到更多的代码来维护。

还有更多...

如果,除了只在组件状态中保留TextField值之外,你还保留了idlabel信息呢?将永远不会改变的价值作为状态存储可能会显得有些混乱,但权衡是你可以让状态数据驱动组件渲染的内容,而不是不得不反复重复相同的TextField组件。

首先,让我们改变组件状态的结构,如下所示:

const [inputs, setInputs] = useState([
  { id: 'first', label: 'First', value: '' },
  { id: 'second', label: 'Second', value: '' },
  { id: 'third', label: 'Third', value: '' }
]);

与使用具有字符串属性的对象来保存文本字段值不同,inputs状态是一个对象数组。它是一个数组,这样组件就可以在保持顺序的同时遍历值。每个对象都有渲染TextField所需的一切。让我们看看更新的标记:

<Grid container spacing={4} className={classes.container}>
  {inputs.map(input => (
    <Grid item key={input.id}>
      <TextField
        id={input.id}
        label={input.label}
        value={input.value}
        onChange={onChange}
      />
    </Grid>
  ))}
</Grid>

每个Grid项现在映射到inputs数组中的一个元素。如果你需要添加、删除或更改这些文本字段中的任何一个,你可以通过更新状态来实现。最后,让我们看看onChange()的实现:

const onChange = ({ target: { id, value } }) => {
  const newInputs = [...inputs];
  const index = inputs.findIndex(input => input.id === id);

  newInputs[index] = { ...inputs[index], value };

  setInputs(newInputs);
};

onChange() 函数更新数组中的一个项目,即 inputs 数组。首先,它根据文本字段的 id 找到要更新的项目的 index。然后,它使用文本字段的值更新 value 属性。

功能与之前完全相同,但采用了更少 JSX 标记的方法。

相关内容

占位符和辅助文本

至少,文本字段应该有一个标签,以便用户知道要输入什么。但仅有的标签可能会非常令人困惑——尤其是如果你在同一屏幕上有多个文本字段。为了帮助用户理解要输入什么,你可以利用 placeholderhelperText,除了 label 之外。

如何做到这一点...

让我们编写一些代码,展示你可以与 TextField 组件一起使用的各种 labelplaceholderhelperText 配置:

import React from 'react';

import { withStyles } from '@material-ui/core/styles';
import Grid from '@material-ui/core/Grid';
import TextField from '@material-ui/core/TextField';

const styles = theme => ({
  container: { margin: theme.spacing(2) }
});

const PlaceholderAndHelperText = withStyles(styles)(({ classes }) => (
  <Grid container spacing={4} className={classes.container}>
    <Grid item>
      <TextField label="The Value" />
    </Grid>
    <Grid item>
      <TextField placeholder="Example Value" />
    </Grid>
    <Grid item>
      <TextField helperText="Brief explanation of the value" />
    </Grid>
    <Grid item>
      <TextField
        label="The Value"
        placeholder="Example Value"
        helperText="Brief explanation of the value"
      />
    </Grid>
  </Grid>
));

export default PlaceholderAndHelperText;

这是四个文本字段的模样:

它是如何工作的...

让我们逐一查看这些文本字段,并分析它们的优缺点。

首先,有一个只包含 label 组件的文本字段:

<TextField label="The Value" />

当你只有 label 时,它将显示在用户输入文本的位置:

当用户导航到文本字段并获得焦点时,label 缩小并移开:

下一个文本字段使用 placeholder 属性指定占位文本:

<TextField placeholder="Example Value" />

如果可能的话,placeholder 文本应向用户提供一个有效值的示例:

当用户开始输入文本时,placeholder 值消失:

下一个文本字段提供了 helperText 属性的值:

文本字段的辅助文本在静态意义上是始终可见的,即使用户开始输入也不会移动。最后,文本字段可以具有所有三个帮助用户确定应提供什么值的属性:

  • 一个告诉用户值是什么的标签

  • 提供示例值的占位文本

  • 提供更多解释为什么需要值的辅助文本

当你结合这三个属性时,你正在增加用户理解应输入什么内容的可能性。当文本字段未获得焦点时,标签和辅助文本是可见的:

当文本字段获得焦点时,标签缩小,占位符值被揭示:

相关内容

验证和错误显示

即使有辅助文本、占位符和标签,用户也难免会输入一些不太正确的东西。并不是他们试图搞砸事情(公平地说,有些人确实如此);而是错误总是会发生。当出现错误时,文本输入字段需要标记为处于错误状态。

如何做到这一点...

假设你有两个输入:一个电话号码和一个电子邮件地址,并且你想要确保用户提供的值是正确的。

请注意:验证并不完美。幸运的是,这个组件可以工作,只要你需要它,你仍然会得到所有的 Material-UI 组件。

这是实现它的代码:

import React, { useState } from 'react';

import { makeStyles } from '@material-ui/styles';
import Grid from '@material-ui/core/Grid';
import TextField from '@material-ui/core/TextField';

const useStyles = makeStyles(theme => ({
  container: { margin: theme.spacing(2) }
}));

export default function ValidationAndErrorDisplay() {
  const classes = useStyles();
  const [inputs, setInputs] = useState([
    {
      id: 'phone',
      label: 'Phone',
      placeholder: '999-999-9999',
      value: '',
      error: false,
      helperText: 'Any valid phone number will do',
      getHelperText: error =>
        error
          ? 'Woops. Not a valid phone number'
          : 'Any valid phone number will do',
      isValid: value =>
        /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/.test(
          value
        )
    },
    {
      id: 'email',
      label: 'Email',
      placeholder: 'john@acme.com',
      value: '',
      error: false,
      helperText: 'Any valid email address will do',
      getHelperText: error =>
        error
          ? 'Woops. Not a valid email address'
          : 'Any valid email address will do',
      isValid: value => /\S+@\S+\.\S+/.test(value)
    }
  ]);

  const onChange = ({ target: { id, value } }) => {
    const newInputs = [...inputs];
    const index = inputs.findIndex(input => input.id === id);
    const input = inputs[index];
    const isValid = input.isValid(value);

    newInputs[index] = {
      ...input,
      value: value,
      error: !isValid,
      helperText: input.getHelperText(!isValid)
    };

    setInputs(newInputs);
  };

  return (
    <Grid container spacing={4} className={classes.container}>
      {inputs.map(input => (
        <Grid item key={input.id}>
          <TextField
            id={input.id}
            label={input.label}
            placeholder={input.placeholder}
            helperText={input.helperText}
            value={input.value}
            onChange={onChange}
            error={input.error}
          />
        </Grid>
      ))}
    </Grid>
  );
}

ValidationAndErrorDisplay 组件将在屏幕上渲染两个 TextField 组件。这是屏幕首次加载时的样子:

电话和电子邮件文本字段只是带有标签、辅助文本和占位符的常规文本字段。例如,当电话字段获得焦点时,它看起来像这样:

当你开始输入时,文本字段的值会与电话格式的正则表达式进行验证。以下是当字段具有无效电话号码值时的样子:

然后,一旦你有一个有效的电话号码值,文本字段的状况就会恢复到正常:

电子邮件字段的工作方式相同——唯一的区别是用于验证值格式的正则表达式。

它是如何工作的...

让我们先看看 ValidationAndErrorDisplay 组件的状态:

const [inputs, setInputs] = useState([
  {
    id: 'phone',
    label: 'Phone',
    placeholder: '999-999-9999',
    value: '',
    error: false,
    helperText: 'Any valid phone number will do',
    getHelperText: error =>
      error
        ? 'Woops. Not a valid phone number'
        : 'Any valid phone number will do',
    isValid: value =>
      /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/.test(
        value
      )
  },
  {
    id: 'email',
    label: 'Email',
    placeholder: 'john@acme.com',
    value: '',
    error: false,
    helperText: 'Any valid email address will do',
    getHelperText: error =>
      error
        ? 'Woops. Not a valid email address'
        : 'Any valid email address will do',
    isValid: value => /\S+@\S+\.\S+/.test(value)
  }
]);

inputs 数组通过 render() 方法映射到 TextField 组件。这个数组中的每个对象都有直接映射到 TextField 组件的属性。例如,idlabelplaceholder——这些都是 TextField 的属性。每个对象都有两个帮助验证文本字段值的函数。首先,getHelperText() 返回默认的辅助文本,或者如果 error 参数为真,则替换辅助文本的错误文本。isValid() 函数将 value 参数与正则表达式进行验证,如果匹配则返回 true

接下来,让我们看看 onChange() 处理程序:

const onChange = ({ target: { id, value } }) => {
  const newInputs = [...inputs];
  const index = inputs.findIndex(input => input.id === id);
  const input = inputs[index];
  const isValid = input.isValid(value);

  newInputs[index] = {
    ...input,
    value: value,
    error: !isValid,
    helperText: input.getHelperText(!isValid)
  };

  setInputs(newInputs);
};

随着用户输入,此函数会更新给定文本字段的值状态。它还会调用 isValid() 函数,并将更新后的值传递给它。如果值无效,则将 error 状态设置为 truehelperText 状态也会通过 getHelperText() 更新,这同样取决于值的有效性。

还有更多...

如果这个例子可以被修改,以至于您不需要将错误信息作为状态存储,或者不需要一个函数来更改文本框的辅助文本?为了做到这一点,您可以引入一个新的TextField抽象,该抽象处理设置error属性,并在值无效时更改helperText组件。以下是新的组件:

const MyTextField = ({ isInvalid, ...props }) => {
  const invalid = isInvalid(props.value);

  return (
    <TextField
      {...props}
      error={invalid}
      helperText={invalid || props.helperText}
    />
  );
};

与返回true表示数据有效的函数不同,MyTextField组件期望一个isInvalid()属性,当数据有效时返回false,当数据无效时返回错误信息。然后,error属性可以使用这个值,这将改变文本框的颜色以指示它处于错误状态,而helperText属性可以使用isInvalid()函数返回的字符串,或者传递给组件的helperText属性。

接下来,让我们看看ValidationAndErrorDisplay组件现在使用的状态:

const [inputs, setInputs] = useState([
  {
    id: 'phone',
    label: 'Phone',
    placeholder: '999-999-9999',
    value: '',
    helperText: 'Any valid phone number will do',
    isInvalid: value =>
      value === '' ||
      /^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$/.test(
        value
      )
        ? false
        : 'Woops. Not a valid phone number'
  },
  {
    id: 'email',
    label: 'Email',
    placeholder: 'john@acme.com',
    value: '',
    helperText: 'Any valid email address will do',
    isInvalid: value =>
      value === '' || /\S+@\S+\.\S+/.test(value)
        ? false
        : 'Woops. Not a valid email address'
  }
]);

输入不再需要getHelperText()函数或error状态。isInvalid()函数在值无效时返回错误辅助文本。接下来,让我们看看onChange()处理程序:

const onChange = ({ target: { id, value } }) => {
  const newInputs = [...inputs];
  const index = inputs.findIndex(input => input.id === id);

  newInputs[index] = {
    ...inputs[index],
    value: value
  };

  setInputs(newInputs);
};

现在,它不需要接触error状态,也不必担心更新辅助文本,或者调用任何验证函数——所有这些现在都由MyTextField处理。

参见

密码字段

密码字段是一种特殊的文本输入类型,在输入时隐藏单个字符。Material-UI TextField组件通过改变type属性的值来支持这种类型的字段。

如何做到这一点...

这里有一个简单的例子,它将常规文本输入转换为防止在屏幕上显示值的password输入:

import React, { useState } from 'react';

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

export default function PasswordFields() {
  const [password, setPassword] = useState('12345');

  const onChange = e => {
    setPassword(e.target.value);
  };

  return (
    <TextField
      type="password"
      label="Password"
      value={password}
      onChange={onChange}
    />
  );
}

这里是首次加载时的屏幕样子:

如果您更改密码字段的值,任何新的字符都将保持隐藏,尽管实际输入的值存储在PasswordFields组件的password状态中。

它是如何工作的...

type属性告诉TextField组件使用密码 HTML input元素。这就是为什么用户在输入时值保持隐藏,或者如果字段预先填充了密码值。有时,密码字段可以被自动填充。

还有更多...

您可以使用autoComplete属性来控制浏览器如何自动填充密码值。这个值的一个常见用例是在用户名字段填写后,自动在登录屏幕上填充密码字段。以下是一个在屏幕上具有用户名和密码字段时如何使用此属性的示例:

import React, { useState } from 'react';

import { makeStyles } from '@material-ui/styles';
import Grid from '@material-ui/core/Grid';
import TextField from '@material-ui/core/TextField';

const useStyles = makeStyles(theme => ({
  container: { margin: theme.spacing(2) }
}));

export default function PasswordFields() {
  const classes = useStyles();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  return (
    <Grid container spacing={4} className={classes.container}>
      <Grid item>
        <TextField
          id="username"
          label="Username"
          autoComplete="username"
          InputProps={{ name: 'username' }}
          value={username}
          onChange={e => setUsername(e.target.value)}
        />
      </Grid>
      <Grid item>
        <TextField
          id="password"
          type="password"
          label="Password"
          autoComplete="current-password"
          value={password}
          onChange={e => setPassword(e.target.value)}
        />
      </Grid>
    </Grid>
  );
}

第一个 TextField 组件使用了 autoCompleteusername 值。它还向 InputProps 传递了 { name: 'username' },以便在 <input> 元素上设置 name 属性。你需要这样做的原因是,在第二个 TextField 组件中,autoCompletecurrent-password 值告诉浏览器根据 username 字段值查找密码。

并非所有浏览器都实现了这一功能。为了使任何凭据能够自动填充到文本字段中,它们必须使用原生的浏览器凭据记住工具保存。

参见

多行输入

对于某些字段,用户需要提供多行文本值的能力。multiline 属性有助于实现这一目标。

如何实现...

假设你有一个可能需要多行文本的字段,由用户提供。您可以指定 multiline 属性以允许这样做:

import React, { useState } from 'react';

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

export default function MultilineInput() {
  const [multiline, setMultiline] = useState('');

  return (
    <TextField
      multiline
      value={multiline}
      onChange={e => setMultiline(e.target.value)}
    />
  );
}

当屏幕首次加载时,文本字段看起来像一个普通字段,因为它默认只有一行:

图片

你可以在这个文本字段中输入你需要的任意多行。新行通过按下 Enter 键开始:

图片

它是如何工作的...

multiline 布尔属性用于向 TextField 组件指示该字段需要 multiline 支持。在先前的示例中,如果你计划在一个拥挤的空间中使用 multiline 输入,比如屏幕上有许多其他字段或是在对话框中,你可能会遇到一些问题:

  • 当用户按下 Enter 键时,字段的高度会改变,向组件添加更多行。这可能会引起布局问题,因为其他元素会被移动。

  • 如果字段从一行开始并且看起来像常规的单行文本输入,那么用户可能不会意识到他们可以在字段中输入多行文本。

还有更多...

为了帮助防止动态大小的 multiline 文本字段可能引起的问题,您可以指定 multiline 文本字段使用的行数。以下是如何使用 rows 属性的示例:

<TextField
  multiline
  rows={5}
  label="Address"
  value={multiline}
  onChange={e => setMultiline(e.target.value)}
/>

现在,文本字段将正好有五行:

图片

如果用户输入超过五行的文本,将显示垂直滚动条——文本的高度不会改变,并且不会影响其他周围组件的布局。您可以通过使用 rowsMax 属性而不是 rows 来对 TextField 组件施加相同类型的高度限制。区别在于文本字段将从一个行开始,并在用户添加新行时增长。但如果将 rowsMax 设置为 5,文本字段将不会超过五行。

参见

输入装饰

Material-UI Input 组件具有允许你自定义其外观和行为属性的属性。想法是你可以用其他 Material-UI 组件装饰输入,以扩展基本文本输入的功能,使其对应用用户有意义。

如何实现...

假设你的应用有几个屏幕,这些屏幕都有密码输入。你的应用用户喜欢在输入密码时能够看到密码。默认情况下,值将被隐藏,但如果输入组件本身有一个切换值可见性的按钮,这将使你的用户感到高兴。

这里是一个示例,展示了一个通用的组件,它将为密码字段添加一个可见性切换按钮:

import React, { useState } from 'react';

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

import VisibilityIcon from '@material-ui/icons/Visibility';
import VisibilityOffIcon from '@material-ui/icons/VisibilityOff';

function PasswordField() {
  const [visible, setVisible] = useState(false);

  const toggleVisibility = () => {
    setVisible(!visible);
  };

  return (
    <TextField
      type={visible ? 'text' : 'password'}
      InputProps={{
        endAdornment: (
          <InputAdornment position="end">
            <IconButton onClick={toggleVisibility}>
              {visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
            </IconButton>
          </InputAdornment>
        )
      }}
    />
  );
}

export default function InputAdornments() {
  const [password, setPassword] = useState('');

  return (
    <PasswordField
      value={password}
      onChange={e => setPassword(e.target.value)}
    />
  );
}

如果你开始输入而不点击切换可见性按钮,你会看到这样的效果:

图片

如果我们点击切换可见性按钮,密码字段看起来是这样的:

图片

它是如何工作的...

让我们更仔细地看看 PasswordField 组件:

function PasswordField() {
  const [visible, setVisible] = useState(false);

  const toggleVisibility = () => {
    setVisible(!visible);
  };

  return (
    <TextField
      type={visible ? 'text' : 'password'}
      InputProps={{
        endAdornment: (
          <InputAdornment position="end">
            <IconButton onClick={toggleVisibility}>
              {visible ? <VisibilityIcon /> : <VisibilityOffIcon />}
            </IconButton>
          </InputAdornment>
        )
      }}
    />
  );
}

这个组件维护一个名为 visible 的状态。PasswordField 维护这个状态而不是父组件的原因是关注点分离原则。例如,父组件可能需要访问密码字段的值。这个值作为属性传递到 PasswordField。然而,只有 PasswordField 关心 visibility 状态。因此,通过将其封装在这个组件中,你简化了任何使用 PasswordField 的代码。

这个抽象的另一个有价值的方面是装饰本身。type 属性随着 visible 状态的变化而变化——这是显示或隐藏密码值的机制。endAdornment 属性传递给 TextField 渲染的 Input 组件,通过 InputProps 传递。这就是你如何向字段添加组件的方式。在这个例子中,你正在向输入的右侧(末端)添加一个图标按钮。这里的图标根据可见性状态变化,并且当点击时,会调用 toggleVisible() 方法来实际改变可见性状态。

还有更多...

你可以使用输入装饰来做的不仅仅是显示密码字段的值按钮。例如,在一个需要验证的字段中,你可以使用输入装饰来帮助可视化字段的状态。假设你需要验证用户输入的电子邮件字段。你可以创建一个组件形式的抽象,根据用户提供的验证结果改变组件的颜色和装饰。这个组件看起来是这样的:

const ValidationField = props => {
  const { isValid, ...rest } = props;
  const empty = props.value === '';
  const valid = isValid(props.value);
  let startAdornment;

  if (empty) {
    startAdornment = null;
  } else if (valid) {
    startAdornment = (
      <InputAdornment position="start">
        <CheckCircleIcon color="primary" />
      </InputAdornment>
    );
  } else {
    startAdornment = (
      <InputAdornment position="start">
        <ErrorIcon color="error" />
      </InputAdornment>
    );
  }

  return (
    <TextField
      {...rest}
      error={!empty && !valid}
      InputProps={{ startAdornment }}
    />
  );
};

ValidationField 的想法是获取一个 isValid() 函数属性,并使用它来测试值属性。如果它返回 true,则 startAdornment 是一个勾选标记。如果 isValid() 返回 false,则 startAdornment 是一个红色的 x。以下是组件的使用方法:

<ValidationField
  label="Email"
  value={this.state.email}
  onChange={this.onEmailChange}
  isValid={v => /\S+@\S+\.\S+/.test(v)}
/>

ValidationField 组件几乎可以与 TextField 一样使用。唯一的增加是 isValid 属性。任何状态都在 ValidationField 之外处理,这意味着 isValid() 会在值更改时被调用,并将更新组件的外观以反映数据的有效性。作为额外的奖励:你实际上不需要在任何地方存储任何类型的错误状态,因为 ValidationField 从值和 isValid 属性中推导出它所需的一切。

这是带有无效电子邮件地址的字段看起来像什么:

这是带有有效电子邮件地址的字段看起来像什么:

参见

输入掩码

一些文本输入需要具有特定格式的值。使用 Material-UI TextField 组件,你可以添加掩码功能,这有助于引导用户提供正确的格式。

如何操作...

假设你有一个电话号码和电子邮件字段,并且你想要为每个字段提供一个输入掩码。以下是你可以如何使用来自 react-text-maskMaskedInput 组件与 TextField 组件一起添加掩码功能:

import React, { Fragment, useState } from 'react';
import MaskedInput from 'react-text-mask';
import emailMask from 'text-mask-addons/dist/emailMask';

import { makeStyles } from '@material-ui/styles';
import TextField from '@material-ui/core/TextField';

const useStyles = makeStyles(theme => ({
  input: { margin: theme.spacing.unit * 3 }
}));

const PhoneInput = ({ inputRef, ...props }) => (
  <MaskedInput
    {...props}
    ref={ref => {
      inputRef(ref ? ref.inputElement : null);
    }}
    mask={[
      '(',
      /[1-9]/,
      /\d/,
      /\d/,
      ')',
      ' ',
      /\d/,
      /\d/,
      /\d/,
      '-',
      /\d/,
      /\d/,
      /\d/,
      /\d/
    ]}
    placeholderChar={'\u2000'}
  />
);

const EmailInput = ({ inputRef, ...props }) => (
  <MaskedInput
    {...props}
    ref={ref => {
      inputRef(ref ? ref.inputElement : null);
    }}
    mask={emailMask}
    placeholderChar={'\u2000'}
  />
);

export default function InputMasking() {
  const classes = useStyles();
  const [phone, setPhone] = useState('');
  const [email, setEmail] = useState('');

  return (
    <Fragment>
      <TextField
        label="Phone"
        className={classes.input}
        value={phone}
        onChange={e => setPhone(e.target.value)}
        InputProps={{ inputComponent: PhoneInput }}
      />
      <TextField
        label="Email"
        className={classes.input}
        value={email}
        onChange={e => setEmail(e.target.value)}
        InputProps={{ inputComponent: EmailInput }}
      />
    </Fragment>
  );
}

这是屏幕首次加载时的样子:

当你开始在电话字段中输入值时,格式掩码就会出现:

这就是完成后的值看起来像什么——用户永远不需要输入 ()-

这就是完成后的电子邮件值看起来像什么:

在电子邮件输入中,用户实际上必须输入 @.,因为掩码不知道电子邮件地址的任何部分有多少个字符。然而,它确实阻止用户将这两个字符放在错误的位置。

它是如何工作的...

为了使这起作用,你创建了一个 PhoneInput 组件和一个 EmailInput 组件。每个组件的想法是围绕 MaskedInput 组件提供基本的抽象。让我们更详细地看看每个组件,从 PhoneInput 开始:

const PhoneInput = ({ inputRef, ...props }) => (
  <MaskedInput
    {...props}
    ref={ref => {
      inputRef(ref ? ref.inputElement : null);
    }}
    mask={[
      '(',
      /[1-9]/,
      /\d/,
      /\d/,
      ')',
      ' ',
      /\d/,
      /\d/,
      /\d/,
      '-',
      /\d/,
      /\d/,
      /\d/,
      /\d/
    ]}
    placeholderChar={'\u2000'}
  />
);

传递给PhoneInput的属性大部分被转发到MaskedInput。由于名称不同,ref属性需要显式设置。placeholder属性被设置为空白。mask属性是最重要的——这是用户在开始输入时看到的模式。传递给mask的值是一个包含正则表达式和字符串字符的数组。字符串字符是用户开始输入时显示的内容——在电话号码的情况下,这些是()-字符。正则表达式是与用户输入匹配的动态部分。对于电话号码,任何数字都可以,但不允许符号和字母。

现在我们来看看EmailInput组件:

const EmailInput = ({ inputRef, ...props }) => (
  <MaskedInput
    {...props}
    ref={ref => {
      inputRef(ref ? ref.inputElement : null);
    }}
    mask={emailMask}
    placeholderChar={'\u2000'}
  />
);

这与PhoneInput采用相同的方法。主要区别在于,不是传递一个字符串数组和正则表达式,而是使用从react-text-mask导入的emailMask函数。

现在你有了这两个掩码输入,你可以通过将它们传递给inputComponent属性来使用它们:

<TextField
  label="Phone"
  className={classes.input}
  value={phone}
  onChange={e => setPhone(e.target.value)}
  InputProps={{ inputComponent: PhoneInput }}
/>
<TextField
  label="Email"
  className={classes.input}
  value={email}
  onChange={e => setEmail(e.target.value)}
  InputProps={{ inputComponent: EmailInput }}
/>

参见

第十二章:自动完成和芯片 - 多个项目的文本输入建议

在本章中,你将学习以下主题:

  • 构建自动完成组件

  • 选择自动完成建议

  • API 驱动的自动完成

  • 突出显示搜索结果

  • 独立芯片输入

简介

当有太多选择时,Web 应用程序通常会提供自动完成输入字段。自动完成字段类似于文本输入字段——当用户开始输入时,他们会根据输入的内容得到一个更小的选择列表。一旦用户准备好进行选择,实际的输入将被称为 Chips 的组件填充——特别是当用户需要能够进行多项选择时。

构建自动完成组件

Material-UI 并不实际包含一个 Autocomplete 组件。原因是,在 React 生态系统中已经存在许多不同的自动完成选择组件的实现,因此再提供一个没有意义。相反,你可以选择现有的实现,并用 Material-UI 组件增强它,以便它可以很好地与你的 Material-UI 应用程序集成。

如何做到这一点...

假设你有一个用于选择曲棍球队的选择器。但是,由于球队太多,无法合理地放入简单的选择组件中——你需要自动完成功能。你可以使用来自 react-select 包的 Select 组件来提供所需的自动完成功能。你可以使用 Select 属性来替换关键自动完成组件,以便自动完成与你的应用程序的其他部分保持一致的外观和感觉。

让我们创建一个可重用的 Autocomplete 组件。Select 组件允许你替换自动完成体验的某些方面。特别是,以下是你将替换的组件:

  • 控制:要使用的文本输入组件

  • 菜单:当用户开始输入时显示带有建议的菜单

  • NoOptionsMessage:当没有建议显示时显示的消息

  • Option:用于 菜单 中每个建议的组件

  • Placeholder:文本输入的占位文本组件

  • SingleValue:用于显示已选择的值的组件

  • ValueContainer:包装 SingleValue 的组件

  • IndicatorSeparator:分隔自动完成右侧的按钮

  • ClearIndicator:用于清除当前值的按钮的组件

  • DropdownIndicator:用于显示 菜单 的按钮的组件

这些组件中的每一个都将被替换为改变自动完成外观和感觉的 Material-UI 组件。此外,你将拥有所有这些作为新的 Autocomplete 组件,你可以在整个应用程序中重用它们。

在深入研究每个替换组件的实现之前,让我们看看结果。以下是在屏幕首次加载时你会看到的内容:

如果你点击向下箭头,你会看到一个包含所有值的菜单,如下所示:

尝试在自动完成文本字段中输入 tor,如下所示:

如果你进行选择,菜单将关闭,文本字段将填充所选值,如下所示:

你可以通过打开菜单并选择另一个值来更改你的选择,或者你可以通过点击文本右侧的清除按钮来清除选择。

它是如何工作的...

让我们通过查看组成 Autocomplete 组件的各个组件以及替换 Select 组件的部分来分解源代码。然后,我们将查看最终的 Autocomplete 组件。

文本输入控件

这是 Control 组件的源代码:

const inputComponent = ({ inputRef, ...props }) => (
  <div ref={inputRef} {...props} />
);

const Control = props => (
  <TextField
    fullWidth
    InputProps={{
      inputComponent,
      inputProps: {
        className: props.selectProps.classes.input,
        inputRef: props.innerRef,
        children: props.children,
        ...props.innerProps
      }
    }}
    {...props.selectProps.textFieldProps}
  />
);

inputComponent() 函数是一个组件,它将 inputRef 值(对底层输入元素的引用)传递给 ref 属性。然后,inputComponent 传递给 InputProps 以设置 TextField 使用的输入组件。这个组件有点令人困惑,因为它在传递引用并使用一个 helper 组件来完成这个目的。重要的是要记住,Control 的任务是设置 Select 组件以使用 Material-UI TextField 组件。

选项菜单

这是当用户开始输入或点击向下箭头时显示自动完成选项的组件:

const Menu = props => (
  <Paper
    square
    className={props.selectProps.classes.paper}
    {...props.innerProps}
  >
    {props.children}
  </Paper>
);

Menu 组件渲染一个 Material-UI Paper 组件,以便围绕选项的元素相应地主题化。

没有可用的选项

这是 NoOptionsMessage 组件。当没有自动完成选项可以显示时,它会被渲染,如下所示:

const NoOptionsMessage = props => (
  <Typography
    color="textSecondary"
    className={props.selectProps.classes.noOptionsMessage}
    {...props.innerProps}
  >
    {props.children}
  </Typography>
);

这会渲染一个具有 textSecondary 作为 color 属性值的 Typography 组件。

单个选项

在自动完成菜单中显示的单独选项使用 MenuItem 组件渲染,如下所示:

const Option = props => (
  <MenuItem
    buttonRef={props.innerRef}
    selected={props.isFocused}
    component="div"
    style={{
      fontWeight: props.isSelected ? 500 : 400
    }}
    {...props.innerProps}
  >
    {props.children}
  </MenuItem>
);

selectedstyle 属性根据 isSelectedisFocused 属性改变项目显示的方式。children 属性设置项目的值。

占位文本

Autocomplete 组件的 Placeholder 文本在用户输入任何内容或进行选择之前显示,如下所示:

const Placeholder = props => (
  <Typography
    color="textSecondary"
    className={props.selectProps.classes.placeholder}
    {...props.innerProps}
  >
    {props.children}
  </Typography>
);

Material-UI Typography 组件用于主题化 Placeholder 文本。

SingleValue

再次强调,Material-UI Typography 组件用于在自动完成输入中渲染来自菜单的选中值,如下所示:

const SingleValue = props => (
  <Typography
    className={props.selectProps.classes.singleValue}
    {...props.innerProps}
  >
    {props.children}
  </Typography>
);

ValueContainer

使用 ValueContainer 组件将 SingleValue 组件包裹在一个 div 元素和 valueContainer CSS 类中,如下所示:

const ValueContainer = props => (
  <div className={props.selectProps.classes.valueContainer}>
    {props.children}
  </div>
);

IndicatorSeparator

默认情况下,Select 组件使用管道字符作为自动完成菜单右侧按钮之间的分隔符。由于它们将被 Material-UI 按钮组件替换,因此这个分隔符不再必要,如下所示:

const IndicatorSeparator = () => null;

通过让组件返回 null,不渲染任何内容。

清除选项指示器

此按钮用于清除用户之前所做的任何选择,如下所示:

const ClearIndicator = props => (
  <IconButton {...props.innerProps}>
    <CancelIcon />
  </IconButton>
);

此组件的目的是使用 Material-UI 的 IconButton 组件并渲染 Material-UI 图标。点击处理程序通过 innerProps 传入。

显示菜单指示器

就像 ClearIndicator 组件一样,DropdownIndicator 组件用 Material-UI 的图标替换了显示自动完成菜单的按钮,如下所示:

const DropdownIndicator = props => (
  <IconButton {...props.innerProps}>
    <ArrowDropDownIcon />
  </IconButton>
);

样式

这里是自动完成各个子组件使用的样式:

const useStyles = makeStyles(theme => ({
  root: {
    flexGrow: 1,
    height: 250
  },
  input: {
    display: 'flex',
    padding: 0
  },
  valueContainer: {
    display: 'flex',
    flexWrap: 'wrap',
    flex: 1,
    alignItems: 'center',
    overflow: 'hidden'
  },
  noOptionsMessage: {
    padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`
  },
  singleValue: {
    fontSize: 16
  },
  placeholder: {
    position: 'absolute',
    left: 2,
    fontSize: 16
  },
  paper: {
    position: 'absolute',
    zIndex: 1,
    marginTop: theme.spacing(1),
    left: 0,
    right: 0
  }
}));

自动完成

最后,以下是在整个应用程序中可以重用的 Autocomplete 组件:

export default function Autocomplete(props) {
  const classes = useStyles();
  const [value, setValue] = useState(null);

  return (
    <div className={classes.root}>
      <Select
        value={value}
        onChange={v => setValue(v)}
        textFieldProps={{
          label: 'Team',
          InputLabelProps: {
            shrink: true
          }
        }}
        {...{ ...props, classes }}
      />
    </div>
  );
}

Autocomplete.defaultProps = {
  isClearable: true,
  components: {
    Control,
    Menu,
    NoOptionsMessage,
    Option,
    Placeholder,
    SingleValue,
    ValueContainer,
    IndicatorSeparator,
    ClearIndicator,
    DropdownIndicator
  },
  options: [
    { label: 'Boston Bruins', value: 'BOS' },
    { label: 'Buffalo Sabres', value: 'BUF' },
    { label: 'Detroit Red Wings', value: 'DET' },
    { label: 'Florida Panthers', value: 'FLA' },
    { label: 'Montreal Canadiens', value: 'MTL' },
    { label: 'Ottawa Senators', value: 'OTT' },
    { label: 'Tampa Bay Lightning', value: 'TBL' },
    { label: 'Toronto Maple Leafs', value: 'TOR' },
    { label: 'Carolina Hurricanes', value: 'CAR' },
    { label: 'Columbus Blue Jackets', value: 'CBJ' },
    { label: 'New Jersey Devils', value: 'NJD' },
    { label: 'New York Islanders', value: 'NYI' },
    { label: 'New York Rangers', value: 'NYR' },
    { label: 'Philadelphia Flyers', value: 'PHI' },
    { label: 'Pittsburgh Penguins', value: 'PIT' },
    { label: 'Washington Capitals', value: 'WSH' },
    { label: 'Chicago Blackhawks', value: 'CHI' },
    { label: 'Colorado Avalanche', value: 'COL' },
    { label: 'Dallas Stars', value: 'DAL' },
    { label: 'Minnesota Wild', value: 'MIN' },
    { label: 'Nashville Predators', value: 'NSH' },
    { label: 'St. Louis Blues', value: 'STL' },
    { label: 'Winnipeg Jets', value: 'WPG' },
    { label: 'Anaheim Ducks', value: 'ANA' },
    { label: 'Arizona Coyotes', value: 'ARI' },
    { label: 'Calgary Flames', value: 'CGY' },
    { label: 'Edmonton Oilers', value: 'EDM' },
    { label: 'Los Angeles Kings', value: 'LAK' },
    { label: 'San Jose Sharks', value: 'SJS' },
    { label: 'Vancouver Canucks', value: 'VAN' },
    { label: 'Vegas Golden Knights', value: 'VGK' }
  ]
};

将所有上述组件联系在一起的是传递给 Selectcomponents 属性。实际上,它在 Autocomplete 中被设置为 default 属性,因此可以进一步覆盖。传递给 components 的值是一个简单的对象,将组件名称映射到其实例。

相关内容

选择自动完成建议

在上一节中,你构建了一个能够选择单个值的 Autocomplete 组件。有时,你需要从 Autocomplete 组件中选择多个值。好消息是,通过一些小的添加,上一节中创建的组件已经完成了大部分工作。

如何操作...

让我们逐一查看需要添加以支持 Autocomplete 组件中多值选择的修改,从新的 MultiValue 组件开始,如下所示:

const MultiValue = props => (
  <Chip
    tabIndex={-1}
    label={props.children}
    className={clsx(props.selectProps.classes.chip, {
      [props.selectProps.classes.chipFocused]: props.isFocused
    })}
    onDelete={props.removeProps.onClick}
    deleteIcon={<CancelIcon {...props.removeProps} />}
  />
);

MultiValue 组件使用 Material-UI 的 Chip 组件来渲染选定的值。为了将 MultiValue 传递给 Select,请将其添加到传递给 Selectcomponents 对象中:

components: {
  Control,
  Menu,
  NoOptionsMessage,
  Option,
  Placeholder,
  SingleValue,
  MultiValue,
  ValueContainer,
  IndicatorSeparator,
  ClearIndicator,
  DropdownIndicator
},

现在,你可以使用你的 Autocomplete 组件进行单值选择,或者进行多值选择。你可以在 defaultProps 中添加 isMulti 属性,默认值为 true,如下所示:

isMulti: true,

现在,你应该能够从自动完成中选择多个值。

它是如何工作的...

当首次渲染或显示菜单时,自动完成看起来并没有什么不同。当你做出选择时,Chip 组件用于显示值。Chips 非常适合显示此类小块信息。此外,关闭按钮与它很好地集成,使用户在做出选择后很容易移除单个选择。

这是多次选择后自动完成的样子:

图片

已选择的价值将从菜单中移除。

参见

API 驱动的自动完成

你并不总是能在页面初始加载时准备好自动完成的数据。想象一下在用户能够与任何东西交互之前尝试加载数百或数千个项目。更好的方法是保持数据在服务器上,并提供一个带有自动完成文本的 API 端点。然后你只需要加载 API 返回的较小数据集。

如何实现...

让我们重新整理前一个示例。我们将保留所有相同的自动完成功能,除了,我们不会将数组传递给 options 属性,而是传递一个返回 Promise 的 API 函数。以下是一个模拟 API 调用并解决 Promise 的 API 函数:

const someAPI = searchText =>
  new Promise(resolve => {
    setTimeout(() => {
      const teams = [
        { label: 'Boston Bruins', value: 'BOS' },
        { label: 'Buffalo Sabres', value: 'BUF' },
        { label: 'Detroit Red Wings', value: 'DET' },
        ...
      ];

      resolve(
        teams.filter(
          team =>
            searchText &&
            team.label
              .toLowerCase()
              .includes(searchText.toLowerCase())
        )
      );
    }, 1000);
  });

此函数接受一个搜索字符串参数,并返回一个 Promise。这里过滤的是本应传递给 Select 组件 options 属性的相同数据。将此函数中发生的任何操作视为在真实应用程序中的 API 后面发生。然后,返回的 Promise 在模拟的 1 秒延迟后解决为匹配项的数组。

你还需要将几个组件添加到 Select 组件的组成中(现在我们有 13 个了),如下所示:

const LoadingIndicator = () => <CircularProgress size={20} />;

const LoadingMessage = props => (
  <Typography
    color="textSecondary"
    className={props.selectProps.classes.noOptionsMessage}
    {...props.innerProps}
  >
    {props.children}
  </Typography>
);

LoadingIndicator组件显示在自动完成文本输入的右侧。它使用 Material-UI 中的CircularProgress组件来指示自动完成正在执行某些操作。LoadingMessage组件与示例中用于Select的其他文本替换组件遵循相同的模式。当菜单显示时,会显示加载文本,但解析optionsPromise仍然挂起。

最后,还有Select组件。您需要使用AsyncSelect版本而不是Select,如下所示:

import AsyncSelect from 'react-select/lib/Async';

否则,AsyncSelectSelect的工作方式相同,如下所示:

<AsyncSelect
  value={value}
  onChange={value => setValue(value)}
  textFieldProps={{
    label: 'Team',
    InputLabelProps: {
      shrink: true
    }
  }}
  {...{ ...props, classes }}
/>

它是如何工作的...

Select自动完成和AsyncSelect自动完成的唯一区别在于 API 请求挂起时发生的情况。以下是发生这种情况时自动完成的外观:

图片

随着用户输入,CircularProgress组件将在右侧渲染,同时使用Typography组件在菜单中渲染加载消息。

另请参阅

突出显示搜索结果

当用户在自动完成中开始输入并在下拉菜单中显示结果时,并不总是明显地知道某个项目是如何与搜索条件匹配的。您可以通过突出显示字符串值的匹配部分来帮助用户更好地理解结果。

如何实现...

您将需要使用autosuggest-highlight包中的两个函数来帮助突出显示自动完成下拉菜单中呈现的文本,如下所示:

import match from 'autosuggest-highlight/match';
import parse from 'autosuggest-highlight/parse';

现在,您可以构建一个新的组件,该组件将渲染项目文本,并在必要时进行突出显示,如下所示:

const ValueLabel = ({ label, search }) => {
  const matches = match(label, search);
  const parts = parse(label, matches);

  return parts.map((part, index) =>
    part.highlight ? (
      <span key={index} style={{ fontWeight: 500 }}>
        {part.text}
      </span>
    ) : (
      <span key={index}>{part.text}</span>
    )
  );
};

最终结果是ValueLabel渲染一个由parse()match()函数确定的span元素数组。如果part.highlight为 true,则其中一个 span 将被加粗。现在,您可以在Option组件中使用ValueLabel,如下所示:

const Option = props => (
  <MenuItem
    buttonRef={props.innerRef}
    selected={props.isFocused}
    component="div"
    style={{
      fontWeight: props.isSelected ? 500 : 400
    }}
    {...props.innerProps}
  >
    <ValueLabel
      label={props.children}
      search={props.selectProps.inputValue}
    />
  </MenuItem>
);

它是如何工作的...

现在,当您在自动完成的文本输入中搜索值时,结果将突出显示每个项目中的搜索条件,如下所示:

图片

另请参阅

独立的芯片输入

一些应用程序需要多值输入但没有为用户预定义选择列表。这排除了使用自动完成或 select 组件的可能性,例如,如果您要求用户提供姓名列表。

如何实现...

您可以安装 material-ui-chip-input 包并使用 ChipInput 组件,该组件将 Material-UI 中的 ChipTextInput 组件结合在一起。代码如下:

import React, { useState } from 'react';

import { makeStyles } from '@material-ui/styles';
import ChipInput from 'material-ui-chip-input';

const useStyles = makeStyles(theme => ({
  chipInput: { minWidth: 300 }
}));

export default function StandaloneChipInput() {
  const classes = useStyles();
  const [values, setValues] = useState([]);

  const onAdd = chip => {
    setValues([...values, chip]);
  };

  const onDelete = (chip, index) => {
    setValues(values.slice(0, index).concat(values.slice(index + 1)));
  };

  return (
    <ChipInput
      className={classes.chipInput}
      helperText="Type name, hit enter to type another"
      value={values}
      onAdd={onAdd}
      onDelete={onDelete}
    />
  );
}

当屏幕首次加载时,字段看起来像一个普通的文本字段,您可以在其中输入,如下所示:

图片

如辅助文本所示,您可以按 Enter 添加项目并输入更多文本,如下所示:

图片

您可以随意向字段中添加项目,如下所示:

图片

确保辅助文本提到了回车键。否则,用户可能无法弄清楚他们可以输入多个值。

它是如何工作的...

保存 chip 输入字段值的 state 是一个数组——因为存在多个值。与 chip 输入状态相关的两个操作是从该数组中添加和删除字符串。让我们更详细地看看 onAdd()onDelete() 函数,如下所示:

const onAdd = chip => {
  setValues([...values, chip]);
};

const onDelete = (chip, index) => {
  setValues(values.slice(0, index).concat(values.slice(index + 1)));
};

onAdd() 函数将 chip 添加到数组中,而 onDelete() 函数删除给定 indexchip。当用户点击芯片中的 Delete 图标时,芯片将被删除。最后,让我们看看 ChipInput 组件本身,如下所示:

<ChipInput
  className={classes.chipInput}
  helperText="Type name, hit enter to type another"
  value={values}
  onAdd={onAdd}
  onDelete={onDelete}
/>

它非常类似于 TextInput 组件。它实际上接受相同的属性,例如 helperText。它还接受在 TextInput 中找不到的附加属性,例如 onAddonDelete

参见