Skip to content

Commit f7a09c3

Browse files
committed
feat(accordion): add transitionDuration and scrollOnOpen props
1 parent b38a2cf commit f7a09c3

6 files changed

Lines changed: 218 additions & 9 deletions

File tree

.changeset/shy-kiwis-bake.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@heroui/accordion": patch
3+
---
4+
5+
- Introduce new prop `scrollOnOpen?: boolean` to automatically scroll to the content when expanded
6+
- Introduce new prop `transitionDuration?: number` to customize animation speed. Defaults to 300ms as it is right now

packages/components/accordion/__tests__/accordion.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,66 @@ describe("Accordion", () => {
438438

439439
expect(getByRole("separator")).toHaveClass("bg-rose-500");
440440
});
441+
442+
it("should scroll to content when scrollOnOpen is true", async () => {
443+
const scrollIntoViewMock = jest.fn();
444+
445+
Element.prototype.scrollIntoView = scrollIntoViewMock;
446+
447+
const wrapper = render(
448+
<Accordion scrollOnOpen>
449+
<AccordionItem key="1" data-testid="item-1" title="Accordion Item 1">
450+
Accordion Item 1 description
451+
</AccordionItem>
452+
</Accordion>,
453+
);
454+
455+
const first = wrapper.getByTestId("item-1");
456+
const firstButton = first.querySelector("button") as HTMLElement;
457+
458+
await user.click(firstButton);
459+
460+
await act(async () => {
461+
await new Promise((resolve) => setTimeout(resolve, 100));
462+
});
463+
464+
expect(scrollIntoViewMock).toHaveBeenCalledWith(
465+
expect.objectContaining({
466+
behavior: "smooth",
467+
block: "nearest",
468+
}),
469+
);
470+
471+
jest.restoreAllMocks();
472+
});
473+
474+
it("should apply custom transition duration", async () => {
475+
const customDuration = 500;
476+
const wrapper = render(
477+
<Accordion disableAnimation={false} transitionDuration={customDuration}>
478+
<AccordionItem key="1" data-testid="item-1" title="Accordion Item 1">
479+
Accordion Item 1 description
480+
</AccordionItem>
481+
</Accordion>,
482+
);
483+
484+
const first = wrapper.getByTestId("item-1");
485+
const firstButton = first.querySelector("button") as HTMLElement;
486+
487+
await user.click(firstButton);
488+
489+
const content = first.querySelector("section");
490+
491+
expect(content).toBeInTheDocument();
492+
// During animation the opacity changes from 0 to 1, reaching number close to 1 on the end
493+
expect(content).toHaveAttribute("style", expect.stringMatching("opacity: 0"));
494+
495+
// Allow framer-motion time to apply animation styles
496+
await act(async () => {
497+
await new Promise((resolve) => setTimeout(resolve, customDuration + 10));
498+
});
499+
500+
// Match opacity values between 0.98 and 1 (inclusive)
501+
expect(content).toHaveAttribute("style", expect.stringMatching(/opacity: (0\.9[8-9]\d*|1)/));
502+
});
441503
});

packages/components/accordion/src/accordion-item.tsx

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type {Variants} from "framer-motion";
2-
import type {ReactNode} from "react";
32
import type {UseAccordionItemProps} from "./use-accordion-item";
3+
import type {ReactNode} from "react";
44

55
import {forwardRef} from "@heroui/system";
6-
import {useMemo} from "react";
6+
import {useMemo, useRef, useEffect} from "react";
77
import {ChevronIcon} from "@heroui/shared-icons";
88
import {AnimatePresence, LazyMotion, m, useWillChange} from "framer-motion";
99
import {TRANSITION_VARIANTS} from "@heroui/framer-utils";
@@ -31,6 +31,8 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => {
3131
keepContentMounted,
3232
disableAnimation,
3333
motionProps,
34+
scrollOnOpen,
35+
transitionDuration,
3436
getBaseProps,
3537
getHeadingProps,
3638
getButtonProps,
@@ -41,6 +43,22 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => {
4143
} = useAccordionItem({...props, ref});
4244

4345
const willChange = useWillChange();
46+
const contentRef = useRef<HTMLDivElement>(null);
47+
48+
// Handle scrolling to content when opened
49+
useEffect(() => {
50+
if (isOpen && scrollOnOpen && contentRef.current) {
51+
const content = contentRef.current;
52+
53+
// Wait for animation to start
54+
setTimeout(() => {
55+
content.scrollIntoView({
56+
behavior: "smooth",
57+
block: "nearest",
58+
});
59+
}, 50);
60+
}
61+
}, [isOpen, scrollOnOpen]);
4462

4563
const indicatorContent = useMemo<ReactNode>(() => {
4664
if (typeof indicator === "function") {
@@ -57,15 +75,33 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => {
5775
const content = useMemo(() => {
5876
if (disableAnimation) {
5977
if (keepContentMounted) {
60-
return <div {...getContentProps()}>{children}</div>;
78+
return (
79+
<div ref={contentRef} {...getContentProps()}>
80+
{children}
81+
</div>
82+
);
6183
}
6284

63-
return isOpen && <div {...getContentProps()}>{children}</div>;
85+
return (
86+
isOpen && (
87+
<div ref={contentRef} {...getContentProps()}>
88+
{children}
89+
</div>
90+
)
91+
);
6492
}
6593

6694
const transitionVariants: Variants = {
67-
exit: {...TRANSITION_VARIANTS.collapse.exit, overflowY: "hidden"},
68-
enter: {...TRANSITION_VARIANTS.collapse.enter, overflowY: "unset"},
95+
exit: {
96+
...TRANSITION_VARIANTS.collapse.exit,
97+
overflowY: "hidden",
98+
transition: {duration: transitionDuration ? transitionDuration / 1000 : 0.3},
99+
},
100+
enter: {
101+
...TRANSITION_VARIANTS.collapse.enter,
102+
overflowY: "unset",
103+
transition: {duration: transitionDuration ? transitionDuration / 1000 : 0.3},
104+
},
69105
};
70106

71107
return keepContentMounted ? (
@@ -82,7 +118,9 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => {
82118
}}
83119
{...motionProps}
84120
>
85-
<div {...getContentProps()}>{children}</div>
121+
<div ref={contentRef} {...getContentProps()}>
122+
{children}
123+
</div>
86124
</m.section>
87125
</LazyMotion>
88126
) : (
@@ -101,13 +139,15 @@ const AccordionItem = forwardRef<"button", AccordionItemProps>((props, ref) => {
101139
}}
102140
{...motionProps}
103141
>
104-
<div {...getContentProps()}>{children}</div>
142+
<div ref={contentRef} {...getContentProps()}>
143+
{children}
144+
</div>
105145
</m.section>
106146
</LazyMotion>
107147
)}
108148
</AnimatePresence>
109149
);
110-
}, [isOpen, disableAnimation, keepContentMounted, children, motionProps]);
150+
}, [isOpen, disableAnimation, keepContentMounted, children, motionProps, transitionDuration]);
111151

112152
return (
113153
<Component {...getBaseProps()}>

packages/components/accordion/src/use-accordion-item.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ export interface Props<T extends object> extends HTMLHeroUIProps<"div"> {
4242
* Callback fired when the focus state changes.
4343
*/
4444
onFocusChange?: (isFocused: boolean, key?: React.Key) => void;
45+
/**
46+
* Whether to automatically scroll to the content when expanded.
47+
* @default false
48+
*/
49+
scrollOnOpen?: boolean;
50+
/**
51+
* Custom duration for the expand/collapse animation in milliseconds.
52+
* @default 300
53+
*/
54+
transitionDuration?: number;
4555
}
4656

4757
export type UseAccordionItemProps<T extends object = {}> = Props<T> &
@@ -71,6 +81,8 @@ export function useAccordionItem<T extends object = {}>(props: UseAccordionItemP
7181
disableAnimation = globalContext?.disableAnimation ?? false,
7282
keepContentMounted = false,
7383
disableIndicatorAnimation = false,
84+
scrollOnOpen = false,
85+
transitionDuration = 300,
7486
HeadingComponent = as || "h2",
7587
onPress,
7688
onPressStart,
@@ -276,6 +288,8 @@ export function useAccordionItem<T extends object = {}>(props: UseAccordionItemP
276288
keepContentMounted,
277289
disableAnimation,
278290
motionProps,
291+
scrollOnOpen,
292+
transitionDuration,
279293
getBaseProps,
280294
getHeadingProps,
281295
getButtonProps,

packages/components/accordion/src/use-accordion.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ interface Props extends HTMLHeroUIProps<"div"> {
4747
* The accordion items classNames.
4848
*/
4949
itemClasses?: AccordionItemProps["classNames"];
50+
/**
51+
* Whether to automatically scroll to the content when an accordion item is expanded.
52+
* @default false
53+
*/
54+
scrollOnOpen?: boolean;
55+
/**
56+
* Custom duration for the expand/collapse animation in milliseconds.
57+
* @default 300
58+
*/
59+
transitionDuration?: number;
5060
}
5161

5262
export type UseAccordionProps<T extends object = {}> = Props &
@@ -73,6 +83,8 @@ export type ValuesType<T extends object = {}> = {
7383
keepContentMounted?: Props["keepContentMounted"];
7484
disableIndicatorAnimation?: AccordionItemProps["disableAnimation"];
7585
motionProps?: AccordionItemProps["motionProps"];
86+
scrollOnOpen?: boolean;
87+
transitionDuration?: number;
7688
};
7789

7890
export function useAccordion<T extends object>(props: UseAccordionProps<T>) {
@@ -105,6 +117,8 @@ export function useAccordion<T extends object>(props: UseAccordionProps<T>) {
105117
disableAnimation = globalContext?.disableAnimation ?? false,
106118
disableIndicatorAnimation = false,
107119
itemClasses,
120+
scrollOnOpen = false,
121+
transitionDuration = 300,
108122
...otherProps
109123
} = props;
110124

@@ -197,6 +211,8 @@ export function useAccordion<T extends object>(props: UseAccordionProps<T>) {
197211
disableAnimation,
198212
keepContentMounted,
199213
disableIndicatorAnimation,
214+
scrollOnOpen,
215+
transitionDuration,
200216
}),
201217
[
202218
focusedKey,
@@ -211,6 +227,8 @@ export function useAccordion<T extends object>(props: UseAccordionProps<T>) {
211227
state.expandedKeys.size,
212228
state.disabledKeys.size,
213229
motionProps,
230+
scrollOnOpen,
231+
transitionDuration,
214232
],
215233
);
216234

@@ -246,6 +264,8 @@ export function useAccordion<T extends object>(props: UseAccordionProps<T>) {
246264
disableAnimation,
247265
handleFocusChanged,
248266
itemClasses,
267+
scrollOnOpen,
268+
transitionDuration,
249269
};
250270
}
251271

packages/components/accordion/stories/accordion.stories.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,57 @@ const WithFormTemplate = (args: AccordionProps) => {
377377
);
378378
};
379379

380+
const WithScrollOnOpenTemplate = (args: AccordionProps) => (
381+
<div className="h-96 overflow-auto p-4 border rounded">
382+
<h3 className="text-xl font-bold mb-6">Scroll container</h3>
383+
<div className="mb-96">
384+
<p className="mb-6">Scroll down to see the accordion</p>
385+
</div>
386+
<Accordion {...args} scrollOnOpen>
387+
<AccordionItem key="1" aria-label="Accordion 1" title="Scroll on open (try opening this)">
388+
<div className="p-4">
389+
<p className="mb-4">{defaultContent}</p>
390+
<Button color="primary">This will be scrolled into view</Button>
391+
</div>
392+
</AccordionItem>
393+
<AccordionItem key="2" aria-label="Accordion 2" title="Accordion 2">
394+
{defaultContent}
395+
</AccordionItem>
396+
<AccordionItem key="3" aria-label="Accordion 3" title="Accordion 3">
397+
{defaultContent}
398+
</AccordionItem>
399+
</Accordion>
400+
</div>
401+
);
402+
403+
const WithCustomTransitionDurationTemplate = (args: AccordionProps) => (
404+
<div className="flex flex-col gap-8">
405+
<div>
406+
<h3 className="mb-2 font-medium">Fast Transition (150ms)</h3>
407+
<Accordion {...args} transitionDuration={150}>
408+
<AccordionItem key="1" aria-label="Accordion 1" title="Fast transition accordion">
409+
{defaultContent}
410+
</AccordionItem>
411+
<AccordionItem key="2" aria-label="Accordion 2" title="Accordion 2">
412+
{defaultContent}
413+
</AccordionItem>
414+
</Accordion>
415+
</div>
416+
417+
<div>
418+
<h3 className="mb-2 font-medium">Slow Transition (800ms)</h3>
419+
<Accordion {...args} transitionDuration={800}>
420+
<AccordionItem key="1" aria-label="Accordion 1" title="Slow transition accordion">
421+
{defaultContent}
422+
</AccordionItem>
423+
<AccordionItem key="2" aria-label="Accordion 2" title="Accordion 2">
424+
{defaultContent}
425+
</AccordionItem>
426+
</Accordion>
427+
</div>
428+
</div>
429+
);
430+
380431
export const Default = {
381432
render: Template,
382433

@@ -529,3 +580,19 @@ export const CustomWithClassNames = {
529580
...defaultProps,
530581
},
531582
};
583+
584+
export const WithScrollOnOpen = {
585+
render: WithScrollOnOpenTemplate,
586+
587+
args: {
588+
...defaultProps,
589+
},
590+
};
591+
592+
export const WithCustomTransitionDuration = {
593+
render: WithCustomTransitionDurationTemplate,
594+
595+
args: {
596+
...defaultProps,
597+
},
598+
};

0 commit comments

Comments
 (0)