diff --git a/packages/frappe-ui-react/src/components/alert/alert.stories.tsx b/packages/frappe-ui-react/src/components/alert/alert.stories.tsx new file mode 100644 index 00000000..18f6d506 --- /dev/null +++ b/packages/frappe-ui-react/src/components/alert/alert.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; + +import Alert from "./alert"; +import { Button } from "../button"; +import { BadgeInfo } from "lucide-react"; + +export default { + title: "Components/Alert", + component: Alert, + argTypes: { + title: { + control: "text", + description: "The title text of the alert", + }, + theme: { + control: { + type: "select", + options: ["yellow", "blue", "red", "green"], + }, + description: "Color theme of the alert", + }, + variant: { + control: { + type: "select", + options: ["subtle", "outline"], + }, + description: "Visual variant of the alert", + }, + description: { + control: "text", + description: "Description text displayed below the title", + }, + dismissable: { + control: "boolean", + description: "Whether the alert can be dismissed", + }, + visible: { + control: "boolean", + description: "Controls the visibility of the alert (controlled mode)", + }, + icon: { + control: false, + description: "Custom icon to display in the alert", + }, + footer: { + control: false, + description: "Custom footer content for the alert", + }, + }, + parameters: { + docs: { + source: { + type: "dynamic", + }, + }, + layout: "centered", + }, + tags: ["autodocs"], +} as Meta; + +type Story = StoryObj; + +const AlertTemplate: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const Success: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "green", + }, +}; + +export const Warning: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "yellow", + }, +}; + +export const Error: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "red", + }, +}; + +export const Info: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "blue", + }, +}; + +export const ControlledState: Story = { + render: (args) => { + const [visible, setVisible] = useState(true); + + return ( +
+
+ ); + }, + args: {}, +}; + +export const CustomSlots: Story = { + render: (args) => ( +
+ } + footer={() => ( +
+ ), + args: {}, +}; diff --git a/packages/frappe-ui-react/src/components/alert/alert.tsx b/packages/frappe-ui-react/src/components/alert/alert.tsx index 3cbd98ac..a7ba369c 100644 --- a/packages/frappe-ui-react/src/components/alert/alert.tsx +++ b/packages/frappe-ui-react/src/components/alert/alert.tsx @@ -1,53 +1,96 @@ -import React, { useMemo } from "react"; +/** + * External dependencies. + */ +import React, { useCallback, useMemo, useState } from "react"; +import { CircleCheck, CircleX, Info, TriangleAlert, X } from "lucide-react"; +import clsx from "clsx"; +/** + * Internal dependencies. + */ import type { AlertProps } from "./types"; const Alert: React.FC = ({ title, - type = "warning", - actions, - children, - ...rest + theme, + variant = "subtle", + description, + dismissable = true, + visible: controlledVisible, + onVisibleChange, + icon, + footer, }) => { + const [internalVisible, setInternalVisible] = useState(true); + + const isControlled = controlledVisible !== undefined; + const visible = isControlled ? controlledVisible : internalVisible; + + const handleDismiss = useCallback(() => { + if (isControlled) { + onVisibleChange?.(false); + } else { + setInternalVisible(false); + } + }, [isControlled, onVisibleChange]); + const classes = useMemo(() => { - const typeClasses: { [type: string]: string } = { - warning: "text-ink-gray-7 bg-surface-blue-1", + const subtleBgs = { + yellow: "bg-surface-amber-2", + blue: "bg-surface-blue-2", + red: "bg-surface-red-2", + green: "bg-surface-green-2", + }; + + if (variant === "outline") return "border border-outline-gray-3"; + + return theme ? subtleBgs[theme] : "bg-surface-gray-2"; + }, [theme, variant]); + + const iconConfig = useMemo(() => { + const data = { + yellow: { component: TriangleAlert, css: "text-ink-amber-3" }, + blue: { component: Info, css: "text-ink-blue-3" }, + red: { component: CircleX, css: "text-ink-red-3" }, + green: { component: CircleCheck, css: "text-ink-green-3" }, }; - return typeClasses[type] || typeClasses["warning"]; - }, [type]); + return theme ? data[theme] : null; + }, [theme]); + + if (!visible) return null; return ( -
-
- - - -
-
- {title && ( -

{title}

- )} -
{children}
- {actions && ( -
{actions}
- )} -
-
+
+ {icon ? ( + icon() + ) : iconConfig ? ( + + ) : null} + +
+ {title} + + {description ? ( + typeof description === "string" ? ( +

+ {description} +

+ ) : ( + description() + ) + ) : null}
+ + {dismissable && ( + + )} + + {footer ?
{footer()}
: null}
); }; diff --git a/packages/frappe-ui-react/src/components/alert/tests/alert.tsx b/packages/frappe-ui-react/src/components/alert/tests/alert.tsx index dc47a75a..bac97d52 100644 --- a/packages/frappe-ui-react/src/components/alert/tests/alert.tsx +++ b/packages/frappe-ui-react/src/components/alert/tests/alert.tsx @@ -1,29 +1,102 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import Alert from "../alert"; describe("Alert Component", () => { it("should render with title", () => { - render(Test content); + render(); expect(screen.getByText("Test Title")).toBeInTheDocument(); }); - it("should render with children content", () => { - render(Test content); - expect(screen.getByText("Test content")).toBeInTheDocument(); + it("should render with description", () => { + render(); + expect(screen.getByText("Test description")).toBeInTheDocument(); }); - it("should render with actions", () => { - render(Action}>Test content); - expect(screen.getByRole("button")).toBeInTheDocument(); + it("should render with footer", () => { + render( + } /> + ); + expect(screen.getByText("Footer Action")).toBeInTheDocument(); }); - it("should apply warning styles by default", () => { - render(Test content); - const container = - screen.getByText("Test content").parentElement?.parentElement - ?.parentElement; - expect(container).toHaveClass("text-ink-gray-7 bg-surface-blue-1"); + it("should apply correct theme classes", () => { + const { container } = render(); + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).toHaveClass("bg-surface-green-2"); + }); + + it("should apply outline variant classes", () => { + const { container } = render(); + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).toHaveClass("border border-outline-gray-3"); + }); + + it("should render dismiss button when dismissable is true", () => { + render(); + const dismissButton = screen.getByLabelText("Dismiss alert"); + expect(dismissButton).toBeInTheDocument(); + }); + + it("should not render dismiss button when dismissable is false", () => { + render(); + const dismissButton = screen.queryByLabelText("Dismiss alert"); + expect(dismissButton).not.toBeInTheDocument(); + }); + + it("should hide alert when dismiss button is clicked (uncontrolled)", () => { + const { container } = render(); + const dismissButton = screen.getByLabelText("Dismiss alert"); + + fireEvent.click(dismissButton); + + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).not.toBeInTheDocument(); + }); + + it("should call onVisibleChange when dismiss button is clicked (controlled)", () => { + const onVisibleChange = jest.fn(); + render( + + ); + const dismissButton = screen.getByLabelText("Dismiss alert"); + + fireEvent.click(dismissButton); + + expect(onVisibleChange).toHaveBeenCalledWith(false); + }); + + it("should not render when visible is false", () => { + const { container } = render(); + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).not.toBeInTheDocument(); + }); + + it("should render custom icon", () => { + const CustomIcon = () =>
Icon
; + render( } />); + expect(screen.getByTestId("custom-icon")).toBeInTheDocument(); + }); + + it("should render theme-based icon for each theme", () => { + const themes = ["yellow", "blue", "red", "green"] as const; + + themes.forEach((theme) => { + const { container } = render(); + const icon = container.querySelector("svg"); + expect(icon).toBeInTheDocument(); + }); + }); + + it("should not render icon when no theme and no custom icon provided", () => { + const { container } = render(); + const svgs = container.querySelectorAll("svg"); + expect(svgs.length).toBe(1); // Only dismiss button icon }); }); diff --git a/packages/frappe-ui-react/src/components/alert/types.ts b/packages/frappe-ui-react/src/components/alert/types.ts index 0c7f432e..5ec9085c 100644 --- a/packages/frappe-ui-react/src/components/alert/types.ts +++ b/packages/frappe-ui-react/src/components/alert/types.ts @@ -1,7 +1,16 @@ +import type { ReactNode } from "react"; + export interface AlertProps { - title?: string; - type?: "warning"; - actions?: React.ReactNode; - children: React.ReactNode; - [key: string]: any; + title: string; + theme?: "yellow" | "blue" | "red" | "green"; + variant?: "subtle" | "outline"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + description?: string | ((args?: any) => ReactNode); + dismissable?: boolean; + visible?: boolean; + onVisibleChange?: (visible: boolean) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon?: (args?: any) => ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + footer?: (args?: any) => ReactNode; } diff --git a/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx b/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx index 8912aa46..c4a850bd 100644 --- a/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx +++ b/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx @@ -191,6 +191,7 @@ export const CustomHeader: Story = { />