React вложенный Accordion Parent не обновляет высоту

Я пытаюсь создать мобильную версию своей домашней страницы. Кажется, у моего вложенного аккордеона «Проекты» есть ошибка, из-за которой при первом открытии не отображается правильная высота нижнего раздела проектов.

Чтобы открыть это, вы сначала щелкаете по тексту проектов, затем перечисляете проекты, а затем щелкаете по проекту, переключая Карту проекта.

(Обновлено) Я считаю, что это происходит из-за того, что мой родительский Аккордеон не обновляет свою высоту повторно, когда открывается дочерний Аккордеон.

Вы знаете хороший способ сделать это? Или, если необходимо, я должен реструктурировать свои компоненты таким образом, чтобы это стало возможным? Сложность заключается в том, что Accordion принимает детей, и я повторно использую Accordion внутри него, поэтому это довольно запутанно. Я знаю, что потенциально могу использовать функцию обратного вызова для запуска родителя, но не совсем уверен, как к этому подойти.

Домашняя страница.tsx


import { Accordion } from "@/components/atoms/Accordion"
import { AccordionGroup } from "@/components/atoms/AccordionGroup"
import { AccordionSlideOut } from "@/components/atoms/AccordionSlideOut"
import { Blog } from "@/components/compositions/Blog"
import { Contact } from "@/components/compositions/Contact"
import { Portfolio } from "@/components/compositions/Portfolio"
import { PuyanWei } from "@/components/compositions/PuyanWei"
import { Resumé } from "@/components/compositions/Resumé"
import { Socials } from "@/components/compositions/Socials"
import { Component } from "@/shared/types"

interface HomepageProps extends Component {}

export function Homepage({ className = "", testId = "homepage" }: HomepageProps) {
  return (
    <main className = {`grid grid-cols-12 pt-24 ${className}`} data-testid = {testId}>
      <section className = "col-span-10 col-start-2">
        <AccordionGroup>
          <Accordion title = "Puyan Wei">
            <PuyanWei />
          </Accordion>
          <Accordion className = "lg:hidden" title = "Portfolio">
            <Portfolio />
          </Accordion>
          <AccordionSlideOut className = "hidden lg:flex" title = "Portfolio">
            <Portfolio />
          </AccordionSlideOut>
          <Accordion title = "Resumé">
            <Resumé />
          </Accordion>
          <Accordion title = "Contact">
            <Contact />
          </Accordion>
          <Accordion title = "Blog">
            <Blog />
          </Accordion>
          <Accordion title = "Socials">
            <Socials />
          </Accordion>
        </AccordionGroup>
      </section>
    </main>
  )
}

Портфолио.tsx

import { Accordion } from "@/components/atoms/Accordion"
import { AccordionGroup } from "@/components/atoms/AccordionGroup"
import { ProjectCard } from "@/components/molecules/ProjectCard"
import { projects } from "@/shared/consts"
import { Component } from "@/shared/types"

interface PortfolioProps extends Component {}

export function Portfolio({ className = "", testId = "portfolio" }: PortfolioProps) {
  return (
    <AccordionGroup className = {`overflow-hidden ${className}`} testId = {testId}>
      {projects.map((project, index) => (
        <Accordion title = {project.title} key = {`${index}-${project}`} headingSize = "h2">
          <ProjectCard project = {project} />
        </Accordion>
      ))}
    </AccordionGroup>
  )
}

AccordionGroup.tsx - Цель AccordionGroup — разрешить одновременно открывать только один дочерний элемент Accordion. Если Accordion не находится в AccordionGroup, он может открываться и закрываться независимо друг от друга.


"use client"

import React, { Children, ReactElement, cloneElement, isValidElement, useState } from "react"
import { AccordionProps } from "@/components/atoms/Accordion"
import { Component } from "@/shared/types"

interface AccordionGroupProps extends Component {
  children: ReactElement<AccordionProps>[]
}

export function AccordionGroup({
  children,
  className = "",
  testId = "accordion-group",
}: AccordionGroupProps) {
  const [activeAccordion, setActiveAccordion] = useState<number | null>(null)

  function handleAccordionToggle(index: number) {
    setActiveAccordion((prevIndex) => (prevIndex === index ? null : index))
  }

  return (
    <div className = {className} data-testid = {testId}>
      {Children.map(children, (child, index) =>
        isValidElement(child)
          ? cloneElement(child, {
              onClick: () => handleAccordionToggle(index),
              isActive: activeAccordion === index,
              children: child.props.children,
              title: child.props.title,
            })
          : child
      )}
    </div>
  )
}

Аккордеон.tsx


"use client"
import { Component } from "@/shared/types"
import React, { MutableRefObject, ReactNode, RefObject, useEffect, useRef, useState } from "react"
import { Heading } from "@/components/atoms/Heading"

export interface AccordionProps extends Component {
  title: string
  children: ReactNode
  isActive?: boolean
  onClick?: () => void
  headingSize?: "h1" | "h2"
}

export function Accordion({
  className = "",
  title,
  children,
  isActive,
  onClick,
  headingSize = "h1",
  testId = "Accordion",
}: AccordionProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [height, setHeight] = useState("0px")
  const contentHeight = useRef(null) as MutableRefObject<HTMLElement | null>

  useEffect(() => {
    if (isActive === undefined) return
    isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px")
  }, [isActive])

  function handleToggle() {
    if (!contentHeight?.current) return
    setIsOpen((prevState) => !prevState)
    setHeight(isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`)
    if (onClick) onClick()
  }
  return (
    <div className = {`w-full text-lg font-medium text-left focus:outline-none ${className}`}>
      <button onClick = {handleToggle} data-testid = {testId}>
        <Heading
          className = "flex items-center justify-between"
          color = {isActive ? "text-blue-200" : "text-white"}
          level = {headingSize}
        >
          {title}
        </Heading>
      </button>
      <div
        className = {`overflow-hidden transition-max-height duration-250 ease-in-out`}
        ref = {contentHeight as RefObject<HTMLDivElement>}
        style = {{ maxHeight: height }}
      >
        <div className = "pt-2 pb-4">{children}</div>
      </div>
    </div>
  )
}

ProjectCard.tsx


import Image from "next/image"
import { Card } from "@/components/atoms/Card"
import { Children, Component, Project } from "@/shared/types"
import { Subheading } from "@/components/atoms/Subheading"
import { Tag } from "@/components/atoms/Tag"
import { Text } from "@/components/atoms/Text"

interface ProjectCardProps extends Component {
  project: Project
}

export function ProjectCard({
  className = "",
  testId = "project-card",
  project,
}: ProjectCardProps) {
  const {
    title,
    description,
    coverImage: { src, alt, height, width },
    tags,
  } = project
  return (
    <Card className = {`flex min-h-[300px] ${className}`} data-testid = {testId}>
      <div className = "w-1/2">
        <CoverImage className = "relative w-full h-full mb-4 -mx-6-mt-6">
          <Image
            className = "absolute inset-0 object-cover object-center w-full h-full rounded-l-md"
            src = {src}
            alt = {alt}
            width = {parseInt(width)}
            height = {parseInt(height)}
            loading = "eager"
          />
        </CoverImage>
      </div>
      <div className = "w-1/2 p-4 px-8 text-left">
        <Subheading className = "text-3xl font-bold" color = "text-black">
          {title}
        </Subheading>
        <Tags className = "flex flex-wrap pb-2">
          {tags.map((tag, index) => (
            <Tag className = "mt-2 mr-2" key = {`${index}-${tag}`} text = {tag} />
          ))}
        </Tags>
        <Text color = "text-black" className = "text-sm">
          {description}
        </Text>
      </div>
    </Card>
  )
}

function CoverImage({ children, className }: Children) {
  return <div className = {className}>{children}</div>
}
function Tags({ children, className }: Children) {
  return <div className = {className}>{children}</div>
}

Любая помощь будет оценена, спасибо!

🤔 А знаете ли вы, что...
React Testing Library - это инструмент для тестирования компонентов React.


3
94
2

Ответы:

Решено

Вы знаете, что для обработки таких ситуаций первая идея, которая приходит на ум, состоит в том, чтобы поднять состояние или, по крайней мере, использовать контекст, но с этой реализацией, использующей дочерние реквизиты, это немного сложно, потому что, когда вы хотите обновить высоту прародительского аккордеона по сравнению с дочерним, вы не сможете узнать оттуда, какой соответствующий прародительский аккордеон вы хотите обновить, если не передадите реквизиты, чтобы помочь в достижении этого.


Одно из решений, которое я могу придумать, - это создать состояние на домашней странице для хранения текущей высоты каждого родительского аккордеона.

const [heights, setHeights] = useState(Array(7).fill("0px"));

Здесь я указал длину для того же количества родительских аккордеонов на домашней странице, но даже если это массив, который вы сопоставляете для возврата аккордеонов, вы можете инициализировать состояние с помощью метода .map.

Каждому Аккордеону вы передаете функцию установки состояния setHeights, индекс indexx, а также соответствующую высоту heightParent, если это родительский Аккордеон.

<AccordionGroup>
  <Accordion title = "Puyan Wei" heightParent = {heights[0]} setHeights = {setHeights} indexx = "0">
      <PuyanWei setHeights = {setHeights} indexx = "0" />
  </Accordion>
  <Accordion title = "Puyan Wei" heightParent = {heights[1]} setHeights = {setHeights} indexx = "1">
      <Portfolio setHeights = {setHeights} indexx = "1" />
  </Accordion>
  //...
  <Accordion title = "Puyan Wei" heightParent = {heights[6]} setHeights = {setHeights} indexx = "6">
      <Socials setHeights = {setHeights} indexx = "6" />
  </Accordion> 
</AccordionGroup>

Затем вы передаете их еще на один уровень вниз к Аккордеону:

export function Portfolio({
  className = "",
  testId = "portfolio",
  indexx,
  setHeight,
}: PortfolioProps) {
  // update props interface
  return (
    <AccordionGroup className = {`overflow-hidden ${className}`} testId = {testId}>
      {projects.map((project, index) => (
        <Accordion
          title = {project.title}
          key = {`${index}-${project}`}
          headingSize = "h2"
          indexx = {indexx}
          setHeight = {setHeight}
        >
          <ProjectCard project = {project} />
        </Accordion>
      ))}
    </AccordionGroup>
  );
}

Теперь из вашего дочернего компонента Accordion вы можете обновить высоту соответствующего родителя Accordion в состоянии HomePage, используя преимущества переданных реквизитов indexx, поэтому, когда мы обновляем дочернюю высоту, мы также обновляем родительскую высоту.

function handleToggle() {
  if (!contentHeight?.current) return;
  setIsOpen((prevState) => !prevState);
  let newHeight = isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`;
  if (!heightParent) { // there is no need to update the state when it is a parent Accordion
    setHeight(newHeight);
  }
  setHeights((prev) =>
    prev.map((h, index) => (index == indexx ? newHeight : h))
  );
}

Наконец, когда вы указываете высоту для одного аккордеона, вы можете проверить, получает ли он heightParent в качестве реквизита, чтобы мы знали, что он является родительским:

style = {{ maxHeight: `${heightParent? heightParent : height }` }}

Анализ проблемы:

TL;DR: родительский аккордеон должен знать об этих изменениях, чтобы он мог соответствующим образом отрегулировать свою высоту.

Я предполагаю, что вы можете использовать amiut/accordionify , как показано в « Create Lightweight React Accordions » от Amin A. Rezapour.
Это единственное, что я нахожу с использованием AccordionGroup.

Вложенная структура аккордеона в вашем приложении включает отношения родитель-потомок, где высота дочернего аккордеона динамически изменяется в зависимости от того, развернут он или свернут.

Это иллюстрируется вашим Portfolio.tsx, где компонент AccordionGroup содержит несколько компонентов Accordion, созданных на основе массива projects. Эти Accordion компоненты являются упомянутыми «дочерними» аккордеонами:

export function Portfolio({ className = "", testId = "portfolio" }: PortfolioProps) {
  return (
    <AccordionGroup className = {`overflow-hidden ${className}`} testId = {testId}>
      {projects.map((project, index) => (
        <Accordion title = {project.title} key = {`${index}-${project}`} headingSize = "h2">
          <ProjectCard project = {project} />
        </Accordion>
      ))}
    </AccordionGroup>
  )
}

Каждый дочерний элемент Accordion содержит элемент ProjectCard, отображающий детали проекта. Когда пользователь нажимает на Accordion (или «проект»), он расширяется, чтобы показать ProjectCard.
Именно здесь в игру вступает изменение высоты; аккордеон будет расширяться или сворачиваться в зависимости от взаимодействия с пользователем, динамически изменяя свою высоту.

Динамическая высота управляется в Accordion.tsx:

  function handleToggle() {
    if (!contentHeight?.current) return
    setIsOpen((prevState) => !prevState)
    setHeight(isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`)
    if (onClick) onClick()
  }

Когда вызывается функция handleToggle, она проверяет, открыт ли в данный момент аккордеон (isOpen). Если это так, высота устанавливается на "0px" (т. е. аккордеон сворачивается). Если он не открыт, высота устанавливается равной высоте прокрутки содержимого (т. е. аккордеон расширяется).

Динамическое изменение высоты этих дочерних аккордеонов является ключевой частью проблемы, с которой вы столкнулись. Родительский аккордеон должен знать об этих изменениях, чтобы он мог соответствующим образом отрегулировать свою высоту.

Я вижу там же Accordion.tsx:

  useEffect(() => {
    if (isActive === undefined) return
    isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px")
  }, [isActive])

Высота аккордеона задается на основе реквизита isActive, который показывает, открыт ли аккордеон в данный момент или нет. Если он открыт, высота устанавливается равной высоте прокрутки содержимого аккордеона (фактически расширяя аккордеон), а если он не активен, высота устанавливается на 0px (сворачивание аккордеона).

Однако, хотя этот эффект правильно регулирует высоту каждого аккордеона в зависимости от его собственного состояния isActive, он не учитывает изменения высоты дочерних аккордеонов.

Когда вложенный (дочерний) аккордеон изменяет свою высоту (из-за расширения или свертывания), высота родительского аккордеона не пересчитывается и, следовательно, не подстраивается под новую высоту дочернего элемента.

Другими словами, родительский аккордеон не знает, что ему нужно повторно отображать и регулировать свою высоту при изменении высоты дочернего аккордеона. Это отсутствие повторного рендеринга, когда вложенный аккордеон расширяется или сворачивается, приводит к тому, что родительский аккордеон не показывает правильную высоту.

Возможное решение

TL;DR: решение будет заключаться в том, чтобы родитель знал об изменениях высоты в дочернем аккордеоне, чтобы он мог соответствующим образом отрегулировать свою высоту.

(одна из техник, упомянутых в «React: Force Component to Re-Render | 4 Simple Ways », от Josip Miskovic)

Ваш компонент Accordion может извлечь выгоду из поддержки функции обратного вызова, которая вызывается при изменении его высоты, например onHeightChange. Затем в вашем компоненте Portfolio вы можете распространить это изменение высоты вверх на компонент Homepage, передав новую функцию обратного вызова компоненту Accordion, который использует свойство onHeightChange.

Accordion.tsx:

export interface AccordionProps extends Component {
  title: string
  children: ReactNode
  isActive?: boolean
  onClick?: () => void
  onHeightChange?: () => void
  headingSize?: "h1" | "h2"
}

export function Accordion({
  className = "",
  title,
  children,
  isActive,
  onClick,
  onHeightChange,
  headingSize = "h1",
  testId = "Accordion",
}: AccordionProps) {
  // ...

  useEffect(() => {
    if (isActive === undefined) return
    isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px")
    if (onHeightChange) onHeightChange() // Call the onHeightChange callback
  }, [isActive])

  // ...
}

Затем измените компонент «Портфолио», чтобы распространить событие изменения высоты:

export function Portfolio({ className = "", testId = "portfolio", onHeightChange }: PortfolioProps & {onHeightChange?: () => void}) {
  return (
    <AccordionGroup className = {`overflow-hidden ${className}`} testId = {testId}>
      {projects.map((project, index) => (
        <Accordion 
          title = {project.title} 
          key = {`${index}-${project}`} 
          headingSize = "h2"
          onHeightChange = {onHeightChange} // Propagate the height change event
        >
          <ProjectCard project = {project} />
        </Accordion>
      ))}
    </AccordionGroup>
  )
}

Наконец, вы можете добавить ключ к аккордеону вашего портфолио на главной странице, который будет меняться при срабатывании события изменения высоты. Это приведет к повторному рендерингу аккордеона:

export function Homepage({ className = "", testId = "homepage" }: HomepageProps) {
  const [key, setKey] = useState(Math.random());
  //...

  return (
    //...
    <Accordion className = "lg:hidden" title = "Portfolio" key = {key}>
      <Portfolio onHeightChange = {() => setKey(Math.random())} />
    </Accordion>
    //...
  )
}

Таким образом, вы принудительно выполняете повторную визуализацию родительского компонента Accordion всякий раз, когда изменяется высота дочернего компонента Accordion.