diff --git a/apps/storybook/src/Accordion.stories.tsx b/apps/storybook/src/Accordion.stories.tsx index 3dab5be876..141d67e4c3 100644 --- a/apps/storybook/src/Accordion.stories.tsx +++ b/apps/storybook/src/Accordion.stories.tsx @@ -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, +}; diff --git a/apps/web/content/docs/components/accordion.mdx b/apps/web/content/docs/components/accordion.mdx index dc56a46587..ea3ae7b6bc 100644 --- a/apps/web/content/docs/components/accordion.mdx +++ b/apps/web/content/docs/components/accordion.mdx @@ -25,6 +25,12 @@ Use this example to automatically collapse all of the accordion panels by passin +## With animation + +Enable smooth open/close animation by passing `animate` and `animationDuration` (in milliseconds) to the `` component. + + + ## Theme To learn more about how to customize the appearance of components, please see the [Theme docs](/docs/customize/theme). diff --git a/apps/web/examples/accordion/accordion.animation.tsx b/apps/web/examples/accordion/accordion.animation.tsx new file mode 100644 index 0000000000..005cac12f5 --- /dev/null +++ b/apps/web/examples/accordion/accordion.animation.tsx @@ -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 ( + + + What is Flowbite? + +

+ Flowbite is an open-source library of interactive components built on top of Tailwind CSS including buttons, + dropdowns, modals, navbars, and more. +

+

+ Check out this guide to learn how to  + + get started  + + and start developing websites even faster with components on top of Tailwind CSS. +

+
+
+ + Is there a Figma file available? + +

+ 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. +

+

+ Check out the + + Figma design system + + based on the utility classes from Tailwind CSS and components from Flowbite. +

+
+
+ + What are the differences between Flowbite and Tailwind UI? + +

+ 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. +

+

+ 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. +

+

Learn more about these technologies:

+ +
+
+
+ ); +} +`; + +export function Component() { + return ( + + + What is Flowbite? + +

+ Flowbite is an open-source library of interactive components built on top of Tailwind CSS including buttons, + dropdowns, modals, navbars, and more. +

+

+ Check out this guide to learn how to  + + get started  + + and start developing websites even faster with components on top of Tailwind CSS. +

+
+
+ + Is there a Figma file available? + +

+ 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. +

+

+ Check out the + + Figma design system + + based on the utility classes from Tailwind CSS and components from Flowbite. +

+
+
+ + What are the differences between Flowbite and Tailwind UI? + +

+ 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. +

+

+ 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. +

+

Learn more about these technologies:

+ +
+
+
+ ); +} + +export const animation: CodeData = { + type: "single", + code: { + fileName: "index", + language: "tsx", + code, + }, + githubSlug: "accordion/accordion.animation.tsx", + component: , +}; diff --git a/apps/web/examples/accordion/index.ts b/apps/web/examples/accordion/index.ts index 99694a61ee..83db6c3010 100644 --- a/apps/web/examples/accordion/index.ts +++ b/apps/web/examples/accordion/index.ts @@ -1,2 +1,3 @@ +export { animation } from "./accordion.animation"; export { collapseAll } from "./accordion.collapseAll"; export { root } from "./accordion.root"; diff --git a/packages/ui/src/components/Accordion/Accordion.test.tsx b/packages/ui/src/components/Accordion/Accordion.test.tsx index f34d11052d..40abcc18b9 100644 --- a/packages/ui/src/components/Accordion/Accordion.test.tsx +++ b/packages/ui/src/components/Accordion/Accordion.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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( + + + First + + Link inside + + + + Second + +

Content

+
+
+
, + ); + + 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) => ( diff --git a/packages/ui/src/components/Accordion/Accordion.tsx b/packages/ui/src/components/Accordion/Accordion.tsx index fb0816b3c7..7ec3b0e9e1 100644 --- a/packages/ui/src/components/Accordion/Accordion.tsx +++ b/packages/ui/src/components/Accordion/Accordion.tsx @@ -27,6 +27,10 @@ export interface AccordionRootTheme { export interface AccordionProps extends ComponentProps<"div">, ThemingProps { 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>; children: ReactElement | ReactElement[]; flush?: boolean; @@ -43,6 +47,8 @@ export function Accordion(props: AccordionProps) { const { alwaysOpen = false, + animate = false, + animationDuration = 300, arrowIcon = ChevronDownIcon, children, flush = false, @@ -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 ( diff --git a/packages/ui/src/components/Accordion/AccordionContent.tsx b/packages/ui/src/components/Accordion/AccordionContent.tsx index 5c59de00bd..7cd21c54d7 100644 --- a/packages/ui/src/components/Accordion/AccordionContent.tsx +++ b/packages/ui/src/components/Accordion/AccordionContent.tsx @@ -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"; @@ -17,7 +18,28 @@ export interface AccordionContentTheme { export interface AccordionContentProps extends ComponentProps<"div">, ThemingProps {} export function AccordionContent(props: AccordionContentProps) { - const { isOpen } = useAccordionContext(); + const { isOpen, animate = false, animationDuration = 300 } = useAccordionContext(); + const contentRef = useRef(null); + const wrapperRef = useRef(null); + const [height, setHeight] = useState(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( @@ -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 ? (