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.
(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
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
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 propsThen you pass them one more level down to Accordion:
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 heightnow 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: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 theAccordionGroup
component contains multipleAccordion
components that are created based on theprojects
array. TheseAccordion
components are the "child" accordions mentioned:Each child
Accordion
contains aProjectCard
that displays the project details. When a user clicks on anAccordion
(or "project"), it expands to show theProjectCard
.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
: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
: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 to0px
(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 exampleonHeightChange
. Then, in yourPortfolio
component, you could propagate this height change upwards to theHomepage
component by passing a new callback function to theAccordion
component that utilizes theonHeightChange
prop.Accordion.tsx
:And then modify your Portfolio component to propagate the height change event:
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:
That way, you are forcing a re-render of the parent Accordion component whenever a child Accordion’s height changes.