iT邦幫忙

2021 iThome 鐵人賽

DAY 13
0
Modern Web

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

【Day13】數據展示元件 - Accordion/Collapse 摺疊面板

元件介紹

Accordion 是一個可折疊/展開內容區域的元件。主要是針對顯示內容複雜或很多的頁面進行分區塊的顯示及隱藏。

參考設計 & 屬性分析

元件命名

可折疊的面版看起來名稱目前是還有一些討論空間。在 MUI 中,原本的元件是叫做 ExpansionPanel,但之後官網宣稱元件要改成 Accordion 這個更通用的命名。

The ExpansionPanel component was renamed to Accordion to use a more common naming convention.

Antd 上面可折疊面版是叫做 Collpase,但是提供一個 Accordion 模式(手風琴模式),由 accordion 這個 boolean props 來決定,手風琴模式就是說,在這個折疊面版 group 當中,一次只顯示一個,所以如果打開另外一個,原本的這一個就會關閉。

w3school 上面也有教你怎麼做折疊面版,但是 Collapse/Accordion 兩個名詞在上面看起來是一樣的東西。
How To - Collpase: https://www.w3schools.com/howto/howto_js_collapsible.asp
How To - Collapsibles/Accordion: https://www.w3schools.com/howto/howto_js_accordion.asp

Bootstrap 5.0 上面也是 Collpase 和 Accordion 兩者都有,但是有一些區別,
Bootstrap 指的 Collpase 不一定是像手風琴樣式的元件,而是如下圖一個簡單的 Button 點擊事件來觸發區塊收合的都叫做 Collpase

https://getbootstrap.com/docs/5.0/components/collapse/

而 Accordion 就是如同上述其他 Library 一樣的那種手風琴的折疊樣式:

https://getbootstrap.com/docs/5.0/components/accordion/

目前為止這樣看下來,Bootstrap 的命名方式是我覺得比較有道理的,如果要用 Accordion 這個名稱,除了「可折疊」這個行為要符合,他的外型也需要是「手風琴的形象」;否則如果是 Collpase 這個名詞,他只有描述到「可折疊」這個部分,並沒有描述到他的外型,所以其實其他形狀的可折疊元件,要叫做 Collpase 也不為過。

區塊是否展開

expanded 這個 boolean props 可以讓我們單獨控制各個區塊是否展開或折疊,MUI 也是透過這個 props 來做到是否需要打開一個區塊就關閉其他區塊,以下列程式碼示意:

const [expanded, setExpanded] = React.useState('panel1');


<div>
  <Accordion expanded={expanded === 'panel1'} onChange={() => setExpanded('panel1')}>
    {panel1}
  </Accordion>
  <Accordion expanded={expanded === 'panel2'} onChange={() => setExpanded('panel2')}>
    {panel2}
  </Accordion>
  <Accordion expanded={expanded === 'panel3'} onChange={() => setExpanded('panel3')}>
    {panel3}
  </Accordion>
</div>

展開的過場動畫

區塊是否顯現最簡單的初階做法就是用一個 boolean props 讓他「啪、啪」的直接顯示和折疊

<div>
  <AccordionHeader />
  {expanded && <AccordionDetails />}
</div>

那假設我們需要過場動畫,我們應該怎麼做呢?這邊我們可以直接參考 w3school 的原始碼:

<style>
.accordion {
  // some styling...
  transition: 0.4s;
}

.panel {
  // some styling...
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.2s ease-out;
}
</style>
<button class="accordion">Section 1</button>
<div class="panel">
  <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
</div>

<script>
var acc = document.getElementsByClassName("accordion");
var i;

for (i = 0; i < acc.length; i++) {
  acc[i].addEventListener("click", function() {
    this.classList.toggle("active");
    var panel = this.nextElementSibling;
    if (panel.style.maxHeight) {
      panel.style.maxHeight = null;
    } else {
      panel.style.maxHeight = panel.scrollHeight + "px";
    } 
  });
}
</script>

https://www.w3schools.com/howto/tryit.asp?filename=tryhow_js_accordion_animate

簡單的說明原理,這邊的關鍵是透過 JavaScript 來決定 panel 的 css max-height,也就是其展開的高度,若是 panel 展開的時候,以上述的例子是希望 panel 不要有 scrollbar,因此把 max-height 設為跟 panel.scrollHeight 一樣的高度;如果 panel 的高度是 0px ,則是折疊起來,過場的動畫則是搭配 css transition 來實現。折疊起來的時候,由於可視範圍的高度是 0px ,是小於內容的高度,因此會有 overflow 的問題,這邊是把 css overflow 的樣式設為 hidden 就可以了。

Accordion Header / Panel

這疊面版大致上分為兩個區塊,一個是可點擊的 Header 區塊,一個是 Panel 區塊。Header 區塊其實我們多看幾個範例就可以發現,有時候有的 Header 有箭頭,有的沒有箭頭,有箭頭的 Header 其箭頭的位置也不是很固定,有時候在最左邊,有時候在最右邊,有時候在文字的右邊旁邊。所以 Header 的設計我會希望他是傳入一個 ReactNode 的 props,這樣這個元件就不會去限制住他的樣式。

所以如果簡單來做的話,我想像中的 Accordion 元件大概會是長這樣:

<Accordion
  header={<div>This is panel header</div>}
  expanded={expanded}
  onChange={handleChange}
>
  {panel}
</Accordion>

但是更進階一點,我們也可以學習 Antd 的這種方式

<Collapse defaultActiveKey={['1']} onChange={callback}>
  <Panel header="This is panel header 1" key="1">
    <p>{text}</p>
  </Panel>
  <Panel header="This is panel header 2" key="2">
    <p>{text}</p>
  </Panel>
  <Panel header="This is panel header 3" key="3">
    <p>{text}</p>
  </Panel>
</Collapse>

這樣的好處就是說,我不用逐一的在每個 Panel 上面傳入 expanded 和 onChange,而是把這兩個 props 提升到 parent component,由父層來控制子層的行為,子層的 Panel 只需要加上 key 來讓父層辨識就可以了。

其實要這樣做也是還蠻不錯的,但一開始我們先簡單來做,之後再慢慢調整也是可以的。

介面設計

屬性 說明 類型 默認值
isExpand 是否展開 boolean
onClick 標題的點擊事件 func
header 標題 ReactNode
children 可被收合的 panel 內容 ReactNode
className 客製化樣式 string

元件實作

以下是我們設計的 Accordion 結構:

const Accordion = ({
  header, children,
  isExpand, onClick, className,
}) => (
  <StyledAccordion className={className}>
    <Header isExpand={isExpand} onClick={onClick} header={header} />
    <Panel isExpand={isExpand} panel={children} />
  </StyledAccordion>
);

首先直接來講 Panel 收合的核心原理,前面已經仔細說明,是透過 max-height 來決定 panel 是否收合,當 max-height 是 0 的時候,就是收起來,當 max-height 等於內容高度的時候,就是展開。

為了做到這個效果,我們必須要拿到 panel 的高度,如下程式碼,我們是用 useRef 來取得:

const Panel = ({ panel, isExpand }) => {
  const panelRef = useRef(null);
  const scrollHeight = panelRef.current?.scrollHeight;

  return (
    <StyledPanel
      ref={panelRef}
      className="accordion__panel"
      $maxHeight={isExpand ? scrollHeight : 0}
    >
      {panel}
    </StyledPanel>
  );
};

並且透過 transition 來幫我們做到一些動畫效果。記得在收合的時候,要把超出範圍的內容設為 overflow: hidden;

const StyledPanel = styled.div`
  max-height: ${(props) => props.$maxHeight}px;
  overflow: hidden;
  transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
`;

Header

Header 的部分主要是要呈現外部傳進來的內容、處理點擊事件、以及可以做一些箭頭變化的效果

const Header = ({ header, isExpand, onClick }) => (
  <StyledHeader
    className="accordion__header"
    onClick={onClick}
  >
    {header}
    <ExpandIcon $isExpand={isExpand} className="accordion__header-expand-icon">
      <ArrowDownIcon style={{ fill: '#333333' }} />
    </ExpandIcon>
  </StyledHeader>
);

特別講一下這個旋轉的箭頭,是根據是否收合的 boolean 值 isExpand 來決定他旋轉的角度,旋轉的方式是用 transform: rotate(180deg);,記得加上 transition 讓他有過場的效果:

const ExpandIcon = styled.div`
  {/*...省略其他樣式...*/}
  transform: rotate(${(props) => (props.$isExpand ? 180 : 0)}deg);
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
`;

到目前為止我們已經完成了一個簡單的 Accordion 元件了,很陽春,沒有樣式,只有收合動畫,因為我希望收合功能跟樣式不要綁死在一起,如果需要樣式,可以用這個元件再去加工:

客製化樣式

我在 header 及 panel 都保留了可以客製化樣式的彈性,因此我們可以很容易透過 css class 來調整各別的樣式。

以 header 來說,我透過再包一層 styled-component 來覆寫 header 樣式:

const StyledAccordion = styled(Accordion)`
  border: none;
  .accordion__header {
    background: #587cb028;
    padding: 16px;
  }
`;

可以做到這樣的效果,是因為我們有在 header 的地方留下這個 className:

const Header = ({ header, isExpand, onClick }) => (
  <StyledHeader
    className="accordion__header"
    {...props}
  >
    {header}
  </StyledHeader>
);

而 Panel 的樣式,因為我們是把他整個當作 children 塞進去,所以就可以直接在外面處理樣式,成果如下:

手風琴模式,Show single accordion

這邊簡單做一個手風琴模式的範例,也就是一次只展開一個 panel。
主要的原理是點擊的時候我們用 useState 記錄下點擊的是哪一個 panel 的 key,然後如果 key 有對應到的話就展開,沒有對應到的話就收合:

const ShowSingleAccordionDemo = (args) => {
  const [activeKey, setActiveKey] = useState(false);

  return (
    <AccordionGroup>
      {
        [...Array(4).keys()].map((key) => (
          <StyledAccordion
            key={key}
            {...args}
            header={`header__${key + 1}`}
            isExpand={activeKey === key}
            onClick={() => {
              if (activeKey === key) {
                setActiveKey('');
              } else {
                setActiveKey(key);
              }
            }}
          >
            <Panel>
              Lorem Ipsum is ......
            </Panel>
          </StyledAccordion>
        ))
      }
    </AccordionGroup>
  );
};

以上就是我們今天的成果啦!


Accordion 元件原始碼:
Source code

Storybook:
Accordion


上一篇
【Day12】數據展示元件 - Tooltip
下一篇
【Day14】數據展示元件 - Card
系列文
30 天擁有一套自己手刻的 React UI 元件庫30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
no5110
iT邦新手 5 級 ‧ 2023-09-15 14:40:57

多謝清楚的介紹

我要留言

立即登入留言