iT邦幫忙

2021 iThome 鐵人賽

DAY 28
0
Modern Web

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

【Day28】反饋元件 - Modal

元件介紹

Modal 元件為彈出相關元件提供了重要的基礎建設,如 DialogPopoverDrawer...等等。

參考設計 & 屬性分析

各家元件庫參考

在 Antd 元件當中,對於 Modal 就直接定義為 對話框 元件,其使用時機是當系統流程當中需要用戶處理額外事務,但又不希望跳轉頁面以打斷目前工作流程時,提供一個彈出互動框解決方案。


https://ant.design/components/modal-cn/

但對於 MUI 來說,他就是另一個思維,以下是他對於 Modal 的定義:

The modal component provides a solid foundation for creating dialogs, popovers, lightboxes, or whatever else.

意思就是說,Antd 的 Modal 對於 MUI 來說其實已經是一個 Dialog 元件,他是 Modal 元件的延伸應用。

換句話說,MUI 的 Modal 是一個基礎建設元件,所以類似這種彈窗式互動的元件,例如 對話框(Dialog)彈出提示框(Popovers)菜單(Menu)抽屜(Drawer)...等等元件,都是能夠基於 Modal 來實現的。


https://mui.com/api/modal/

我們再來看 Bootstrap,Bootstrap 裡面的 Modal 也是跟 Antd 一樣,是直接說 Modal 是一個對話視窗:


https://bootstrap5.hexschool.com/docs/5.0/components/modal/

我們簡單看了上述幾種說詞之後,我覺得 MUI 這樣的思維還是比較吸引我,畢竟這樣的方式比較能夠重複利用相同邏輯的程式碼。

就像 MUI 所提到的,我們想想看,其實很多元件是換湯不換藥,明明邏輯是一樣的,為什麼就只因為樣式不一樣我們就要把同樣的邏輯再重新做一次呢?

所以今天我們想要演示的題目,就是我們先來做一個簡單的 Modal,再來,我們會利用這個 Modal 來打造我們的 Dialog。

使用方式

接下來我們來看一下各家元件庫是怎麼設計元件的使用介面,首先來看 Antd:

<Button type="primary" onClick={showModal}>
  Open Modal
</Button>
<Modal
  title="Basic Modal"
  visible={isModalVisible}
  onOk={handleOk}
  onCancel={handleCancel}
>
  <p>Some contents...</p>
  <p>Some contents...</p>
  <p>Some contents...</p>
</Modal>

再來我們也看一下 MUI:

<Button onClick={handleOpen}>Open modal</Button>
<Modal
  open={open}
  onClose={handleClose}
  aria-labelledby="modal-modal-title"
  aria-describedby="modal-modal-description"
>
  <Box sx={style}>
    <Typography id="modal-modal-title" variant="h6" component="h2">
      Text in a modal
    </Typography>
    <Typography id="modal-modal-description" sx={{ mt: 2 }}>
      Duis mollis, est non commodo luctus, nisi erat porttitor ligula.
    </Typography>
  </Box>
</Modal>

在 Antd 當中,他的介面可以成為我們 Dialog 元件的參考,MUI 的 Modal props 介面應該就足夠做一個基礎建設,綜合以上的介面,一個最簡易的 Modal 應該是可以長這樣:

<Button onClick={handleOpen}>Open Modal</Button>
<Modal
  isOpen={isOpen}
  onClose={handleClose}
>
  {children}
</Modal>

上述的介面當中,isOpen 這個 props 來控制 Modal 出現還是消失,而畫面上必需要有一個地方可以觸發 isOpen 變成 true,這邊的範例統一都是用一顆 Button 來觸發。再來,要關閉一個 Modal 的時候,我們可以點擊 children 以外的區域,或是將來變成 Dialog 的時候,可以點擊 Header 的 <CloseIcon />,所以在 Modal 裡面,除了 children 以外,給他 isOpenonClose 就能夠滿足最低限度的需要了。

介面設計

Modal props

屬性 說明 類型 默認值
isOpen 是否顯示 boolean false
children 內容 ReactNode
onClose 觸發關閉 function
animationDuration 定義動畫完成一次週期的時間(ms) number 200
hasMask 是否顯示遮罩 boolean true

Dialog props

屬性 說明 類型 默認值
isOpen 是否顯示 boolean false
title 標題內容 function
children 內容 ReactNode
onClose 觸發關閉 function
onSubmit 觸發確認事件 function

元件實作

在前幾篇的 Drawer 當中,其實同樣的邏輯我們已經做過一次,所以請原諒我這次當個壞寶寶,做個錯誤示範,我就直接把他抄過來。

如果你今天想要當一個乖寶寶,那應該怎麼做呢?假設時光倒流,我們應該是需要先做出 Modal 這個基礎建設元件,然後再來利用 Modal 來實現出我們的 Drawer 元件,這樣就不會出現同樣的邏輯重複出現在 Modal 和 Drawer 了。

但沒關係,這個風風雨雨的社會,浪子有一天還是有機會回頭,雖然以前做壞,但現在要做一個善良的歹囝,薰莫閣食,酒袂閣焦(寫 code 寫到唱起來 XDD)。

等一下我們還是會示範一下怎麼當好寶寶,我們會把 Modal 用來實現 Dialog

Modal

以下就是我們這次的 Modal:

const Modal = ({
  isOpen,
  onClose,
  animationDuration,
  children,
  hasMask,
}) => {
  const [removeDOM, setRemoveDOM] = useState(!isOpen);

  useEffect(() => {
    if (isOpen) {
      setRemoveDOM(false);
    } else {
      setTimeout(() => {
        setRemoveDOM(true);
      }, (animationDuration + 100));
    }
  }, [animationDuration, isOpen]);

  return !removeDOM && (
    <Portal>
      {hasMask && (
        <Mask
          $isOpen={isOpen}
          $animationDuration={animationDuration}
          onClick={onClose}
        />
      )}
      <ModalWrapper
        $isOpen={isOpen}
      >
        {children}
      </ModalWrapper>
    </Portal>
  );
};

因為之前在 Drawer 有詳細說明過,這次就簡單來複習。

我們的 Modal 一樣也是全版蓋在畫面上,所以為了避免被蓋住的問題,我們一樣用 Portal 把他傳送到最上層。

再來 Portal 的內容當中,會有 Mask 以及 ModalWrapper,其中 ModalWrapper 裡面就是放我們的 children,也就是對話窗的內容。

另外因為前面 MUI 有說,Popover、Menu 也是有機會可以使用 Modal 來實現,而這兩個元件他是沒有 Mask 遮罩的,所以我們用一個 hasMask 的 boolean 來決定是不是要使用遮罩來弱化背景。

最後我們使用 removeDOM 這個 boolean 幫助我們在關閉 Modal 之後的 100ms,也就是 Modal 消失動畫結束之後,我們把這個被關閉的元件從 DOM 當中移除掉。

那這樣我們就能夠做出像下面這樣的 Modal 了:

import Button from '../components/Button';
import Modal from '../components/Modal';

const ModalDemo = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setIsOpen(true)}>Open Modal</Button>
      <Modal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
      >
        <div style={{ background: '#FFF' }}>Modal content</div>
      </Modal>
    </>
  );
};

基於 Modal 實現的 Dialog

先給大家看一下我們要實現的 Dialog 長這樣,這邊我是以 Antd 的 Basic Modal 樣式為例:

這個 Dialog 主要分成三大部分,HeaderContentFooter

Header 當中我們需要顯示標題,也需要有一個叉叉按鈕讓我們可以關閉對話框。

const Header = ({ title, onClose }) => (
  <HeaderWrapper>
    {title}
    <CloseButton onClick={onClose}>
      <CloseIcon />
    </CloseButton>
  </HeaderWrapper>
);

Content 的部分就是我們對話框的內容,這邊就直接顯示 children。

Footer 主要是兩個按鈕,一個是確認按鈕,一個取消按鈕。

const Footer = ({ onClose, onSubmit }) => (
  <FooterWrapper>
    <ButtonGroup>
      <Button variant="outlined" onClick={onClose}>取消</Button>
      <Button onClick={onSubmit}>確認</Button>
    </ButtonGroup>
  </FooterWrapper>
);

所以我們基於 Modal 的 Dialog 按照上述的描述就長這樣了:

import Modal from '../Modal';
import Header from './Header';
import Footer from './Footer';

const Dialog = ({
  isOpen,
  onClose,
  onSubmit,
  title,
  children,
}) => (
  <Modal
    isOpen={isOpen}
    onClose={onClose}
  >
    <DialogWrapper $isOpen={isOpen}>
      <Header title={title} onClose={onClose} />
      <Content>
        {children}
      </Content>
      <Footer onClose={onClose} onSubmit={onSubmit} />
    </DialogWrapper>
  </Modal>
);

特別說明一下 DialogWrapper 是我們對話框的背景,主要是做一些樣式的設定以及佈局。

樣式的部分例如對話框的 背景顏色寬度圓角陰影 以及 出現及消失的動畫

const DialogWrapper = styled.div`
  width: calc(100vw - 40px);
  max-width: 520px;
  border-radius: 4px;
  background: #FFF;
  box-shadow: 0 3px 6px -4px #0000001f, 0 6px 16px #00000014, 0 9px 28px 8px #0000000d;
  animation: ${(props) => (props.$isOpen ? showDialog : hideDialog)} 200ms ease-in-out forwards;
`;

動畫的部分,出現的時候我希望他可以有淡入的效果,並且讓他微微從小變大,好像有從距離遠的地方服出來的感覺。消失的時候就反過來,讓他有淡出效果,並且讓他微微縮小,有種退到後面去感覺:

import styled, { keyframes } from 'styled-components';

const hideDialog = keyframes`
  0% {
    transform: scale(1);
    opacity: 1;
  }
  100% {
    transform: scale(0.9);
    opacity: 0;
  }
`;

const showDialog = keyframes`
  0% {
    transform: scale(0.9);
    opacity: 0;
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
`;

最後來展示我們用 Modal 實現 Dialog 的成果:

const DialogDemo = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setIsOpen(true)}>Open Dialog</Button>
      <Dialog
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title={(
          <div style={{ fontWeight: 500 }}>
            Title
          </div>
        )}
      >
        <div>
          <div>Some contents...</div>
          <div>Some contents...</div>
          <div>Some contents...</div>
        </div>
      </Dialog>
    </>
  );
};


Modal 元件原始碼:
Source code

Dialog 元件原始碼:
Source code

Storybook:
Modal


上一篇
【Day27】反饋元件 - Progress circle
下一篇
【Day29】反饋元件 - Toast
系列文
30 天擁有一套自己手刻的 React UI 元件庫30

尚未有邦友留言

立即登入留言