我正在尝试构建我的主页的移动版本,我的嵌套手风琴“项目”似乎有一个错误,它在第一次打开时无法显示底部项目部分的正确高度。
要打开它,您首先单击项目文本,然后它列出项目,然后单击项目切换项目卡。
(已更新)我相信发生这种情况是因为当子手风琴打开时,我的父手风琴没有重新更新其高度。
你知道这样做的好方法吗?或者如果需要的话,我应该以一种使这成为可能的方式重组我的组件吗?困难在于 Accordion 接受儿童,并且我在其中重复使用 Accordion,所以它相当混乱。我知道我可以使用回调函数来触发父级,但不太确定如何解决这个问题。
主页.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>
)
}
投资组合.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 - AccordionGroup 的目的是只允许一次打开一个子 Accordion。如果 Accordion 不在 AccordionGroup 中,它可以独立打开和关闭。
"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>
)
}
手风琴.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>
}
如有任何帮助,我们将不胜感激,谢谢!
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号
问题分析:
TL;DR:父手风琴需要了解这些变化,以便它可以相应地调整自己的高度。
我想您可能正在使用
amiut/accordionify,如图所示通过“创建轻量级 React Accordions”来自Amin A. Rezapour。这是我发现的唯一一个使用
AccordionGroup的项目。应用中的嵌套手风琴结构涉及父子关系,其中子手风琴的高度会根据展开还是折叠而动态变化。
这可以通过您的
Portfolio.tsx来说明,其中AccordionGroup组件包含多个基于创建的数组。这些Accordion组件项目Accordion组件是提到的“子”手风琴: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> ) }每个子
Accordion都包含一个显示项目详细信息的ProjectCard。当用户单击Accordion(或“项目”)时,它会展开以显示ProjectCard。这就是高度变化发挥作用的地方;手风琴将根据用户交互展开或折叠,动态改变其高度。
动态高度在
Accordion.tsx中管理:function handleToggle() { if (!contentHeight?.current) return setIsOpen((prevState) => !prevState) setHeight(isOpen ? "0px" : `${contentHeight.current.scrollHeight}px`) if (onClick) onClick() }当调用
handleToggle函数时,它会检查手风琴当前是否打开(isOpen)。如果是,则高度设置为“0px”(即,手风琴折叠)。如果未打开,则高度设置为内容的滚动高度(即展开手风琴)。这些儿童手风琴的动态高度变化是您遇到的问题的关键部分。父手风琴需要了解这些变化,以便它可以相应地调整自己的高度。
我在同一个
Accordion.tsx中看到:useEffect(() => { if (isActive === undefined) return isActive ? setHeight(`${contentHeight.current?.scrollHeight}px`) : setHeight("0px") }, [isActive])手风琴的高度是根据
isActive属性设置的,它表示手风琴当前是否打开。如果打开,则高度设置为手风琴内容的滚动高度(有效展开手风琴),如果未激活,则高度设置为0px(折叠手风琴)。但是,虽然此效果根据每个手风琴自身的
isActive状态正确调整其高度,但它并未考虑子手风琴高度的变化。当嵌套(子)手风琴改变其高度(由于展开或折叠)时,父手风琴的高度不会重新计算,因此不会调整以适应父手风琴的新高度孩子。
换句话说,当子手风琴的高度发生变化时,父手风琴并不知道它需要重新渲染并调整其高度。当嵌套的手风琴展开或折叠时缺乏重新渲染会导致父手风琴无法显示正确的高度。
可能的解决方案
TL;DR:解决方案包括让父级意识到子手风琴的高度变化,以便它可以相应地调整自己的高度。
(“React:强制组件重新渲染中提到的技术之一| 4 个简单方法”,来自 Josip Miskovic)
您的
Accordion组件可以受益于回调函数 prop,该函数在其高度发生变化时被调用,例如onHeightChange。然后,在Portfolio组件中,您可以通过将新的回调函数传递给Accordion组件来将此高度更改向上传播到Homepage组件,利用onHeightChange属性。手风琴.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]) // ... }然后修改您的 Portfolio 组件以传播高度更改事件:
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> ) }最后,您可以向主页上的 Portfolio 手风琴添加一个键,该键将在触发高度更改事件时发生变化。这将导致手风琴重新渲染:
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> //... ) }这样,只要子 Accordion 的高度发生变化,您就会强制重新渲染父 Accordion 组件。
您知道,这里的实现有点具有挑战性,因为当您想要从其子手风琴更新祖父母手风琴的高度时,您无法真正从那里知道您想要更新哪个相应的祖父母手风琴除非您将道具传递给祖父母手风琴,并将道具传递给中间组件(例如,
Portfolio,即子手风琴的父级),以便它可以将它们传播到其子手风琴。通过这样做我们可以让祖父母和孩子手风琴以某种方式进行交流。
也许这不是您能找到的最佳解决方案,但遗憾的是,我想不出更好的解决方案。
所以回顾一下:这个想法是在顶层创建一个状态来保存引用每个父手风琴的高度,所以它是一个数组,其中长度是“手动”设置的,这使得它在某种程度上很难看,但是如果你必须使用数据数组来动态显示您的组件,那么这不是问题,我们稍后会发现,我们也会看到解决方法的限制。
解决方法:
现在我们将采用最简单、最直接的修复方法,该修复方法适用于问题中包含的内容。
如上所述,首先我们在 HomePage 组件中创建状态:
const [heights, setHeights] = useState(Array(7).fill("0px")); // you have 7 parent Accordions在顶层创建数组状态后,现在,我们向每个 Accordion 组件传递状态设置函数
setHeights、索引indexx以及相应的heightheightParent如果它是父 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>注意: 传递给父级的
indexx属性和传递给中间组件(Portfolio)的indexx属性它们应该具有相同的值表示对应的索引,这实际上是解决方案的关键。命名为“indexx”,其中带有两个“x”,以避免以后发生冲突。
然后,从中间组件将这些收到的道具传递给子手风琴:
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> ); }现在,从您的子 Accordion 组件中,您可以利用传递的
indexx属性来更新 HomePage 状态下相应 Accordion 父级的高度,因此当我们更新子高度时,我们还更新父高度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)) ); }最后,当您指定一个 Accordion 的高度时,您可以检查它是否接收
heightParent作为 props,以便我们知道它是父级,这样,我们让 Accordion 组件使用heightParent作为maxHeight而不是它自己的状态height(如果它是父状态),这就是忽略更新height状态的原因当它是打开的父 Accordion 时,因此,我们必须更改maxHeight属性的设置方式,现在它应该取决于 Accordion 的性质:style={{ maxHeight: `${heightParent? heightParent : height }` }}如果您想让父 Accordion 只需使用其状态
height作为maxHeight并保持代码不变,这样更有意义style={{ maxHeight: height }}您仍然可以通过在 Accordion 组件中添加
useEffect并确保其仅在更新并定义接收到的heightParent属性时运行来执行此操作,我们这样做确保代码仅在父手风琴应更新其height状态时运行:useEffect(()=>{ if(heightParent){ setHeight(heightParent) } },[heightParent])如上所述,这更有意义,而且也最漂亮,但我仍然更喜欢第一个,因为它更简单,并且还节省了一个额外的渲染。
动态处理数据:
如果我们将数据存储在数组中,并且希望基于此显示我们的组件,则可以这样做:
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>您可以注意到,我们必须在父手风琴中指定一个
key,以便我们可以使用它而不是indexx,但您知道key> 财产很特殊,无论如何我们都不想弄乱它,希望您明白限制:
很明显,这个解决方案仅适用于一个级别,因此,如果子手风琴本身成为子手风琴,那么您必须再次围绕它,但如果我理解您在做什么,您可能就不会面对这种情况,因为通过您的实现,子 Accordion 应该显示项目,但谁知道也许有一天您需要让它返回另一个子 Accordion,这就是为什么我认为我的建议是一种解决方法而不是最佳解决方案。
就像我说的,这可能不是最好的解决方案,但说实话,尤其是对于这个实现,我认为不存在这样的多级工作解决方案,请证明我错了,我我正在关注该帖子。