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 it's 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!