From 437ead4ae7b76614bf4d0f29e86d472b2a9e4cb1 Mon Sep 17 00:00:00 2001 From: "Khaled-(" Date: Mon, 27 Jul 2026 22:25:11 +0400 Subject: [PATCH] feat(alert): add dismiss transition, timing and duration props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'transition', 'timing', and 'duration' props to the Alert component so the dismiss animation can be customized, mirroring vanilla Flowbite's Dismiss class options. - transition (default: transition-opacity) — Tailwind transition utility class - duration (default: 300) — length of the transition in ms - timing (default: ease-out) — Tailwind timing-function utility class onDismiss now fires after the transition has finished (or immediately when duration is 0). The transition classes are also exposed via the optional alert.transition theme key for global overrides. Closes #1092 --- apps/web/content/docs/components/alert.mdx | 10 +++ apps/web/examples/alert/alert.transition.tsx | 63 +++++++++++++++++ apps/web/examples/alert/index.ts | 1 + .../ui/src/components/Alert/Alert.test.tsx | 70 ++++++++++++++++++- packages/ui/src/components/Alert/Alert.tsx | 47 ++++++++++++- 5 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 apps/web/examples/alert/alert.transition.tsx 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) => {