diff --git a/.changeset/shy-kiwis-bake.md b/.changeset/shy-kiwis-bake.md new file mode 100644 index 0000000000..cb49af7ba1 --- /dev/null +++ b/.changeset/shy-kiwis-bake.md @@ -0,0 +1,6 @@ +--- +"@heroui/accordion": patch +--- + +- Introduce new prop `scrollOnOpen?: boolean` to automatically scroll to the content when expanded +- Introduce new prop `transitionDuration?: number` to customize animation speed. Defaults to 300ms as it is right now diff --git a/apps/docs/content/components/accordion/index.ts b/apps/docs/content/components/accordion/index.ts index b5f0a7bc80..f8fa3a88ed 100644 --- a/apps/docs/content/components/accordion/index.ts +++ b/apps/docs/content/components/accordion/index.ts @@ -14,6 +14,8 @@ import indicatorFunction from "./indicator-function"; import customMotion from "./custom-motion"; import controlled from "./controlled"; import customStyles from "./custom-styles"; +import transitionDuration from "./transition-duration"; +import scrollOnOpen from "./scroll-on-open"; export const accordionContent = { usage, @@ -32,4 +34,6 @@ export const accordionContent = { customMotion, controlled, customStyles, + transitionDuration, + scrollOnOpen, }; diff --git a/apps/docs/content/components/accordion/scroll-on-open.raw.jsx b/apps/docs/content/components/accordion/scroll-on-open.raw.jsx new file mode 100644 index 0000000000..ce5ae0aa71 --- /dev/null +++ b/apps/docs/content/components/accordion/scroll-on-open.raw.jsx @@ -0,0 +1,65 @@ +import {Accordion, AccordionItem, Button} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( +
+
+

Without Scroll on Open

+
+
+

+ Scroll down to see the accordion. When you expand items, they won't automatically + scroll into view. +

+
+ + +
+

{defaultContent}

+ +
+
+ + {defaultContent} + + + {defaultContent} + +
+
+
+
+

With Scroll on Open

+
+
+

+ Scroll down to see the accordion. When you expand items, they will automatically + scroll into view. +

+
+ + +
+

{defaultContent}

+ +
+
+ + {defaultContent} + + + {defaultContent} + +
+
+
+
+ ); +} diff --git a/apps/docs/content/components/accordion/scroll-on-open.ts b/apps/docs/content/components/accordion/scroll-on-open.ts new file mode 100644 index 0000000000..be68073aab --- /dev/null +++ b/apps/docs/content/components/accordion/scroll-on-open.ts @@ -0,0 +1,9 @@ +import App from "./scroll-on-open.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/accordion/transition-duration.raw.jsx b/apps/docs/content/components/accordion/transition-duration.raw.jsx new file mode 100644 index 0000000000..bd33a6e290 --- /dev/null +++ b/apps/docs/content/components/accordion/transition-duration.raw.jsx @@ -0,0 +1,67 @@ +import {Accordion, AccordionItem} from "@heroui/react"; + +export default function App() { + const defaultContent = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."; + + return ( +
+
+

Fast Transition (150ms)

+ + + {defaultContent} + + + {defaultContent} + + + {defaultContent} + + +
+
+

Default Transition (300ms)

+ + + {defaultContent} + + + {defaultContent} + + + {defaultContent} + + +
+
+

Slow Transition (800ms)

+ + + {defaultContent} + + + {defaultContent} + + + {defaultContent} + + +
+
+

Very Slow Transition (1200ms)

+ + + {defaultContent} + + + {defaultContent} + + + {defaultContent} + + +
+
+ ); +} diff --git a/apps/docs/content/components/accordion/transition-duration.ts b/apps/docs/content/components/accordion/transition-duration.ts new file mode 100644 index 0000000000..3f79f4f936 --- /dev/null +++ b/apps/docs/content/components/accordion/transition-duration.ts @@ -0,0 +1,9 @@ +import App from "./transition-duration.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/accordion.mdx b/apps/docs/content/docs/components/accordion.mdx index e474e6f981..7e4c00574c 100644 --- a/apps/docs/content/docs/components/accordion.mdx +++ b/apps/docs/content/docs/components/accordion.mdx @@ -122,6 +122,18 @@ Accordion offers a `motionProps` property to customize the `enter` / `exit` anim > Learn more about Framer motion variants [here](https://www.framer.com/motion/animation/#variants). +### Transition Duration + +Use `transitionDuration` property to customize the animations duration. + + + +### Scroll on Open + +Use `scrollOnOpen` property to automatically scroll to the content when an accordion item is expanded. + + + ### Controlled Accordion is a controlled component, which means you need to control the `selectedKeys` property by yourself. @@ -281,6 +293,18 @@ Here's an example of how to customize the accordion styles: description: "The motion properties of the Accordion.", default: "-" }, + { + attribute: "transitionDuration", + type: "number", + description: "The duration of the animations in milliseconds.", + default: 300 + }, + { + attribute: "scrollOnOpen", + type: "boolean", + description: "Whether to automatically scroll to the content when an accordion item is expanded.", + default: false + }, { attribute: "disabledKeys", type: "React.Key[]", diff --git a/packages/components/accordion/__tests__/accordion.test.tsx b/packages/components/accordion/__tests__/accordion.test.tsx index d310d2c3b8..7581e06999 100644 --- a/packages/components/accordion/__tests__/accordion.test.tsx +++ b/packages/components/accordion/__tests__/accordion.test.tsx @@ -438,4 +438,66 @@ describe("Accordion", () => { expect(getByRole("separator")).toHaveClass("bg-rose-500"); }); + + it("should scroll to content when scrollOnOpen is true", async () => { + const scrollIntoViewMock = jest.fn(); + + Element.prototype.scrollIntoView = scrollIntoViewMock; + + const wrapper = render( + + + Accordion Item 1 description + + , + ); + + const first = wrapper.getByTestId("item-1"); + const firstButton = first.querySelector("button") as HTMLElement; + + await user.click(firstButton); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(scrollIntoViewMock).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: "smooth", + block: "nearest", + }), + ); + + jest.restoreAllMocks(); + }); + + it("should apply custom transition duration", async () => { + const customDuration = 500; + const wrapper = render( + + + Accordion Item 1 description + + , + ); + + const first = wrapper.getByTestId("item-1"); + const firstButton = first.querySelector("button") as HTMLElement; + + await user.click(firstButton); + + const content = first.querySelector("section"); + + expect(content).toBeInTheDocument(); + // During animation the opacity changes from 0 to 1, reaching number close to 1 on the end + expect(content).toHaveAttribute("style", expect.stringMatching("opacity: 0")); + + // Allow framer-motion time to apply animation styles + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, customDuration + 10)); + }); + + // Match opacity values between 0.98 and 1 (inclusive) + expect(content).toHaveAttribute("style", expect.stringMatching(/opacity: (0\.9[8-9]\d*|1)/)); + }); }); diff --git a/packages/components/accordion/src/accordion-item.tsx b/packages/components/accordion/src/accordion-item.tsx index a539d92e6a..a6057c97cd 100644 --- a/packages/components/accordion/src/accordion-item.tsx +++ b/packages/components/accordion/src/accordion-item.tsx @@ -1,9 +1,9 @@ import type {Variants} from "framer-motion"; -import type {ReactNode} from "react"; import type {UseAccordionItemProps} from "./use-accordion-item"; +import type {ReactNode} from "react"; import {forwardRef} from "@heroui/system"; -import {useMemo} from "react"; +import {useMemo, useRef, useLayoutEffect} from "react"; import {ChevronIcon} from "@heroui/shared-icons"; import {AnimatePresence, LazyMotion, m, useWillChange} from "framer-motion"; import {TRANSITION_VARIANTS} from "@heroui/framer-utils"; @@ -31,6 +31,8 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => { keepContentMounted, disableAnimation, motionProps, + scrollOnOpen, + transitionDuration, getBaseProps, getHeadingProps, getButtonProps, @@ -41,6 +43,35 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => { } = useAccordionItem({...props, ref}); const willChange = useWillChange(); + const contentRef = useRef(null); + + // Handle scrolling to content when opened + useLayoutEffect(() => { + const frameIds: number[] = []; + + if (isOpen && scrollOnOpen && contentRef.current) { + // Use double RAF to ensure the animation has started and layout is updated + frameIds.push( + requestAnimationFrame(() => { + frameIds.push( + requestAnimationFrame(() => { + // Re-check current state before scrolling + if (contentRef.current && isOpen) { + contentRef.current.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + }), + ); + }), + ); + } + + return () => { + frameIds.forEach(cancelAnimationFrame); + }; + }, [isOpen, scrollOnOpen]); const indicatorContent = useMemo(() => { if (typeof indicator === "function") { @@ -57,15 +88,33 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => { const content = useMemo(() => { if (disableAnimation) { if (keepContentMounted) { - return
{children}
; + return ( +
+ {children} +
+ ); } - return isOpen &&
{children}
; + return ( + isOpen && ( +
+ {children} +
+ ) + ); } const transitionVariants: Variants = { - exit: {...TRANSITION_VARIANTS.collapse.exit, overflowY: "hidden"}, - enter: {...TRANSITION_VARIANTS.collapse.enter, overflowY: "unset"}, + exit: { + ...TRANSITION_VARIANTS.collapse.exit, + overflowY: "hidden", + transition: {duration: transitionDuration ? transitionDuration / 1000 : 0.3}, + }, + enter: { + ...TRANSITION_VARIANTS.collapse.enter, + overflowY: "unset", + transition: {duration: transitionDuration ? transitionDuration / 1000 : 0.3}, + }, }; return keepContentMounted ? ( @@ -82,7 +131,9 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => { }} {...motionProps} > -
{children}
+
+ {children} +
) : ( @@ -101,13 +152,15 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => { }} {...motionProps} > -
{children}
+
+ {children} +
)} ); - }, [isOpen, disableAnimation, keepContentMounted, children, motionProps]); + }, [isOpen, disableAnimation, keepContentMounted, children, motionProps, transitionDuration]); return ( diff --git a/packages/components/accordion/src/use-accordion-item.ts b/packages/components/accordion/src/use-accordion-item.ts index 63d904cf45..daf1eb06c0 100644 --- a/packages/components/accordion/src/use-accordion-item.ts +++ b/packages/components/accordion/src/use-accordion-item.ts @@ -42,6 +42,16 @@ export interface Props extends HTMLHeroUIProps<"div"> { * Callback fired when the focus state changes. */ onFocusChange?: (isFocused: boolean, key?: React.Key) => void; + /** + * Whether to automatically scroll to the content when expanded. + * @default false + */ + scrollOnOpen?: boolean; + /** + * Custom duration for the expand/collapse animation in milliseconds. + * @default 300 + */ + transitionDuration?: number; } export type UseAccordionItemProps = Props & @@ -71,6 +81,8 @@ export function useAccordionItem(props: UseAccordionItemP disableAnimation = globalContext?.disableAnimation ?? false, keepContentMounted = false, disableIndicatorAnimation = false, + scrollOnOpen = false, + transitionDuration = 300, HeadingComponent = as || "h2", onPress, onPressStart, @@ -276,6 +288,8 @@ export function useAccordionItem(props: UseAccordionItemP keepContentMounted, disableAnimation, motionProps, + scrollOnOpen, + transitionDuration, getBaseProps, getHeadingProps, getButtonProps, diff --git a/packages/components/accordion/src/use-accordion.ts b/packages/components/accordion/src/use-accordion.ts index 0b7a6b9dc8..b127d23a50 100644 --- a/packages/components/accordion/src/use-accordion.ts +++ b/packages/components/accordion/src/use-accordion.ts @@ -47,6 +47,16 @@ interface Props extends HTMLHeroUIProps<"div"> { * The accordion items classNames. */ itemClasses?: AccordionItemProps["classNames"]; + /** + * Whether to automatically scroll to the content when an accordion item is expanded. + * @default false + */ + scrollOnOpen?: boolean; + /** + * Custom duration for the expand/collapse animation in milliseconds. + * @default 300 + */ + transitionDuration?: number; } export type UseAccordionProps = Props & @@ -73,6 +83,8 @@ export type ValuesType = { keepContentMounted?: Props["keepContentMounted"]; disableIndicatorAnimation?: AccordionItemProps["disableAnimation"]; motionProps?: AccordionItemProps["motionProps"]; + scrollOnOpen?: boolean; + transitionDuration?: number; }; export function useAccordion(props: UseAccordionProps) { @@ -105,6 +117,8 @@ export function useAccordion(props: UseAccordionProps) { disableAnimation = globalContext?.disableAnimation ?? false, disableIndicatorAnimation = false, itemClasses, + scrollOnOpen = false, + transitionDuration = 300, ...otherProps } = props; @@ -197,6 +211,8 @@ export function useAccordion(props: UseAccordionProps) { disableAnimation, keepContentMounted, disableIndicatorAnimation, + scrollOnOpen, + transitionDuration, }), [ focusedKey, @@ -211,6 +227,8 @@ export function useAccordion(props: UseAccordionProps) { state.expandedKeys.size, state.disabledKeys.size, motionProps, + scrollOnOpen, + transitionDuration, ], ); @@ -246,6 +264,8 @@ export function useAccordion(props: UseAccordionProps) { disableAnimation, handleFocusChanged, itemClasses, + scrollOnOpen, + transitionDuration, }; } diff --git a/packages/components/accordion/stories/accordion.stories.tsx b/packages/components/accordion/stories/accordion.stories.tsx index 7a0309a8ac..e3f792fbc9 100644 --- a/packages/components/accordion/stories/accordion.stories.tsx +++ b/packages/components/accordion/stories/accordion.stories.tsx @@ -377,6 +377,57 @@ const WithFormTemplate = (args: AccordionProps) => { ); }; +const WithScrollOnOpenTemplate = (args: AccordionProps) => ( +
+

Scroll container

+
+

Scroll down to see the accordion

+
+ + +
+

{defaultContent}

+ +
+
+ + {defaultContent} + + + {defaultContent} + +
+
+); + +const WithCustomTransitionDurationTemplate = (args: AccordionProps) => ( +
+
+

Fast Transition (150ms)

+ + + {defaultContent} + + + {defaultContent} + + +
+ +
+

Slow Transition (800ms)

+ + + {defaultContent} + + + {defaultContent} + + +
+
+); + export const Default = { render: Template, @@ -529,3 +580,19 @@ export const CustomWithClassNames = { ...defaultProps, }, }; + +export const WithScrollOnOpen = { + render: WithScrollOnOpenTemplate, + + args: { + ...defaultProps, + }, +}; + +export const WithCustomTransitionDuration = { + render: WithCustomTransitionDurationTemplate, + + args: { + ...defaultProps, + }, +};