diff --git a/apps/web/content/docs/components/alert.mdx b/apps/web/content/docs/components/alert.mdx index 3f51b43c20..e5015f6ca7 100644 --- a/apps/web/content/docs/components/alert.mdx +++ b/apps/web/content/docs/components/alert.mdx @@ -33,6 +33,16 @@ You can use the `onDismiss` prop on the `` component to add a dismiss but +## Dismiss transition + +Animate the alert while it is being dismissed by passing the `transition`, `timing` and `duration` props, mirroring the [options of vanilla Flowbite's `Dismiss` class](https://flowbite.com/docs/components/alerts/#options). `onDismiss` fires once the transition has finished. + +- `transition` — a Tailwind CSS transition utility class (default: `transition-opacity`) +- `timing` — a Tailwind CSS transition-timing-function utility class (default: `ease-out`) +- `duration` — the length of the transition in milliseconds (default: `300`) + + + ## Rounded alert To make the alert box rounded you can use the `rounded` prop on the `` component. diff --git a/apps/web/examples/alert/alert.transition.tsx b/apps/web/examples/alert/alert.transition.tsx new file mode 100644 index 0000000000..e41bb691a2 --- /dev/null +++ b/apps/web/examples/alert/alert.transition.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Alert } from "flowbite-react"; +import { useState } from "react"; +import type { CodeData } from "~/components/code-demo"; + +const code = ` +"use client"; + +import { useState } from "react"; +import { Alert } from "flowbite-react"; + +export function Component() { + const [isDismissed, setIsDismissed] = useState(false); + + if (isDismissed) { + return null; + } + + return ( + setIsDismissed(true)} + timing="ease-in-out" + transition="transition-opacity" + > + Success alert! Dismiss me to see the fade transition. + + ); +} +`; + +export function Component() { + const [isDismissed, setIsDismissed] = useState(false); + + if (isDismissed) { + return null; + } + + return ( + setIsDismissed(true)} + timing="ease-in-out" + transition="transition-opacity" + > + Success alert! Dismiss me to see the fade transition. + + ); +} + +export const transition: CodeData = { + type: "single", + code: { + fileName: "index", + language: "tsx", + code, + }, + githubSlug: "alert/alert.transition.tsx", + component: , +}; diff --git a/apps/web/examples/alert/index.ts b/apps/web/examples/alert/index.ts index 4e6a9ff8d6..420e2c3fa1 100644 --- a/apps/web/examples/alert/index.ts +++ b/apps/web/examples/alert/index.ts @@ -4,4 +4,5 @@ export { borderAccent } from "./alert.borderAccent"; export { dismissible } from "./alert.dismissible"; export { root } from "./alert.root"; export { rounded } from "./alert.rounded"; +export { transition } from "./alert.transition"; export { withIcon } from "./alert.withIcon"; diff --git a/packages/ui/src/components/Alert/Alert.test.tsx b/packages/ui/src/components/Alert/Alert.test.tsx index b4ef4b3d61..24032fd8ab 100644 --- a/packages/ui/src/components/Alert/Alert.test.tsx +++ b/packages/ui/src/components/Alert/Alert.test.tsx @@ -123,11 +123,75 @@ describe("Components / Alert", () => { }); }); + describe("Dismiss transition", () => { + it("should apply the default transition classes while dismissing", async () => { + const user = userEvent.setup(); + render(); + + await user.click(dismiss()); + + expect(alert()).toHaveClass("transition-opacity", "duration-300", "ease-out", "opacity-0"); + }); + + it("should fire `onDismiss` after the `duration` has elapsed", async () => { + const onDismiss = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(dismiss()); + + expect(onDismiss).not.toHaveBeenCalled(); + + await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1)); + }); + + it("should use the custom `transition`, `timing` and `duration` props", async () => { + const user = userEvent.setup(); + render(); + + await user.click(dismiss()); + + expect(alert()).toHaveClass("transition-transform", "duration-1000", "ease-in", "opacity-0"); + }); + + it("should honour a custom `transition` theme", async () => { + const theme: CustomFlowbiteTheme = { + alert: { + transition: { + base: "transition-transform", + duration: "duration-700", + timing: "ease-in-out", + }, + }, + }; + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(dismiss()); + + expect(alert()).toHaveClass("transition-transform", "duration-700", "ease-in-out", "opacity-0"); + }); + + it("should fire `onDismiss` immediately when `duration` is 0", async () => { + const onDismiss = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(dismiss()); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); + describe("Keyboard interactions", () => { it("should dismiss when `Tab` is pressed to navigate to Dismiss button and `Space` is pressed", async () => { const onDismiss = vi.fn(); const user = userEvent.setup(); - render(); + render(); await waitFor(async () => { await user.tab(); @@ -142,10 +206,10 @@ describe("Components / Alert", () => { }); describe("Props", () => { - it("should call `onDismiss` when clicked", async () => { + it("should call `onDismiss` when clicked (after the dismiss transition)", async () => { const onDismiss = vi.fn(); const user = userEvent.setup(); - render(); + render(); await user.click(dismiss()); diff --git a/packages/ui/src/components/Alert/Alert.tsx b/packages/ui/src/components/Alert/Alert.tsx index 2d553da583..a48de892fa 100644 --- a/packages/ui/src/components/Alert/Alert.tsx +++ b/packages/ui/src/components/Alert/Alert.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, type ComponentProps, type FC, type ReactNode } from "react"; +import { forwardRef, useEffect, useState, type ComponentProps, type FC, type ReactNode } from "react"; import { get } from "../../helpers/get"; import { resolveProps } from "../../helpers/resolve-props"; import { useResolveTheme } from "../../helpers/resolve-theme"; @@ -17,6 +17,7 @@ export interface AlertTheme { color: FlowbiteColors; icon: string; rounded: string; + transition?: Partial; wrapper: string; } @@ -26,12 +27,21 @@ export interface AlertCloseButtonTheme { icon: string; } +export interface AlertTransitionTheme { + base: string; + duration: string; + timing: string; +} + export interface AlertProps extends Omit, "color">, ThemingProps { additionalContent?: ReactNode; color?: DynamicStringEnumKeysOf; + duration?: number; icon?: FC>; onDismiss?: ComponentProps<"button">["onClick"]; rounded?: boolean; + timing?: string; + transition?: string; withBorderAccent?: boolean; } @@ -48,13 +58,36 @@ export const Alert = forwardRef((props, ref) => { children, className, color = "info", + duration = 300, icon: Icon, onDismiss, rounded = true, + timing = "ease-out", + transition = "transition-opacity", withBorderAccent, ...restProps } = resolveProps(props, provider.props?.alert); + const [isDismissing, setIsDismissing] = useState(false); + + useEffect(() => { + if (!isDismissing) { + return; + } + const timeoutId = setTimeout(() => { + onDismiss?.(new MouseEvent("click") as unknown as React.MouseEvent); + }, duration); + return () => clearTimeout(timeoutId); + }, [isDismissing, duration, onDismiss]); + + const handleDismissClick: ComponentProps<"button">["onClick"] = (event) => { + if (transition || duration > 0) { + setIsDismissing(true); + return; + } + onDismiss?.(event); + }; + return (
((props, ref) => { theme.color[color], rounded && theme.rounded, withBorderAccent && theme.borderAccent, + isDismissing && + twMerge( + transition, + `duration-${duration}`, + timing, + theme.transition?.base, + theme.transition?.duration, + theme.transition?.timing, + "opacity-0", + ), className, )} role="alert" @@ -75,7 +118,7 @@ export const Alert = forwardRef((props, ref) => {