【Day19】导航元件 - Dropdown

元件介绍

Dropdown 是一个下拉选单元件,当页面上的选项过多时,可以用这个元件来收纳选项,透过滑鼠事件来触发选单弹出,点击选项会执行相对应的命令。

参考设计 & 属性分析

我们来观察一下 Material-UI 的 Menu 元件以及 Antd 的 Dropdown 元件,我们可以发现有异曲同工之妙的事是,弹出的视窗在程序码里面看起来是写在触发按钮的旁边,但实际上却是被 portal 到外面跟 <div id="root" /> 同一层级,跟我们之前分析过的 Tooltip 是一样的做法。

Material-UI Menu component

Antd Dropdown component

接着我们来观察他们的实现方式,以 MUI 来说,下面是他范例的程序码:

export default function MuiMenu() {
  const [anchorEl, setAnchorEl] = React.useState(null);

  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <div>
      <Button aria-controls="simple-menu" aria-haspopup="true" onClick={handleClick}>
        MUI Menu dropdown
      </Button>
      <Menu
        id="simple-menu"
        anchorEl={anchorEl}
        keepMounted
        open={Boolean(anchorEl)}
        onClose={handleClose}
      >
        <MenuItem onClick={handleClose}>Profile</MenuItem>
        <MenuItem onClick={handleClose}>My account</MenuItem>
        <MenuItem onClick={handleClose}>Logout</MenuItem>
      </Menu>
    </div>
  );
}

把 Menu 透过 portal 的方式 render 到外层的核心方法,从程序码里面可以看出端倪。为了让 Menu 弹窗可以知道触发按钮的位置,这边的做法是在触发按钮 onClick 的时候把 event.currentTarget 存进 state,并且当作 props 传入 Menu 的 anchorEl 里面。因此,Menu 就能够透过 anchorEl 来取得触发按钮的位置并能够跟他对齐。

接着我们来看一下 Antd Dropdown 的范例程序码:

const menu = (
  <Menu>
    <Menu.Item key="0">
      <a href="https://www.antgroup.com">1st menu item</a>
    </Menu.Item>
    <Menu.Item key="1">
      <a href="https://www.aliyun.com">2nd menu item</a>
    </Menu.Item>
    <Menu.Divider />
    <Menu.Item key="3">3rd menu item</Menu.Item>
  </Menu>
);

const AntdDropdown = () => {
  return (
    <Dropdown overlay={menu} trigger={['click']}>
      <a className="ant-dropdown-link" onClick={e => e.preventDefault()}>
        Antd Dropdown <DownOutlined />
      </a>
    </Dropdown>
  );
};

在程序码中我们可以发现,触发按钮已经是 Dropdown 的 children element 了,并且要弹出的 menu 也透过 overlay 这个 props 传进去 Dropdown 这个元件。

因此,在 Dropdown 这个元件里面我们已经取得所有需要的资讯,我们可以直接取得 children element 的所在位置,所以把 menu 元件 portal 到外层之後,就能够透过这个位置来找到 children element 并对他对齐定位。

菜单是否显示

菜单是否显示通常会用一 boolean 的 props 来决定,MUI 这边叫做 open,而 Antd 则叫做 visible。

菜单弹出位置

Antd 这边提供一个属性 placement 来决定菜单弹出之後要对齐的位置,分别是 bottomLeft, bottomCenter, bottomRight, topLeft, topCenter, topRight。虽然从 Dropdown 这个名称来看,元件给人的感觉是向下弹出,但是假设我们的菜单在画面偏下面,弹出的菜单很可能就会超出视窗而无法点击,同样的,太右边或太左边的弹穿也需要做相对应的对齐才能够避免超出视窗被遮盖。

菜单内容

若我们参考 Antd 的介面,overlay 是一个让我们可以传入菜单内容的 props,传入的类型为 ReactNode。我会希望在设计 Dropdown 元件的时候,把 menu 跟弹窗这两个功能切割开来,也就是我们不特别去限制 Dropdown 的 menu 一定要是什麽,而是希望透过 props 来决定。

理由是,我们如果有常常在使用 Dropdown 就会发现,会用到 Dropdown 的情境会有蛮多的,有些菜单是单选的一层选单,有些会有两层,有些还会有 input box 在里面提供你下关键字来筛选下面众多的选项,各式各样的内容都有,如果我们绑死菜单的样式在弹窗这个功能上面,那势必每当菜单有调整的时候,我们连原本不用动的弹窗功能都要再做一次,所以我自己的经验会是希望弹窗归弹窗,内容归内容,这样我们就能够复用弹窗功能来适应不同的菜单选择内容。

Disabled

有些状况我们会需要禁用 Dropdown,例如 menu 的内容若是从 API 取得的,那我们有可能会希望完全载入之後再开放让使用者点开,藉此来避免一些非预期的错误。

介面设计

属性 说明 类型 默认值
isOpen 是否显示菜单 boolean false
isDisabled 是否禁用 boolean false
children 触发元件 ReactNode
overlay 菜单内容 ReactNode
placement 菜单弹出位置 bottomLeft, bottomCenter, bottomRight, topLeft, topCenter, topRight bottomCenter

元件实作

我们直接来看我们的 Dropdown 元件用法:

const DropdownDemo = () => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <Dropdown
      isOpen={isOpen}
      onClick={() => setIsOpen(true)}
      onClose={() => setIsOpen(false)}
      overlay={(
        <div>menu</div>
      )}
    >
      <Button
        style={{ borderRadius: 4 }}
        variant="outlined"
      >
        Dropdown
      </Button>
    </Dropdown>
  );
};

children 可以是一个 React element,这样我们就能够让各种元件上面都可以 dropdown。
overlay 则是我们弹出菜单的内容,也是一个 React element。
然後弹出的控制项目 isOpen, onClick, onClose 则由外部来控制,这样就是我们 Dropdown 元件大致上的介面雏形。

弹出菜单

要让 Dropdown 弹出菜单,这样的功能是不是很眼熟呢?没错,其实我们几乎是可以用先前提到的 Tooltip 原理来实作。

下面是我们 Dropdown 实作的长相,其实跟我们 Tooltip 几乎是 87% 像:

<>
  <span
    role="presentation"
    ref={childrenRef}
    data-dropdown-id="dropdown"
    onClick={onClick}
  >
    {children}
  </span>
  <Portal>
    <OverlayWrapper
      data-dropdown-id="dropdown"
      $isOpen={isOpen}
      $position={position}
      $placement={placement}
      $childrenSize={childrenSize}
      $gap={12}
    >
      {overlay}
    </OverlayWrapper>
  </Portal>
</>

我们一样用我们自制的 <Portal /> 把菜单渲染到父层上面,然後跟 Tooltip 篇一样的做法,我们能够决定菜单弹出的位置,一样是用 placement 这个 props,传入的值也是一样的,有 12 种位置选项:

'top', 'top-left', 'top-right',
'bottom', 'bottom-left', 'bottom-right',
'left-top', 'left', 'left-bottom',
'right-top', 'right', 'right-bottom',

这边随意挑出三种位置来展示,一样的做法就不再重复说明,可以直接看程序码以及先前的 Tooltip 篇的介绍:

今天最主要想要跟大家分享的是点击 children 可以弹出视窗,点击 children 以及菜单以外的部分会关闭菜单 的做法。

开启菜单的点击事件,我们就是对 children 使用 onClick 事件的触发,onClick 触发的时候就开启菜单。

再来就是我们这次的关键步骤,在点击 Dropdown 范围以外的地方要关闭菜单。
首先我们要对这个画面设置监听点击的事件:

useEffect(() => {
  document.addEventListener('click', handleOnClick);
  return () => {
    document.removeEventListener('click', handleOnClick);
  };
}, [handleOnClick]);

在点击的时候我们要知道点击的地方是不是 Dropdown 的范围,所以我使用的方法就是在 children 以及 overlay 上面绑定 data-* attribute,当作一个标记,我这边先简单做,给他一个 data-dropdown-id="dropdown"

<>
  <span
    {...省略其他 props}
    data-dropdown-id="dropdown"
    onClick={onClick}
  >
    {children}
  </span>
  <Portal>
    <OverlayWrapper
      {...省略其他 props}
      data-dropdown-id="dropdown"
    >
      {overlay}
    </OverlayWrapper>
  </Portal>
</>

所以我在点击的时候,我只要去检查我点击的元件上面有没有这个 data atrribute,就能够知道我是不是点击在 Dropdown 范围内了:

const handleOnClick = useCallback((event) => {
  const dropdownId = findAttributeInEvent(event, 'data-dropdown-id');
  if (!dropdownId) {
    onClose();
  }
}, [onClose]);

到目前为止我们的 Dropdown 功能已经有了,下一篇我们会来介绍 Select,会使用我们今天实作的 Dropdown 元件来实现,让我们的 Dropdown 可以应用在选单上,敬请期待罗!


Dropdown 元件原始码:
Source code

Storybook:
Dropdown


<<:  [Day 19 - React] 现在开始用框架写网页 — React

>>:  17. PHPer x Code Quality Tool

【Day 29】 从知道到做到:方法我都知,但要怎麽做到?完成目标三阶段的好书推荐

大家好,铁人赛走到 29 天,也快要告一个段落了,是时候来做个小结和收尾。 这次的主题订做:半路出家...

DAY4 将专案资料夹推至 GitHub

昨天已经有完成了在本地端新增了专案资料夹,於是我们开始新增 index.html 让我们的首页出生吧...

[第27天]理财达人Mx. Ada-BETA指标

前言 本文说明使用TA-Lib函式库计算BETA指标。 BETA指标 BETA指标,一种风险指数,表...

从零开始的8-bit迷宫探险【Level 17】稻草人也想要智慧大脑,给怪物一点灵魂跟一点点个性

「我们不能漫无目的地追,要拟定包夹计画!」Rain 大声地说,并展露出大哥是对的姿态。 「我去魔幻...

[DAY24] Boxenn Use Case 的 error handle

Boxenn 的 error handling Boxenn 的实作可以在这边复习。 Use Cas...