skip to Main Content

I am trying to build the mobile version of my homepage. My nested accordion "Projects" seems to have a bug where it doesn’t show the correct height of the bottom projects section on the first open.

To open that you first click on the projects text, then it lists out the projects and then you click on the project toggle the Project Card.

Screenshot of visual bug

(Updated) I believe this is happening because my parent Accordion is not re-updating its height when the child Accordion opens.

Do you know a good way of doing this? Or if needed should I restructure my components in a way that makes this possible? The difficulty is the fact that Accordion accepts children, and I reuse Accordion inside it so its rather confusing. I know I can potentially use a callback function to trigger the parent but not quite sure how to approach this.

Homepage.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>
  )
}

Portfolio.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 –
The purpose of AccordionGroup is to only allow one child Accordion to be open at one time. If an Accordion is not in AccordionGroup it can open and close independently.


"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>
  )
}

Accordion.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>
}

Any help would be appreciated, thanks!

2

Answers


  1. You know to handle those situations the first thing you think of is to lift the state up but with this implementation using children it is a bit challenging, at least for me.
    If you think of context it can also be a solution but the challenging part is when you want to update the context from a child Accordion, is how to know which corresponding Accordion is its grandparent since here you always have AccordionGroup component in the middle.

    I will give a solution, maybe not the best one but it can help.
    In the homePage you can create a state to hold the current height of each parent Accordion

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

    here I specified the length to the same number of parent According in the Hompage but even if it is an array that you map through to return Accordions you can initialize the state with the .map method.

    to each parent Accordion, you pass the corresponding height as heightParent, and to its child you pass the setter function, and also the index as props

    <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>
    

    Then you pass them one more level down to Accordion:

    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>
      );
    }
    

    Now from your child Accordion component, you are able to update the height of the corresponding Accordion parent in the HomePage state , getting advantage of the passed indexx props, so when we update the child height we also update the parent height

    function handleToggle() {
      if (!contentHeight?.current) return;
      setIsOpen((prevState) => !prevState);
      let newHeight = isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`;
      if (!heightParent) {
        setHeight(newHeight);
      }
      setHeights((prev) =>
        prev.map((h, index) => (index == indexx ? newHeight : h))
      );
    }
    

    now when you specify the height for one Accordion you can check if it receives heightParent as props so we know it is a parent one:

    style={{ maxHeight: `${heightParent? heightParent : height }` }}
    
    Login or Signup to reply.
  2. Problem analysis:

    TL;DR: The parent accordion needs to be aware of these changes, so it can adjust its own height accordingly.

    I suppose you might be using amiut/accordionify, as illustrated by "Create Lightweight React Accordions" from Amin A. Rezapour.
    It is the only one I find using AccordionGroup.

    The nested accordion structure in your app involves parent-child relationships where the child accordion’s height changes dynamically based on whether it is expanded or collapsed.

    That is illustrated by your Portfolio.tsx, where the AccordionGroup component contains multiple Accordion components that are created based on the projects array. These Accordion components are the "child" accordions mentioned:

    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>
      )
    }
    

    Each child Accordion contains a ProjectCard that displays the project details. When a user clicks on an Accordion (or "project"), it expands to show the ProjectCard.
    That is where the height change comes into play; the accordion will expand or collapse based on user interaction, changing its height dynamically.

    The dynamic height is managed in Accordion.tsx:

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

    When the handleToggle function is called, it checks whether the accordion is currently open (isOpen). If it is, the height is set to "0px" (i.e., the accordion is collapsed). If it is not open, the height is set to the scroll height of the content (i.e., the accordion is expanded).

    The dynamic height change of these child accordions is the key part of the issue you are experiencing. The parent accordion needs to be aware of these changes, so it can adjust its own height accordingly.

    I see in the same Accordion.tsx:

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

    The height of the accordion is set based on the isActive prop, which represents whether the accordion is currently open or not. If it is open, the height is set to the scroll height of the accordion content (effectively expanding the accordion), and if it is not active, the height is set to 0px (collapsing the accordion).

    However, while this effect correctly adjusts the height of each accordion based on its own isActive state, it does not account for changes in the heights of child accordions.

    When a nested (child) accordion changes its height (due to being expanded or collapsed), the height of the parent accordion is not recalculated and therefore does not adjust to accommodate the new height of the child.

    In other words, the parent accordion is not aware that it needs to re-render and adjust its height when the height of a child accordion changes. That lack of re-rendering when the nested accordion expands or collapses leads to the issue of the parent accordion not showing the correct height.

    Possible solution

    TL;DR: The solution would involve making the parent aware of the height changes in the child accordion so that it can adjust its own height accordingly.

    (one of the techniques mentioned in "React: Force Component to Re-Render | 4 Simple Ways", from Josip Miskovic)

    Your Accordion component could benefit from a callback function prop that gets invoked when its height changes, for example onHeightChange. Then, in your Portfolio component, you could propagate this height change upwards to the Homepage component by passing a new callback function to the Accordion component that utilizes the onHeightChange prop.

    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])
    
      // ...
    }
    

    And then modify your Portfolio component to propagate the height change event:

    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>
      )
    }
    

    Finally, you can add a key to your Portfolio accordion on the Homepage that will change when the height change event is triggered. That will cause the accordion to re-render:

    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>
        //...
      )
    }
    

    That way, you are forcing a re-render of the parent Accordion component whenever a child Accordion’s height changes.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search