Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/storybook/src/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,10 @@ WithArrowIcon.storyName = "With arrow icon";
WithArrowIcon.args = {
arrowIcon: HiOutlineArrowCircleDown,
};

export const WithAnimation = Template.bind({});
WithAnimation.storyName = "With animation";
WithAnimation.args = {
animate: true,
animationDuration: 300,
};
6 changes: 6 additions & 0 deletions apps/web/content/docs/components/accordion.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ Use this example to automatically collapse all of the accordion panels by passin

<Example name="accordion.collapseAll" />

## With animation

Enable smooth open/close animation by passing `animate` and `animationDuration` (in milliseconds) to the `<Accordion>` component.

<Example name="accordion.animation" />

## Theme

To learn more about how to customize the appearance of components, please see the [Theme docs](/docs/customize/theme).
Expand Down
163 changes: 163 additions & 0 deletions apps/web/examples/accordion/accordion.animation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Accordion, AccordionContent, AccordionPanel, AccordionTitle } from "flowbite-react";
import type { CodeData } from "~/components/code-demo";

const code = `
import { Accordion, AccordionContent, AccordionPanel, AccordionTitle } from "flowbite-react";

export function Component() {
return (
<Accordion animate animationDuration={300}>
<AccordionPanel>
<AccordionTitle>What is Flowbite?</AccordionTitle>
<AccordionContent>
<p className="mb-2 text-gray-500 dark:text-gray-400">
Flowbite is an open-source library of interactive components built on top of Tailwind CSS including buttons,
dropdowns, modals, navbars, and more.
</p>
<p className="text-gray-500 dark:text-gray-400">
Check out this guide to learn how to&nbsp;
<a
href="https://flowbite.com/docs/getting-started/introduction/"
className="text-cyan-600 hover:underline dark:text-cyan-500"
>
get started&nbsp;
</a>
and start developing websites even faster with components on top of Tailwind CSS.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel>
<AccordionTitle>Is there a Figma file available?</AccordionTitle>
<AccordionContent>
<p className="mb-2 text-gray-500 dark:text-gray-400">
Flowbite is first conceptualized and designed using the Figma software so everything you see in the library
has a design equivalent in our Figma file.
</p>
<p className="text-gray-500 dark:text-gray-400">
Check out the
<a href="https://flowbite.com/figma/" className="text-cyan-600 hover:underline dark:text-cyan-500">
Figma design system
</a>
based on the utility classes from Tailwind CSS and components from Flowbite.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel>
<AccordionTitle>What are the differences between Flowbite and Tailwind UI?</AccordionTitle>
<AccordionContent>
<p className="mb-2 text-gray-500 dark:text-gray-400">
The main difference is that the core components from Flowbite are open source under the MIT license, whereas
Tailwind UI is a paid product. Another difference is that Flowbite relies on smaller and standalone
components, whereas Tailwind UI offers sections of pages.
</p>
<p className="mb-2 text-gray-500 dark:text-gray-400">
However, we actually recommend using both Flowbite, Flowbite Pro, and even Tailwind UI as there is no
technical reason stopping you from using the best of two worlds.
</p>
<p className="mb-2 text-gray-500 dark:text-gray-400">Learn more about these technologies:</p>
<ul className="list-disc pl-5 text-gray-500 dark:text-gray-400">
<li>
<a href="https://flowbite.com/pro/" className="text-cyan-600 hover:underline dark:text-cyan-500">
Flowbite Pro
</a>
</li>
<li>
<a
href="https://tailwindui.com/"
rel="nofollow"
className="text-cyan-600 hover:underline dark:text-cyan-500"
>
Tailwind UI
</a>
</li>
</ul>
</AccordionContent>
</AccordionPanel>
</Accordion>
);
}
`;

export function Component() {
return (
<Accordion animate animationDuration={300}>
<AccordionPanel>
<AccordionTitle>What is Flowbite?</AccordionTitle>
<AccordionContent>
<p className="mb-2 text-gray-500 dark:text-gray-400">
Flowbite is an open-source library of interactive components built on top of Tailwind CSS including buttons,
dropdowns, modals, navbars, and more.
</p>
<p className="text-gray-500 dark:text-gray-400">
Check out this guide to learn how to&nbsp;
<a
href="https://flowbite.com/docs/getting-started/introduction/"
className="text-cyan-600 hover:underline dark:text-cyan-500"
>
get started&nbsp;
</a>
and start developing websites even faster with components on top of Tailwind CSS.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel>
<AccordionTitle>Is there a Figma file available?</AccordionTitle>
<AccordionContent>
<p className="mb-2 text-gray-500 dark:text-gray-400">
Flowbite is first conceptualized and designed using the Figma software so everything you see in the library
has a design equivalent in our Figma file.
</p>
<p className="text-gray-500 dark:text-gray-400">
Check out the
<a href="https://flowbite.com/figma/" className="text-cyan-600 hover:underline dark:text-cyan-500">
Figma design system
</a>
based on the utility classes from Tailwind CSS and components from Flowbite.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel>
<AccordionTitle>What are the differences between Flowbite and Tailwind UI?</AccordionTitle>
<AccordionContent>
<p className="mb-2 text-gray-500 dark:text-gray-400">
The main difference is that the core components from Flowbite are open source under the MIT license, whereas
Tailwind UI is a paid product. Another difference is that Flowbite relies on smaller and standalone
components, whereas Tailwind UI offers sections of pages.
</p>
<p className="mb-2 text-gray-500 dark:text-gray-400">
However, we actually recommend using both Flowbite, Flowbite Pro, and even Tailwind UI as there is no
technical reason stopping you from using the best of two worlds.
</p>
<p className="mb-2 text-gray-500 dark:text-gray-400">Learn more about these technologies:</p>
<ul className="list-disc pl-5 text-gray-500 dark:text-gray-400">
<li>
<a href="https://flowbite.com/pro/" className="text-cyan-600 hover:underline dark:text-cyan-500">
Flowbite Pro
</a>
</li>
<li>
<a
href="https://tailwindui.com/"
rel="nofollow"
className="text-cyan-600 hover:underline dark:text-cyan-500"
>
Tailwind UI
</a>
</li>
</ul>
</AccordionContent>
</AccordionPanel>
</Accordion>
);
}

export const animation: CodeData = {
type: "single",
code: {
fileName: "index",
language: "tsx",
code,
},
githubSlug: "accordion/accordion.animation.tsx",
component: <Component />,
};
1 change: 1 addition & 0 deletions apps/web/examples/accordion/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { animation } from "./accordion.animation";
export { collapseAll } from "./accordion.collapseAll";
export { root } from "./accordion.root";
69 changes: 69 additions & 0 deletions packages/ui/src/components/Accordion/Accordion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,75 @@ describe("Components / Accordion", () => {
expect(content()[1]).not.toBeVisible(); // content should not be visible
});
});

describe("Animation", () => {
it("should apply transition styles when `animate` is enabled", () => {
render(<TestAccordion animate animationDuration={400} />);

const animatedContent = content()[0];

expect(animatedContent).toHaveStyle("transition: max-height 400ms ease-out");
expect(animatedContent).toHaveAttribute("aria-hidden", "false");
});

it("should not break the animation when `animate` is disabled", () => {
render(<TestAccordion />);

const firstContent = content()[0];

expect(firstContent).not.toHaveStyle("transition: max-height 400ms ease-out");
expect(firstContent).not.toHaveAttribute("aria-hidden");
});

it("should use the default `animationDuration` when `animate` is enabled and `animationDuration` is not provided", () => {
render(<TestAccordion animate />);

const firstContent = content()[0];

expect(firstContent).toHaveStyle("transition: max-height 300ms ease-out");
expect(firstContent).toHaveAttribute("aria-hidden", "false");
});

it("should set inert on closed animated content to suppress focus", () => {
render(<TestAccordion animate collapseAll />);

const firstContent = content()[0];

expect(firstContent).toHaveAttribute("aria-hidden", "true");
expect(firstContent).toHaveAttribute("inert");
});

it("should set inert when closed and remove when open; focusable descendants reachable when open", async () => {
const user = userEvent.setup();
render(
<Accordion animate>
<AccordionPanel>
<AccordionTitle>First</AccordionTitle>
<AccordionContent>
<a href="#link">Link inside</a>
</AccordionContent>
</AccordionPanel>
<AccordionPanel>
<AccordionTitle>Second</AccordionTitle>
<AccordionContent>
<p>Content</p>
</AccordionContent>
</AccordionPanel>
</Accordion>,
);

const link = screen.getByText("Link inside");
const firstTitle = screen.getByRole("button", { name: "First" });

await user.click(screen.getByRole("button", { name: "Second" }));
expect(content()[0]).toHaveAttribute("inert");

await user.click(firstTitle);
expect(content()[0]).not.toHaveAttribute("inert");
await user.tab();
expect(link).toHaveFocus();
});
});
});

const TestAccordion = (props: Omit<AccordionProps, "children">) => (
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/components/Accordion/Accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export interface AccordionRootTheme {

export interface AccordionProps extends ComponentProps<"div">, ThemingProps<AccordionRootTheme> {
alwaysOpen?: boolean;
/** Enable smooth open/close animation for panel content. */
animate?: boolean;
/** Duration of the open/close animation in milliseconds. Only used when `animate` is true. Defaults to 300. */
animationDuration?: number;
arrowIcon?: FC<ComponentProps<"svg">>;
children: ReactElement<AccordionPanelProps> | ReactElement<AccordionPanelProps>[];
flush?: boolean;
Expand All @@ -43,6 +47,8 @@ export function Accordion(props: AccordionProps) {

const {
alwaysOpen = false,
animate = false,
animationDuration = 300,
Comment on lines +50 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard animationDuration against invalid runtime values.

Line 51 defaults only when the prop is undefined; values like negative numbers/NaN still flow through and can produce broken transition behavior downstream. Add a small clamp/sanitize step before passing it to panels.

Suggested fix
-  const {
+  const {
     alwaysOpen = false,
     animate = false,
-    animationDuration = 300,
+    animationDuration: animationDurationProp = 300,
     arrowIcon = ChevronDownIcon,
     children,
     flush = false,
     collapseAll = false,
     className,
     ...restProps
   } = resolveProps(props, provider.props?.accordion);
+
+  const animationDuration =
+    Number.isFinite(animationDurationProp) && animationDurationProp >= 0 ? animationDurationProp : 300;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/Accordion/Accordion.tsx` around lines 50 - 51,
Sanitize the animationDuration prop inside the Accordion component before
passing it to child panels: ensure animationDuration is a finite number and
non-negative (e.g., if not Number.isFinite(animationDuration) or
animationDuration < 0, use the default 300), then pass that sanitized value to
panels/AccordionPanel; do this in the Accordion function body (where animate and
animationDuration are de/structured) so downstream transition code never
receives NaN or negative durations.

arrowIcon = ChevronDownIcon,
children,
flush = false,
Expand All @@ -58,13 +64,15 @@ export function Accordion(props: AccordionProps) {
Children.map(children, (child, i) =>
cloneElement(child, {
alwaysOpen,
animate,
animationDuration,
arrowIcon,
flush,
isOpen: isOpen === i,
setOpen: () => setOpen(isOpen === i ? -1 : i),
}),
),
[alwaysOpen, arrowIcon, children, flush, isOpen],
[alwaysOpen, animate, animationDuration, arrowIcon, children, flush, isOpen],
);

return (
Expand Down
44 changes: 42 additions & 2 deletions packages/ui/src/components/Accordion/AccordionContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import type { ComponentProps } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { get } from "../../helpers/get";
import { resolveProps } from "../../helpers/resolve-props";
import { useResolveTheme } from "../../helpers/resolve-theme";
Expand All @@ -17,7 +18,28 @@ export interface AccordionContentTheme {
export interface AccordionContentProps extends ComponentProps<"div">, ThemingProps<AccordionContentTheme> {}

export function AccordionContent(props: AccordionContentProps) {
const { isOpen } = useAccordionContext();
const { isOpen, animate = false, animationDuration = 300 } = useAccordionContext();
const contentRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number>(0);

useLayoutEffect(() => {
if (!animate || !isOpen) return;
const el = contentRef.current;
if (el) setHeight(el.scrollHeight);
}, [animate, isOpen]);

/** When closed, set inert on the wrapper so descendants are not focusable. */
useLayoutEffect(() => {
if (!animate) return;
const wrapper = wrapperRef.current;
if (!wrapper) return;
if (isOpen) {
wrapper.removeAttribute("inert");
} else {
wrapper.setAttribute("inert", "");
}
}, [animate, isOpen]);

const provider = useThemeProvider();
const theme = useResolveTheme(
Expand All @@ -28,13 +50,31 @@ export function AccordionContent(props: AccordionContentProps) {

const { className, ...restProps } = resolveProps(props, provider.props?.accordionContent);

return (
const handleTransitionEnd = () => {
if (!isOpen) setHeight(0);
};

return !animate ? (
<div
className={twMerge(theme.base, className)}
data-testid="flowbite-accordion-content"
hidden={!isOpen}
{...restProps}
/>
) : (
<div
ref={wrapperRef}
data-testid="flowbite-accordion-content"
aria-hidden={!isOpen}
style={{
overflow: "hidden",
maxHeight: isOpen ? height : 0,
transition: `max-height ${animationDuration}ms ease-out`,
}}
onTransitionEnd={handleTransitionEnd}
>
<div ref={contentRef} className={twMerge(theme.base, className)} {...restProps} />
</div>
);
}

Expand Down
Loading