【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


<<:  Day 11 Generics Part 1

>>:  #10-帮网页加上黑暗模式!日夜开关(CSS变数&Media Query)

Day30 Redux基础练习

以下用to do list作为练习。 Actions Action是一般的JavaScript物件。...

[DAY7]从0开始装k8s(2)-k0s

k0s k0s是Mirantis推出的轻量化Kubernetes发行版,Mirantis有一个k8s...

why

大家好,我想介绍一下自己为什麽会认识spring boot,因为写後端API的时候会用到的框架 然後...