使用React手寫一個手風琴元件

夕水發表於2022-07-17

知識點

  • emotion語法
  • react語法
  • css語法
  • typescript型別語法

效果

讓我們來看一下我們實現的效果圖:

結構分析

根據上圖,我們來分析一下,一個手風琴元件應該包含一個手風琴容器元件和多個手風琴子元素元件。因此,假設我們實現好了所有的邏輯,並寫出使用demo,那麼程式碼應該如下:

<Accordion defaultIndex="1" onItemClick={console.log}>
   <AccordionItem label="A" index="1">
     Lorem ipsum
   </AccordionItem>
   <AccordionItem label="B" index="2">
      Dolor sit amet
   </AccordionItem>
</Accordion>

根據以上的結構,我們可以得知,首先容器元件Accordion會暴露一個defaultIndex屬性以及一個onItemClick事件。顧名思義,defaultIndex代表預設展開的子元素元件AccordionItem的索引,onItemClick代表點選每一個子元素元件所觸發的事件。然後,我們可以看到子元素元件有label屬性和index屬性,很顯然,label代表當前子元素的標題,index代表當前子元素元件的索引值,而我們的Lorem ipsum就是子元素的內容。根據這些分析,我們先來實現一下AccordionItem元件。

AccordionItem子元件

首先我們定義好子元件的結構,函式元件寫法如下:

const AccordionItem = (props) => {
   //返回元素
};

子元素元件分成三個部分,一個容器元素,一個標題元素和一個內容元素,因此我們可以將結構寫成如下:

<div className="according-item-container">
   <div className="according-item-header"></div>
   <div className="according-item-content"></div>
</div>

知道了結構之後,我們就知道props會有哪些屬性,首先是索引index屬性,它的型別為string 或者number,然後是判斷內容是否展開的屬性isCollapsed,它的型別是布林值,其次我們還有渲染標題的屬性label,它應該是一個react節點,型別為ReactNode,同理,還有一個內容屬性即children,型別也應該是ReactNode,最後就是我們要暴露的事件方法handleClick,它的型別應該是一個方法,因此我們可以定義如下的介面:

interface AccordionItemType {
  index: string | number;
  label: string;
  isCollapsed: boolean;
  //SyntheticEvent代表react合成事件物件的型別
  handleClick(e: SyntheticEvent): void;
  children: ReactNode;
}

介面定義好之後,接下來我們就在介面裡面拿值(採用物件解構的方式),這些值都算是可選的,即:

const { label, isCollapsed, handleClick, children } = props;

此時我們的AccordionItem子元件應該是如下:

const AccordionItem = (props: Partial<AccordionItemType>) => {
  const { label, isCollapsed, handleClick, children } = props;
  return (
    <div className={AccordionItemContainer} onClick={handleClick}>
      <div className={AccordionItemHeader}>{label}</div>
      <div
        aria-expanded={isCollapsed}
        className={`${AccordionItemContent}${
          isCollapsed ? ' collapsed' : ' expanded'
        }`}
      >
        {children}
      </div>
    </div>
  );
};

這裡我們可以使用emotion/css來寫css類名樣式,程式碼如下:

const baseStyle = css`
  line-height: 1.5715;
`;
const AccordionItemContainer = css`
  border-bottom: 1px solid #d9d9d9;
`;
const AccordionItemHeader = cx(
  baseStyle,
  css`
    position: relative;
    display: flex;
    flex-wrap: nowrap;
    align-items: flex-start;
    padding: 12px 16px;
    color: rgba(0, 0, 0, 0.85);
    cursor: pointer;
    transition: all 0.3s, visibility 0s;
    box-sizing: border-box;
  `,
);


const AccordionItemContent = css`
  color: #000000d9;
  background-color: #fff;
  border-top: 1px solid #d9d9d9;
  transition: all 0.3s ease-in-out;
  padding: 16px;
  &.collapsed {
    display: none;
  }
  &.expanded {
    display: block;
  }
`;

以上的css後面跟模板字串再跟css樣式就是emotion/css語法,cx也就是組合樣式寫法,樣式都是常規的寫法,也沒什麼好說的。這裡有一個難點,那就是display:none和display:block沒有過渡效果,因此可以採用visibility:hidden和opacity:0的方式來替換,但是這裡為了簡單,沒考慮動畫效果,所以也就將問題放著,後面有時間再優化。

到目前為止,這個子元件就算是完成了,這也就意味著我們的手風琴元件已經完成一半了,接下來我們來看容器元件Accordion的寫法。

Accordion容器元件

首先我們先把結構寫好:

const Accordion = (props) => {
  //後續程式碼
};

我們再來分析一下需要傳給Accordion元件的屬性有哪些,很顯然有defaultIndex,onItemClick和children,因此我們可以定義如下的介面:

interface AccordionType {
  defaultIndex: number | string;
  onItemClick(key: number | string): void;
  children: JSX.Element[];
}

注意這裡的children不應該是ReactNode,而是JSX.Element元素陣列,這是為什麼呢,我們後面再來解釋這個問題。現在我們知道了props的屬性之後,我們可以拿到這些屬性,程式碼如下:

const Accordion = (props:Partial<AccordionType>) => {
  const { defaultIndex, onItemClick, children } = props;
  //後續程式碼
};

現在我們再維護一個狀態,用來代表當前顯示的子元素元件的索引,使用useState hook函式,初始化預設值就應該是defaultIndex。如下:

const Accordion = (props:Partial<AccordionType>) => {
  const { defaultIndex, onItemClick, children } = props;
  //新增的程式碼
  const [bindIndex, setBindIndex] = useState(defaultIndex);
  //後續程式碼
};

接下來,我們編寫好容器元素,並寫好樣式,如下所示:

const Accordion = (props: Partial<AccordionType>) => {
  const { defaultIndex, onItemClick, children } = props;
  const [bindIndex, setBindIndex] = useState(defaultIndex);
  return (
    <div className={AccordionContainer}></div>
  );
};

容器元素的樣式如下:

const baseStyle = css`
  line-height: 1.5715;
`;
const AccordionContainer = cx(
  baseStyle,
  css`
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: #000000d9;
    font-size: 14px;
    background-color: #fafafa;
    border: 1px solid #d9d9d9;
    border-bottom: 0;
    border-radius: 2px;
  `,
);

好的,接下來,我們實際上容器元素的子元素應該是多個AccordionItem元素,也正因為如此,這裡的children型別就是JSX.Element [],我們應該如何獲取這些子元素呢?我們應該知道,每一個子元素對應的就是一個節點,在react中用的是連結串列來表示這些節點,每個節點對應的就有個type屬性,我們只需要拿到容器元素的子元件元素中type屬性為AccordionItem的元素陣列,如下:

//name不是AccordionItem,代表子元素不是AccordionItem,不是的我們需要過濾掉
const items = children?.filter(
    (item) => item?.type?.name === 'AccordionItem,代表子元素不是AccordionItem,所以我們需要過濾掉',
 );

到了這裡,我們就知道了,容器元素的子元素是一個陣列,我們就需要遍歷,使用map方法,如下:

items?.map(({ props: { index, label, children } }) => (
  <AccordionItem
     key={index}
     label={label}
     children={children}
     isCollapsed={bindIndex !== index}
     handleClick={() => changeItem(index)}
  />
))

請注意這一段程式碼:

handleClick={() => changeItem(index)}

這就是我們之前子元件繫結的事件,也是我們需要暴露出去的事件,在這個事件方法中,我們無非執行的就是更改當前被展開元素的索引。所以程式碼就很好寫了:

const changeItem = (index: number | string) => {
   //暴露點選事件方法介面
   if (typeof onItemClick === 'function') {
     onItemClick(index);
   }
   //設定索引
   if (index !== bindIndex) {
     setBindIndex(index);
   }
};

到了這裡,我們的一個手風琴元件就完成了,完整程式碼如下:

import { cx, css } from '@emotion/css';
import React, { useState } from 'react';
import type { ReactNode, SyntheticEvent } from 'react';


const baseStyle = css`
  line-height: 1.5715;
`;
const AccordionContainer = cx(
  baseStyle,
  css`
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    color: #000000d9;
    font-size: 14px;
    background-color: #fafafa;
    border: 1px solid #d9d9d9;
    border-bottom: 0;
    border-radius: 2px;
  `,
);
const AccordionItemContainer = css`
  border-bottom: 1px solid #d9d9d9;
`;
const AccordionItemHeader = cx(
  baseStyle,
  css`
    position: relative;
    display: flex;
    flex-wrap: nowrap;
    align-items: flex-start;
    padding: 12px 16px;
    color: rgba(0, 0, 0, 0.85);
    cursor: pointer;
    transition: all 0.3s, visibility 0s;
    box-sizing: border-box;
  `,
);


const AccordionItemContent = css`
  color: #000000d9;
  background-color: #fff;
  border-top: 1px solid #d9d9d9;
  transition: all 0.3s ease-in-out;
  padding: 16px;
  &.collapsed {
    display: none;
  }
  &.expanded {
    display: block;
  }
`;


interface AccordionItemType {
  index: string | number;
  label: string;
  isCollapsed: boolean;
  handleClick(e: SyntheticEvent): void;
  children: ReactNode;
}
interface AccordionType {
  defaultIndex: number | string;
  onItemClick(key: number | string): void;
  children: JSX.Element[];
}


const AccordionItem = (props: Partial<AccordionItemType>) => {
  const { label, isCollapsed, handleClick, children } = props;
  return (
    <div className={AccordionItemContainer} onClick={handleClick}>
      <div className={AccordionItemHeader}>{label}</div>
      <div
        aria-expanded={isCollapsed}
        className={`${AccordionItemContent}${
          isCollapsed ? ' collapsed' : ' expanded'
        }`}
      >
        {children}
      </div>
    </div>
  );
};


const Accordion = (props: Partial<AccordionType>) => {
  const { defaultIndex, onItemClick, children } = props;
  const [bindIndex, setBindIndex] = useState(defaultIndex);
  const changeItem = (index: number | string) => {
    if (typeof onItemClick === 'function') {
      onItemClick(index);
    }
    if (index !== bindIndex) {
      setBindIndex(index);
    }
  };
  const items = children?.filter(
    (item) => item?.type?.name === 'AccordionItem',
  );
  return (
    <div className={AccordionContainer}>
      {items?.map(({ props: { index, label, children } }) => (
        <AccordionItem
          key={index}
          label={label}
          children={children}
          isCollapsed={bindIndex !== index}
          handleClick={() => changeItem(index)}
        />
      ))}
    </div>
  );
};

讓我們來看一下效果:

到此為止了,更多React元件的實現,可以訪問react-code-segment

原始碼地址可以看這裡原始碼地址。喜歡覺得不錯能夠幫助到您,希望能點個贊,您的贊就是我更新文章的最大動力。

相關文章