I'm trying to build a mobile version of my homepage and there seems to be a bug with my nested accordion "items" where it doesn't display the correct height of the bottom item section when first opened.
To open it, you first click on the project text, then it lists the projects, then click on the project to toggle the project card.
(Updated) I believe this is happening because my parent accordion is not re-updating its height when the child accordion is opened.
Do you know a good way to do this? Or if necessary, should I restructure my components in a way that makes this possible? The difficulty is that the Accordion accepts children, and I'm reusing the Accordion within it, so it's quite confusing. I know I can use a callback function to trigger the parent, but not quite sure how to go about this.
Home.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 an AccordionGroup is to allow only one child Accordion to be open at a time. If an Accordion is not in an AccordionGroup, it can be opened and closed 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 greatly appreciated, thank you!
problem analysis:
TL;DR: The parent accordion needs to know about these changes so that it can adjust its height accordingly.
I think you might be using
amiut/accordionify
as shown via "Create lightweight React Accordions" From Amin A. Rezapour.This is the only project I've found that uses
AccordionGroup
.The nested accordion structure in the application involves a parent-child relationship, where the height of the child accordion changes dynamically depending on whether it is expanded or collapsed.
This can be illustrated by your
Portfolio.tsx
where theAccordionGroup
component contains multipleAccordion
array. Thesecomponent projects
created based onAccordion
components are the "child" accordions mentioned:Each child
Accordion
contains aProjectCard
that displays the project details. When the user clicks on theAccordion
(or "Project"), it expands to show theProjectCard
.This is where height changes come into play; the accordion will expand or collapse based on user interaction, changing its height dynamically.
Dynamic height is managed in
Accordion.tsx
: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. accordion collapsed). If not open, the height is set to the scroll height of the content (i.e. the accordion is expanded).The dynamic height changes of these children's accordions are a key part of your problem. The parent accordion needs to know about these changes so that it can adjust its height accordingly.
I see in the same
Accordion.tsx
:The height of the accordion is set according to the
isActive
property, which indicates whether the accordion is currently open. If on, the height is set to the scroll height of the accordion content (effectively an expanded accordion), if not activated the height is set to0px
(a collapsed accordion).However, while this effect correctly adjusts the height of each accordion based on its own
isActive
state, it does not take into account changes in the height of the child accordions.When a nested (child) accordion changes its height (due to expansion or collapse), the height of the parent accordion is not recalculated and therefore does not adjust to fit the new height of the parent's child.
In other words, when the height of the child accordion changes, the parent accordion doesn't know that it needs to re-render and adjust its height. Lack of re-rendering when a nested accordion is expanded or collapsed causes the parent accordion to not display the correct height.
Possible solutions
TL;DR: The solution involves making the parent aware of the child accordion's height changes so that it can adjust its own height accordingly.
("React: One of the techniques mentioned in Force component re-rendering | 4 easy ways " from Josip Miskovic)
Your
Accordion
component can benefit from a callback function prop that is called when its height changes, such asonHeightChange
. Then, in thePortfolio
component, you can propagate this height change up to theHomepage
component by passing a new callback function to theAccordion
component, usingonHeightChange
property.accordion.tsx
:Then modify your Portfolio component to propagate the height change event:
Finally, you can add a key to the Portfolio accordion on the home page that will change when the height change event is triggered. This will cause the accordion to re-render:
This way, you will force the parent Accordion component to re-render whenever the height of the child Accordion changes.
You know, the implementation here is a bit challenging because when you want to update the height of a grandparent accordion from its child accordion, you can't really know from there which corresponding grandparent accordion you want to update unless you pass the props Give the grandparent accordion, and pass the props to the intermediate component (e.g.
Portfolio
, the parent of the child accordion) so that it can propagate them to its child accordion.By doing this we can allow grandparents and child accordions to communicate in some way.
Maybe this isn't the best solution you can find, but sadly I can't think of a better one.
So to recap: the idea is to create a state at the top level to hold a reference to the height of each parent accordion, so it's an array where the length is set "manually", which makes it somewhat ugly , but if you have to use a data array to display your component dynamically, then this is not a problem, as we will find out later, we will also see the limitations of the workaround.
Solution:
Now we'll go with the simplest, most straightforward fix that works for what's included in the question.
As mentioned above, first we create the state in the HomePage component:
After creating the array state at the top level, now we pass the state setting function
setHeights
, indexindexx
and the corresponding heightheightParent
to each Accordion component if It is the parent AccordionNote: The
indexx
attribute passed to the parent and theindexx
attribute passed to the intermediate component (Portfolio) should have the same value represents the corresponding index, which is actually the key to the solution.Named "indexx" with two "x"s in it to avoid future conflicts.
Then, pass these received props from the middle component to the child accordion:
Now, from your child Accordion component, you can utilize the passed
indexx
property to update the height of the corresponding Accordion parent in the HomePage state, so when we update the child height, we also update the parent highFinally, when you specify the height of an Accordion, you can check if it receives
heightParent
as props so that we know it is the parent, this way, we make the Accordion component useheightParent
AsmaxHeight
instead of its own stateheight
(if it is the parent state), this is why updating theheight
state is ignored when it is open parent Accordion, therefore, we have to change the way themaxHeight
property is set, now it should depend on the nature of the Accordion:If you want the parent Accordion just use its state
height
asmaxHeight
and keep the code the same, it makes more senseYou can still do this by adding a
useEffect
in the Accordion component and making sure it only runs when the receivedheightParent
property is updated and defined, we do this to ensure the code Run only if the parent accordion should update itsheight
state:As mentioned above, this makes more sense and is also the prettiest, but I still prefer the first one as it's simpler and also saves an extra render.
Dynamic data processing:
If we store data in an array and want to display our component based on this, we can do this:
You can notice that we have to specify a
key
in the parent accordion so that we can use it instead ofindexx
, but you knowkey
> The property is special and we don't want to mess with it no matter what, I hope you understandlimit:
Obviously this solution only works for one level, so if the sub-accordion itself becomes a sub-accordion you have to wrap around it again, but if I understand what you are doing you probably won't face this Situation, because with your implementation the child Accordion should show the items, but who knows maybe one day you will need to make it return another child Accordion, that's why I think my suggestion is a workaround and not the best solution.
Like I said, this might not be the best solution, but to be honest, especially for this implementation, I don't think such a multi-level working solution exists, please prove me wrong Yes, I am following this post.