iT邦幫忙

2021 iThome 鐵人賽

DAY 19
0
Modern Web

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

【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


上一篇
【Day18】導航元件 - Breadcrumb
下一篇
【Day20】導航元件 - Select
系列文
30 天擁有一套自己手刻的 React UI 元件庫30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言