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!
P粉9981006482024-02-22 10:41:59
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 the AccordionGroup
component contains multiple Accordion
array. These component projects
created based on 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 the user clicks on the Accordion
(or "Project"), it expands to show the ProjectCard
.
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
:
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. 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
:
useEffect(() => { if (isActive === undefined) return isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px") }, [isActive])
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 to 0px
(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.
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 as onHeightChange
. Then, in the Portfolio
component, you can propagate this height change up to the Homepage
component by passing a new callback function to the Accordion
component, using onHeightChange
property.
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]) // ... }
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 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:
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> //... ) }
This way, you will force the parent Accordion component to re-render whenever the height of the child Accordion changes.
P粉6499902732024-02-22 10:08:40
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.
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:
const [heights, setHeights] = useState(Array(7).fill("0px")); // you have 7 parent Accordions
After creating the array state at the top level, now we pass the state setting function setHeights
, index indexx
and the corresponding height heightParent
to each Accordion component if It is the parent Accordion
<AccordionGroup> <Accordion title="Puyan Wei" heightParent={heights[0]} setHeights={setHeights} indexx="0"> <PuyanWei setHeights={setHeights} indexx="0" /> </Accordion> <Accordion title="Portfolio" heightParent={heights[1]} setHeights={setHeights} indexx="1"> <Portfolio setHeights={setHeights} indexx="1" /> </Accordion> //... <Accordion title="Socials" heightParent={heights[6]} setHeights={setHeights} indexx="6"> <Socials setHeights={setHeights} indexx="6" /> </Accordion> </AccordionGroup>
Note: The indexx
attribute passed to the parent and the indexx
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:
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 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 high
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)) ); }
Finally, 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 use heightParent
As maxHeight
instead of its own state height
(if it is the parent state), this is why updating the height
state is ignored when it is open parent Accordion, therefore, we have to change the way the maxHeight
property is set, now it should depend on the nature of the Accordion:
style={{ maxHeight: `${heightParent? heightParent : height }` }}
If you want the parent Accordion just use its state height
as maxHeight
and keep the code the same, it makes more sense
style={{ maxHeight: height }}
You can still do this by adding a useEffect
in the Accordion component and making sure it only runs when the received heightParent
property is updated and defined, we do this to ensure the code Run only if the parent accordion should update its height
state:
useEffect(()=>{ if(heightParent){ setHeight(heightParent) } },[heightParent])
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.
If we store data in an array and want to display our component based on this, we can do this:
const data = [...] const [heights, setHeights] = useState(data.map(_ => "0px")); //... <section className="col-span-10 col-start-2"> <AccordionGroup> {data.map(element, index => { <Accordion key={index} title={element.title} heightParent={heights[index]} setHeights={setHeights} indexx={index} > {element.for === "portfolio" ? <Portfolio setHeights={setHeights} indexx={index} /> : <PuyanWei setHeights={setHeights} indexx={index} /> } // just an example </Accordion> }) } </AccordionGroup> </section>
You can notice that we have to specify a key
in the parent accordion so that we can use it instead of indexx
, but you know key
> The property is special and we don't want to mess with it no matter what, I hope you understand
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.