diff --git a/.changeset/shaggy-beers-breathe.md b/.changeset/shaggy-beers-breathe.md new file mode 100644 index 0000000000..2b643c4369 --- /dev/null +++ b/.changeset/shaggy-beers-breathe.md @@ -0,0 +1,7 @@ +--- +"@heroui/shared-icons": patch +"@heroui/toast": patch +"@heroui/theme": patch +--- + +Introducing the toast component(#2560) diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json index f1ec7ce7b2..f2fcfe4321 100644 --- a/apps/docs/config/routes.json +++ b/apps/docs/config/routes.json @@ -436,7 +436,7 @@ "title": "Toast", "keywords": "toast, notification, message", "path": "/docs/components/toast.mdx", - "comingSoon": true + "newPost": true }, { "key": "textarea", diff --git a/apps/docs/content/components/toast/color.raw.jsx b/apps/docs/content/components/toast/color.raw.jsx new file mode 100644 index 0000000000..32c6c8ddaa --- /dev/null +++ b/apps/docs/content/components/toast/color.raw.jsx @@ -0,0 +1,24 @@ +import {addToast, Button} from "@heroui/react"; + +export default function App() { + return ( +
+ {["default", "primary", "secondary", "success", "warning", "danger"].map((color) => ( + + ))} +
+ ); +} diff --git a/apps/docs/content/components/toast/color.ts b/apps/docs/content/components/toast/color.ts new file mode 100644 index 0000000000..6c2d8d6af9 --- /dev/null +++ b/apps/docs/content/components/toast/color.ts @@ -0,0 +1,9 @@ +import App from "./color.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/toast/custom-close-icon.raw.jsx b/apps/docs/content/components/toast/custom-close-icon.raw.jsx new file mode 100644 index 0000000000..496a944e63 --- /dev/null +++ b/apps/docs/content/components/toast/custom-close-icon.raw.jsx @@ -0,0 +1,44 @@ +import {addToast, Button} from "@heroui/react"; + +const CustomToastComponent = () => { + return ( + + ); +}; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/toast/custom-close-icon.ts b/apps/docs/content/components/toast/custom-close-icon.ts new file mode 100644 index 0000000000..ea4122cd0b --- /dev/null +++ b/apps/docs/content/components/toast/custom-close-icon.ts @@ -0,0 +1,9 @@ +import App from "./custom-close-icon.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/toast/custom-styles.raw.jsx b/apps/docs/content/components/toast/custom-styles.raw.jsx new file mode 100644 index 0000000000..10e5bff9fa --- /dev/null +++ b/apps/docs/content/components/toast/custom-styles.raw.jsx @@ -0,0 +1,50 @@ +import {addToast, Button, cn} from "@heroui/react"; + +const CustomToastComponent = () => { + return ( + + + + ), + }); + }} + > + Show Toast + + ); +}; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/toast/custom-styles.ts b/apps/docs/content/components/toast/custom-styles.ts new file mode 100644 index 0000000000..da3ea9093a --- /dev/null +++ b/apps/docs/content/components/toast/custom-styles.ts @@ -0,0 +1,9 @@ +import App from "./custom-styles.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/toast/index.ts b/apps/docs/content/components/toast/index.ts new file mode 100644 index 0000000000..c884ac0ac2 --- /dev/null +++ b/apps/docs/content/components/toast/index.ts @@ -0,0 +1,17 @@ +import color from "./color"; +import variants from "./variants"; +import customStyles from "./custom-styles"; +import radius from "./radius"; +import placement from "./placement"; +import usage from "./usage"; +import customCloseIcon from "./custom-close-icon"; + +export const toastContent = { + color, + variants, + customStyles, + radius, + placement, + usage, + customCloseIcon, +}; diff --git a/apps/docs/content/components/toast/placement.raw.jsx b/apps/docs/content/components/toast/placement.raw.jsx new file mode 100644 index 0000000000..a7ab53233d --- /dev/null +++ b/apps/docs/content/components/toast/placement.raw.jsx @@ -0,0 +1,36 @@ +import {addToast, Button, ToastProvider} from "@heroui/react"; +import React from "react"; + +export default function App() { + const [placement, setPlacement] = React.useState("right-bottom"); + + return ( + <> + +
+ {[ + "left-top", + "right-top", + "center-top", + "left-bottom", + "right-bottom", + "center-bottom", + ].map((position) => ( + + ))} +
+ + ); +} diff --git a/apps/docs/content/components/toast/placement.ts b/apps/docs/content/components/toast/placement.ts new file mode 100644 index 0000000000..eee2244366 --- /dev/null +++ b/apps/docs/content/components/toast/placement.ts @@ -0,0 +1,9 @@ +import App from "./placement.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/toast/radius.raw.jsx b/apps/docs/content/components/toast/radius.raw.jsx new file mode 100644 index 0000000000..1414cfb235 --- /dev/null +++ b/apps/docs/content/components/toast/radius.raw.jsx @@ -0,0 +1,24 @@ +import {addToast, Button} from "@heroui/react"; + +export default function App() { + return ( +
+ {["none", "sm", "md", "lg", "full"].map((radius) => ( + + ))} +
+ ); +} diff --git a/apps/docs/content/components/toast/radius.ts b/apps/docs/content/components/toast/radius.ts new file mode 100644 index 0000000000..7b78db1ce0 --- /dev/null +++ b/apps/docs/content/components/toast/radius.ts @@ -0,0 +1,9 @@ +import App from "./radius.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/toast/usage.raw.jsx b/apps/docs/content/components/toast/usage.raw.jsx new file mode 100644 index 0000000000..6f8ee0e9da --- /dev/null +++ b/apps/docs/content/components/toast/usage.raw.jsx @@ -0,0 +1,118 @@ +import {addToast, Button} from "@heroui/react"; + +export default function App() { + return ( +
+ + + + + + + + + + ), + variant: "faded", + }); + }} + > + With endContent + + + + + +
+ ); +} diff --git a/apps/docs/content/components/toast/usage.ts b/apps/docs/content/components/toast/usage.ts new file mode 100644 index 0000000000..1118304c37 --- /dev/null +++ b/apps/docs/content/components/toast/usage.ts @@ -0,0 +1,9 @@ +import App from "./usage.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/toast/variants.raw.jsx b/apps/docs/content/components/toast/variants.raw.jsx new file mode 100644 index 0000000000..7d4d5b9d8d --- /dev/null +++ b/apps/docs/content/components/toast/variants.raw.jsx @@ -0,0 +1,30 @@ +import {addToast, Button} from "@heroui/react"; + +export default function App() { + return ( +
+ {[ + ["solid", "solid"], + ["bordered", "bordered"], + ["flat", "faded"], + ].map((variant) => ( + + ))} +
+ ); +} diff --git a/apps/docs/content/components/toast/variants.ts b/apps/docs/content/components/toast/variants.ts new file mode 100644 index 0000000000..ddea95fb2e --- /dev/null +++ b/apps/docs/content/components/toast/variants.ts @@ -0,0 +1,9 @@ +import App from "./variants.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/toast.mdx b/apps/docs/content/docs/components/toast.mdx new file mode 100644 index 0000000000..d7a1c27fea --- /dev/null +++ b/apps/docs/content/docs/components/toast.mdx @@ -0,0 +1,343 @@ +--- +title: "Toast" +description: "Toast are temporary notifications that provide concise feedback about an action or event." +--- + +import {toastContent} from "@/content/components/toast"; + +# Toast + +Toasts are temporary notifications that provide concise feedback about an action or event. + + + +--- + + + +## Installation + + + +## Import + + + +## Requirement + +The `ToastProvider` must be added to the application before using the `addToast` function. This is required to initialize the context for managing toasts. + +```jsx {4,9} +// app/providers.tsx + +import {HeroUIProvider} from '@heroui/react' +import {ToastProvider} from "@heroui/toast"; + +export default function Providers({children}) { + return ( + + + {children} + + ) +} +``` + + + +```jsx {3,9,11} +// app/layout.tsx + +import {Providers} from "./providers"; + +export default function RootLayout({children}) { + return ( + + + + {children} + + + + ); +} +``` + + +### Usage + + + +### Colors + +Toast comes with 6 color variants to convey different meanings. + + + +### Variants + + + +### Radius + + + +### Toast Placement + + + +### Custom Styles + +You can customize the alert by passing custom Tailwind CSS classes to the component slots. + + + +### Custom Close Icon + +You can pass a custom close icon to the toast by passing the `closeIcon` prop and a custom class name to the `closeButton` slot. + + + +### Global Toast Props + +You can pass global toast props to the `ToastProvider` to apply to all toasts. + +```jsx + +``` + + + +## Data Attributes + +Toast has the following attributes on the `base` element: + +- **data-has-title**: When the toast has a title +- **data-has-description**: When the toast has a description +- **data-animation**: Shows the current animation of toast ("entering", "queued", "exiting", "undefined") +- **data-placement**: Where the toast is placed on the view-port. +- **data-drag-value**: Value by which the toast is dragged from it's original position. (This remains "0" in case of disabledAnimation) + + + +### Slots + +Toast has the following slots: + +- `base`: The main toast container element +- `title`: The title element +- `description`: The description element +- `icon`: The icon element +- `loadingIcon`: The icon to be displayed until `promise` is resolved/rejected. +- `content`: The wrapper for the title, description and icon content. +- `motionDiv`: The motion.div for the FramerMotion. +- `progressTrack`: The track of the progressBar. +- `progressIndicator`: The indicator of the progressBar. +- `closeButton`: The close button element +- `closeIcon`: The icon which resides in the close button. + + +## Accessibility + +- Toast has role of `alert` +- All Toasts are present in ToastRegion. +- Close button has aria-label="Close" by default +- When no toast are present, ToastRegion is removed from the DOM + + + +## API + +### Toast Props + +>", + description: "Allows to set custom class names for the toast slots.", + default: "-" + } + ]} +/> + +### ToastProvider Props + + + + +### Toast Events + + void", + description: "Handler called when the close button is clicked", + default: "-" + } + ]} +/> \ No newline at end of file diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js index 9668d14742..8f3748517e 100644 --- a/apps/docs/tailwind.config.js +++ b/apps/docs/tailwind.config.js @@ -69,6 +69,9 @@ module.exports = { "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", }, + spacing: { + 'toast-gap': 'var(--toast-gap)', + }, typography: (theme) => ({ DEFAULT: { css: { diff --git a/packages/components/toast/README.md b/packages/components/toast/README.md new file mode 100644 index 0000000000..a1e2fee852 --- /dev/null +++ b/packages/components/toast/README.md @@ -0,0 +1,22 @@ +# @heroui/toast + +Toast Component helps to provide feedback on user-actions. + +## Installation + +```sh +yarn add @heroui/toast +# or +npm i @heroui/toast +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/heroui-inc/heroui/blob/canary/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/heroui-inc/heroui/blob/canary/LICENSE). diff --git a/packages/components/toast/__tests__/toast.test.tsx b/packages/components/toast/__tests__/toast.test.tsx new file mode 100644 index 0000000000..613a9db712 --- /dev/null +++ b/packages/components/toast/__tests__/toast.test.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import {render, screen} from "@testing-library/react"; +import userEvent, {UserEvent} from "@testing-library/user-event"; + +import {addToast, ToastProvider} from "../src"; + +const title = "Testing Title"; +const description = "Testing Description"; + +describe("Toast", () => { + let user: UserEvent; + + beforeEach(() => { + user = userEvent.setup(); + }); + + it("should render correctly", () => { + const wrapper = render( + <> + + + , + ); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", async () => { + const ref = React.createRef(); + + const wrapper = render( + <> + + + , + ); + + const button = wrapper.getByTestId("button"); + + await user.click(button); + expect(ref.current).not.toBeNull(); + }); + + it("should display title and description when component is rendered", async () => { + const wrapper = render( + <> + + + , + ); + + const button = wrapper.getByTestId("button"); + + await user.click(button); + + const region = screen.getByRole("region"); + + expect(region).toContainHTML(title); + expect(region).toContainHTML(description); + + await user.click(wrapper.getAllByRole("button")[0]); + }); + + it("should close", async () => { + const wrapper = render( + <> + + + , + ); + + const button = wrapper.getByTestId("button"); + + await user.click(button); + + const initialCloseButtons = wrapper.getAllByRole("button"); + const initialButtonLength = initialCloseButtons.length; + + await user.click(initialCloseButtons[0]); + + const finalCloseButtons = wrapper.getAllByRole("button"); + const finalButtonLength = finalCloseButtons.length; + + expect(initialButtonLength).toEqual(finalButtonLength + 1); + }); + + it("should work with placement", async () => { + const wrapper = render( + <> + + + , + ); + + const region = wrapper.getByRole("region"); + + expect(region).toHaveAttribute("data-placement", "left-bottom"); + }); + + it("should have loading-icon when promise prop is passed.", async () => { + const wrapper = render( + <> + + + , + ); + + const button = wrapper.getByTestId("button"); + + await user.click(button); + + const loadingIcon = wrapper.getByLabelText("loadingIcon"); + + expect(loadingIcon).toBeTruthy(); + }); +}); diff --git a/packages/components/toast/package.json b/packages/components/toast/package.json new file mode 100644 index 0000000000..02df8fa1f0 --- /dev/null +++ b/packages/components/toast/package.json @@ -0,0 +1,64 @@ +{ + "name": "@heroui/toast", + "version": "2.0.0", + "description": "Toast are temporary notifications that provide concise feedback about an action or event", + "keywords": [ + "toast" + ], + "author": "Junior Garcia ", + "homepage": "https://heroui.com", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/heroui-inc/heroui.git", + "directory": "packages/components/toast" + }, + "bugs": { + "url": "https://github.com/heroui-inc/heroui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "@heroui/system": ">=2.4.8", + "@heroui/theme": ">=2.4.7", + "react": ">=18 || >=19.0.0-rc.0", + "react-dom": ">=18 || >=19.0.0-rc.0", + "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1" + }, + "dependencies": { + "@heroui/react-utils": "workspace:*", + "@heroui/shared-utils": "workspace:*", + "@heroui/shared-icons": "workspace:*", + "@heroui/use-is-mobile": "workspace:*", + "@heroui/spinner": "workspace:*", + "@react-aria/toast": "3.0.0-beta.19", + "@react-aria/utils": "3.27.0", + "@react-aria/interactions": "3.23.0", + "@react-stately/toast": "3.0.0-beta.7", + "@react-stately/utils": "3.10.5" + }, + "devDependencies": { + "@heroui/system": "workspace:*", + "@heroui/theme": "workspace:*", + "@heroui/button": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/toast/src/index.ts b/packages/components/toast/src/index.ts new file mode 100644 index 0000000000..25f2660b91 --- /dev/null +++ b/packages/components/toast/src/index.ts @@ -0,0 +1,14 @@ +import Toast from "./toast"; +import {ToastProvider} from "./toast-provider"; + +// export types +export type {ToastProps} from "./toast"; + +// export hooks +export {useToast} from "./use-toast"; +export {addToast} from "./toast-provider"; +export {closeAll} from "./toast-provider"; + +// export component +export {Toast}; +export {ToastProvider}; diff --git a/packages/components/toast/src/toast-provider.tsx b/packages/components/toast/src/toast-provider.tsx new file mode 100644 index 0000000000..c871da2e89 --- /dev/null +++ b/packages/components/toast/src/toast-provider.tsx @@ -0,0 +1,83 @@ +import {ToastOptions, ToastQueue, useToastQueue} from "@react-stately/toast"; +import {useProviderContext} from "@heroui/system"; + +import {ToastRegion} from "./toast-region"; +import {ToastProps} from "./use-toast"; + +let globalToastQueue: ToastQueue | null = null; + +interface ToastProviderProps { + maxVisibleToasts?: number; + placement?: + | "right-bottom" + | "left-bottom" + | "center-bottom" + | "right-top" + | "left-top" + | "center-top"; + disableAnimation?: boolean; + toastProps?: ToastProps; + toastOffset?: number; +} + +export const getToastQueue = () => { + if (!globalToastQueue) { + globalToastQueue = new ToastQueue({ + maxVisibleToasts: Infinity, + hasExitAnimation: true, + }); + } + + return globalToastQueue; +}; + +export const ToastProvider = ({ + placement = "right-bottom", + disableAnimation: disableAnimationProp = false, + maxVisibleToasts = 3, + toastOffset = 0, + toastProps = {}, +}: ToastProviderProps) => { + const toastQueue = useToastQueue(getToastQueue()); + const globalContext = useProviderContext(); + const disableAnimation = disableAnimationProp ?? globalContext?.disableAnimation ?? false; + + if (toastQueue.visibleToasts.length == 0) { + return null; + } + + return ( + + ); +}; + +export const addToast = ({...props}: ToastProps & ToastOptions) => { + if (!globalToastQueue) { + return; + } + + const options: Partial = { + priority: props?.priority, + }; + + globalToastQueue.add(props, options); +}; + +export const closeAll = () => { + if (!globalToastQueue) { + return; + } + + const keys = globalToastQueue.visibleToasts.map((toast) => toast.key); + + keys.map((key) => { + globalToastQueue?.close(key); + }); +}; diff --git a/packages/components/toast/src/toast-region.tsx b/packages/components/toast/src/toast-region.tsx new file mode 100644 index 0000000000..05b36e6579 --- /dev/null +++ b/packages/components/toast/src/toast-region.tsx @@ -0,0 +1,106 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {useToastRegion, AriaToastRegionProps} from "@react-aria/toast"; +import {QueuedToast, ToastState} from "@react-stately/toast"; +import {useHover} from "@react-aria/interactions"; +import {mergeProps} from "@react-aria/utils"; +import {toastRegion, ToastRegionVariantProps} from "@heroui/theme"; + +import Toast from "./toast"; +import {ToastProps} from "./use-toast"; + +interface ToastRegionProps extends AriaToastRegionProps, ToastRegionVariantProps { + toastQueue: ToastState; + placement?: + | "right-bottom" + | "left-bottom" + | "center-bottom" + | "right-top" + | "left-top" + | "center-top"; + maxVisibleToasts: number; + toastOffset?: number; + toastProps?: ToastProps; +} + +export function ToastRegion({ + toastQueue, + placement, + disableAnimation, + maxVisibleToasts, + toastOffset, + toastProps = {}, + ...props +}: ToastRegionProps) { + const ref = useRef(null); + const {regionProps} = useToastRegion(props, toastQueue, ref); + const {hoverProps, isHovered} = useHover({ + isDisabled: false, + }); + + const [isTouched, setIsTouched] = useState(false); + + const slots = useMemo( + () => + toastRegion({ + disableAnimation, + }), + [disableAnimation], + ); + + useEffect(() => { + function handleTouchOutside(event: TouchEvent) { + if (ref.current && !(ref.current as HTMLDivElement).contains(event.target as Node)) { + setIsTouched(false); + } + } + document.addEventListener("touchstart", handleTouchOutside); + + return () => { + document.removeEventListener("touchstart", handleTouchOutside); + }; + }, []); + + const [heights, setHeights] = useState([]); + const total = toastQueue.visibleToasts?.length ?? 0; + + const handleTouchStart = useCallback(() => { + setIsTouched(true); + }, []); + + return ( +
+ {toastQueue.visibleToasts.map((toast: QueuedToast, index) => { + if (disableAnimation && total - index > maxVisibleToasts) { + return null; + } + + if (total - index <= 4 || (isHovered && total - index <= maxVisibleToasts + 1)) { + return ( + + ); + } + + return null; + })} +
+ ); +} diff --git a/packages/components/toast/src/toast.tsx b/packages/components/toast/src/toast.tsx new file mode 100644 index 0000000000..11d4e820cc --- /dev/null +++ b/packages/components/toast/src/toast.tsx @@ -0,0 +1,130 @@ +import {forwardRef} from "@heroui/system"; +import {Button, ButtonProps} from "@heroui/button"; +import { + CloseIcon, + DangerIcon, + InfoFilledIcon, + SuccessIcon, + WarningIcon, +} from "@heroui/shared-icons"; +import {AnimatePresence, m, LazyMotion} from "framer-motion"; +import {cloneElement, isValidElement} from "react"; +import {Spinner} from "@heroui/spinner"; + +import {UseToastProps, useToast} from "./use-toast"; + +const loadFeatures = () => import("framer-motion").then((res) => res.domMax); + +export interface ToastProps extends UseToastProps {} + +const iconMap = { + primary: InfoFilledIcon, + secondary: InfoFilledIcon, + success: SuccessIcon, + warning: WarningIcon, + danger: DangerIcon, +} as const; + +const Toast = forwardRef<"div", ToastProps>((props, ref) => { + const { + Component, + icon, + loadingIcon, + domRef, + endContent, + color, + hideIcon, + closeIcon, + disableAnimation, + progressBarRef, + classNames, + slots, + isProgressBarVisible, + getToastProps, + getContentProps, + getTitleProps, + getDescriptionProps, + getCloseButtonProps, + getIconProps, + getMotionDivProps, + getCloseIconProps, + getLoadingIconProps, + isLoading, + } = useToast({ + ...props, + ref, + }); + + const customIcon = icon && isValidElement(icon) ? cloneElement(icon, getIconProps()) : null; + const IconComponent = iconMap[color] || iconMap.primary; + const customLoadingIcon = + loadingIcon && isValidElement(loadingIcon) + ? cloneElement(loadingIcon, getLoadingIconProps()) + : null; + const loadingIconComponent = isLoading + ? customLoadingIcon || ( + + ) + : null; + + const customCloseIcon = + closeIcon && isValidElement(closeIcon) ? cloneElement(closeIcon, {}) : null; + + const toastContent = ( + +
+ {hideIcon && !isLoading + ? null + : loadingIconComponent || customIcon || } +
+
{props.toast.content.title}
+
{props.toast.content.description}
+
+
+ {isProgressBarVisible && ( +
+
+
+ )} + + {endContent} + + ); + + return ( + <> + {disableAnimation ? ( + toastContent + ) : ( + + + + + {toastContent} + + + + + )} + + ); +}); + +Toast.displayName = "HeroUI.Toast"; + +export default Toast; diff --git a/packages/components/toast/src/use-toast.ts b/packages/components/toast/src/use-toast.ts new file mode 100644 index 0000000000..c826ac013b --- /dev/null +++ b/packages/components/toast/src/use-toast.ts @@ -0,0 +1,604 @@ +import type {SlotsToClasses, ToastSlots, ToastVariantProps} from "@heroui/theme"; + +import {HTMLHeroUIProps, PropGetter, mapPropsVariants, useProviderContext} from "@heroui/system"; +import {toast as toastTheme} from "@heroui/theme"; +import {ReactRef, useDOMRef} from "@heroui/react-utils"; +import {clsx, dataAttr, isEmpty, objectToDeps} from "@heroui/shared-utils"; +import {ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react"; +import {useToast as useToastAria, AriaToastProps} from "@react-aria/toast"; +import {chain, mergeProps} from "@react-aria/utils"; +import {QueuedToast, ToastState} from "@react-stately/toast"; +import {MotionProps} from "framer-motion"; +import {useHover} from "@react-aria/interactions"; +import {useIsMobile} from "@heroui/use-is-mobile"; + +export interface ToastProps extends ToastVariantProps { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + /** + * title of the toast + */ + title?: ReactNode; + /** + * description of the toast + */ + description?: string; + /** + * Promise based on which the notification will be styled. + */ + promise?: Promise; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * addToast({ + * classNames={{ + * base:"base-classes", + * content: "content-classes" + * description: "description-classes" + * title: "title-classes" + * loadingIcon: "loading-icon-classes", + * icon: "icon-classes", + * progressTrack: "progress-track-classes", + * progressIndicator: "progress-indicator-classes", + * closeButton: "closeButton-classes" + * closeIcon: "closeIcon-classes" + * }} + * }) + * ``` + */ + classNames?: SlotsToClasses; + /** + * Content to be displayed in the end side of the toast + */ + endContent?: ReactNode; + /** + * Icon to be displayed in the toast - overrides the default icon + */ + icon?: ReactNode; + /** + * Icon to be displayed in the close button - overrides the default close icon + */ + closeIcon?: ReactNode | ((props: any) => ReactNode); + /** + * Icon to be displayed in the loading toast - overrides the loading icon + */ + loadingIcon?: ReactNode; + /** + * Whether the toast-icon should be hidden. + * @default false + */ + hideIcon?: boolean; + /** + * Time to auto-close the toast. + */ + timeout?: number; + /** + * hides the close button + */ + hideCloseButton?: boolean; + /** + * function which is called when toast is closed. + */ + onClose?: () => void; + /** + * props that will be passed to the m.div + */ + motionProps?: MotionProps; + /** + * should apply styles to indicate timeout progress + */ + shouldShowTimeoutProgess?: boolean; +} + +interface Props extends Omit, "title">, ToastProps { + toast: QueuedToast; + index: number; + total: number; + state: ToastState; + heights: number[]; + setHeights: (val: number[]) => void; + disableAnimation?: boolean; + isRegionExpanded: boolean; + placement?: + | "right-bottom" + | "left-bottom" + | "center-bottom" + | "right-top" + | "left-top" + | "center-top"; + toastOffset?: number; +} + +export type UseToastProps = Props & + ToastVariantProps & + Omit, "div">; + +const SWIPE_THRESHOLD_X = 100; +const SWIPE_THRESHOLD_Y = 20; +const INITIAL_POSITION = 50; + +export function useToast(originalProps: UseToastProps) { + const [props, variantProps] = mapPropsVariants(originalProps, toastTheme.variantKeys); + const { + ref, + as, + title, + description, + className, + classNames, + toast, + endContent, + closeIcon, + hideIcon = false, + placement: placementProp = "right-bottom", + isRegionExpanded, + hideCloseButton = false, + state, + total = 1, + index = 0, + heights, + promise: promiseProp, + setHeights, + toastOffset = 0, + motionProps, + timeout = 6000, + shouldShowTimeoutProgess = false, + icon, + onClose, + ...otherProps + } = props; + + const {isHovered: isToastHovered, hoverProps} = useHover({ + isDisabled: false, + }); + + const globalContext = useProviderContext(); + const disableAnimation = + originalProps?.disableAnimation ?? globalContext?.disableAnimation ?? false; + + const isMobile = useIsMobile(); + let placement = placementProp; + + if (isMobile) { + if (placementProp.includes("top")) { + placement = "center-top"; + } else { + placement = "center-bottom"; + } + } + + const animationRef = useRef(null); + const startTime = useRef(null); + const progressRef = useRef(0); + const progressBarRef = useRef(null); + const pausedTime = useRef(0); + const timeElapsed = useRef(0); + + useEffect(() => { + const updateProgress = (timestamp: number) => { + if (!timeout) { + return; + } + + if (startTime.current === null) { + startTime.current = timestamp; + } + + if (isToastHovered || isRegionExpanded || index != total - 1) { + pausedTime.current += timestamp - startTime.current; + startTime.current = null; + animationRef.current = requestAnimationFrame(updateProgress); + + return; + } + + const elapsed = timestamp - startTime.current + pausedTime.current; + + timeElapsed.current = elapsed; + if (timeElapsed.current >= timeout) { + state.close(toast.key); + } + + progressRef.current = Math.min((elapsed / timeout) * 100, 100); + + if (progressBarRef.current) { + progressBarRef.current.style.width = `${ + shouldShowTimeoutProgess ? progressRef.current : 0 + }%`; + } + + if (progressRef.current < 100) { + animationRef.current = requestAnimationFrame(updateProgress); + } + }; + + animationRef.current = requestAnimationFrame(updateProgress); + + return () => { + if (animationRef.current !== null) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [timeout, shouldShowTimeoutProgess, state, isToastHovered, index, total, isRegionExpanded]); + + const [isLoading, setIsLoading] = useState(!!promiseProp); + + useEffect(() => { + if (!promiseProp) return; + promiseProp.finally(() => { + setIsLoading(false); + }); + }, [promiseProp]); + + const Component = as || "div"; + const loadingIcon: ReactNode = icon; + + const domRef = useDOMRef(ref); + const baseStyles = clsx(className, classNames?.base); + const {toastProps, contentProps, titleProps, descriptionProps, closeButtonProps} = useToastAria( + props, + state, + domRef, + ); + + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const [initialHeight, setInitialHeight] = useState(0); + + // Following was inspired from sonner ❤️ + useLayoutEffect(() => { + if (!domRef.current || !mounted) { + return; + } + const toastNode = domRef.current; + const originalHeight = toastNode.style.height; + + toastNode.style.height = "auto"; + const computedStyle = getComputedStyle(toastNode); + const marginTop = parseFloat(computedStyle.marginTop); + const marginBottom = parseFloat(computedStyle.marginBottom); + const newHeight = toastNode.getBoundingClientRect().height + marginTop + marginBottom; + + toastNode.style.height = originalHeight; + + setInitialHeight((prevHeight) => (prevHeight !== newHeight ? newHeight : prevHeight)); + const updatedHeights = [...heights]; + + if (updatedHeights.length > index) { + updatedHeights[index] = newHeight; + } else { + updatedHeights.push(newHeight); + } + setHeights(updatedHeights); + }, [mounted, total, setHeights, index]); + + let liftHeight = 4; + + for (let idx = index + 1; idx < total; idx++) { + liftHeight += heights[idx]; + } + + const frontHeight = heights[heights.length - 1]; + + const slots = useMemo( + () => + toastTheme({ + ...variantProps, + disableAnimation, + }), + [objectToDeps(variantProps)], + ); + + const multiplier = placement.includes("top") ? 1 : -1; + const toastVariants = { + hidden: {opacity: 0, y: -INITIAL_POSITION * multiplier}, + visible: {opacity: 1, y: 0}, + exit: {opacity: 0, y: -INITIAL_POSITION * multiplier}, + }; + + const [drag, setDrag] = useState(false); + const [dragValue, setDragValue] = useState(0); + + const shouldCloseToast = (offsetX: number, offsetY: number) => { + const isRight = placement.includes("right"); + const isLeft = placement.includes("left"); + const isCenterTop = placement === "center-top"; + const isCenterBottom = placement === "center-bottom"; + + if ( + (isRight && offsetX >= SWIPE_THRESHOLD_X) || + (isLeft && offsetX <= -SWIPE_THRESHOLD_X) || + (isCenterTop && offsetY <= -SWIPE_THRESHOLD_Y) || + (isCenterBottom && offsetY >= SWIPE_THRESHOLD_Y) + ) { + return true; + } + }; + + const getDragElasticConstraints = (placement: string) => { + const elasticConstraint = {top: 0, bottom: 0, right: 0, left: 0}; + + if (placement === "center-bottom") { + elasticConstraint.bottom = 1; + + return elasticConstraint; + } + if (placement === "center-top") { + elasticConstraint.top = 1; + + return elasticConstraint; + } + if (placement.includes("right")) { + elasticConstraint.right = 1; + + return elasticConstraint; + } + if (placement.includes("left")) { + elasticConstraint.left = 1; + + return elasticConstraint; + } + + elasticConstraint.left = 1; + elasticConstraint.right = 1; + + return elasticConstraint; + }; + + let opacityValue: undefined | number = undefined; + + if ((drag && placement === "center-bottom") || placement === "center-top") { + opacityValue = Math.max(0, 1 - dragValue / (SWIPE_THRESHOLD_Y + 5)); + } else if (drag) { + opacityValue = Math.max(0, 1 - dragValue / (SWIPE_THRESHOLD_X + 20)); + } + + const getToastProps: PropGetter = useCallback( + (props = {}) => ({ + ref: domRef, + className: slots.base({class: clsx(baseStyles, classNames?.base)}), + "data-has-title": dataAttr(!isEmpty(title)), + "data-has-description": dataAttr(!isEmpty(description)), + "data-placement": placement, + "data-drag-value": dragValue, + "data-toast": true, + "data-animation": toast.animation, + "aria-label": "toast", + onTransitionEnd: () => { + if (toast.animation === "exiting") { + state.remove(toast.key); + } + }, + style: { + opacity: opacityValue, + }, + ...mergeProps(props, otherProps, toastProps, hoverProps), + }), + [slots, classNames, toastProps, hoverProps, toast, toast.animation, toast.key, opacityValue], + ); + + const getIconProps: PropGetter = useCallback( + (props = {}) => ({ + "aria-label": "descriptionIcon", + className: slots.icon({class: classNames?.icon}), + ...props, + }), + [], + ); + + const getLoadingIconProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.loadingIcon({class: classNames?.loadingIcon}), + ...props, + }), + [], + ); + + const getContentProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.content({class: classNames?.content}), + ...mergeProps(props, otherProps, contentProps), + }), + [contentProps], + ); + + const getTitleProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.title({class: classNames?.title}), + ...mergeProps(props, otherProps, titleProps), + }), + [titleProps], + ); + + const getDescriptionProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.description({class: classNames?.description}), + ...mergeProps(props, otherProps, descriptionProps), + }), + [descriptionProps], + ); + + const getCloseButtonProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.closeButton({class: classNames?.closeButton}), + "aria-label": "closeButton", + "data-hidden": dataAttr(hideCloseButton), + ...mergeProps(props, closeButtonProps, { + onPress: chain(() => { + setTimeout(() => document.body.focus(), 0); + }, onClose), + }), + }), + [closeButtonProps, onClose], + ); + + const getCloseIconProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.closeIcon({class: classNames?.closeIcon}), + "aria-label": "closeIcon", + ...props, + }), + [], + ); + + const getMotionDivProps = useCallback( + ( + props = {}, + ): MotionProps & { + "data-drag": string | boolean; + "data-placement": string; + "data-drag-value": number; + className: string; + } => { + const isCloseToEnd = total - index - 1 <= 2; + const dragDirection = placement === "center-bottom" || placement === "center-top" ? "y" : "x"; + const dragConstraints = {left: 0, right: 0, top: 0, bottom: 0}; + const dragElastic = getDragElasticConstraints(placement); + + const animateProps = (() => { + if (placement.includes("top")) { + return { + top: + isRegionExpanded || drag + ? liftHeight + toastOffset + : (total - 1 - index) * 8 + toastOffset, + bottom: "auto", + }; + } else if (placement.includes("bottom")) { + return { + bottom: + isRegionExpanded || drag + ? liftHeight + toastOffset + : (total - 1 - index) * 8 + toastOffset, + top: "auto", + }; + } + + return {}; + })(); + + return { + animate: { + opacity: isCloseToEnd ? 1 : 0, + pointerEvents: isCloseToEnd ? "all" : "none", + scaleX: isRegionExpanded || drag ? 1 : 1 - (total - 1 - index) * 0.1, + height: isRegionExpanded || drag ? initialHeight : frontHeight, + y: 0, + ...animateProps, + }, + drag: dragDirection, + dragConstraints, + exit: {opacity: 0}, + initial: {opacity: 0, scale: 1, y: -40 * multiplier}, + transition: {duration: 0.3, ease: "easeOut"}, + variants: toastVariants, + dragElastic, + onDragEnd: (_, info) => { + const {x: offsetX, y: offsetY} = info.offset; + + setDrag(false); + + if (shouldCloseToast(offsetX, offsetY)) { + state.close(toast.key); + state.remove(toast.key); + + return; + } + setDragValue(0); + }, + onDrag: (_, info) => { + let updatedDragValue = 0; + + if (placement === "center-top") { + updatedDragValue = -info.offset.y; + } else if (placement === "center-bottom") { + updatedDragValue = info.offset.y; + } else if (placement.includes("right")) { + updatedDragValue = info.offset.x; + } else if (placement.includes("left")) { + updatedDragValue = -info.offset.x; + } + + if (updatedDragValue >= 0) { + setDragValue(updatedDragValue); + } + }, + onDragStart: () => { + setDrag(true); + }, + "data-drag": dataAttr(drag), + "data-placement": placement, + "data-drag-value": dragValue, + className: slots.motionDiv({class: classNames?.motionDiv}), + ...props, + ...motionProps, + }; + }, + [ + closeButtonProps, + total, + index, + placement, + isRegionExpanded, + liftHeight, + multiplier, + initialHeight, + frontHeight, + toastVariants, + classNames, + drag, + dataAttr, + setDrag, + shouldCloseToast, + slots, + ], + ); + + return { + Component, + title, + description, + icon, + loadingIcon, + domRef, + closeIcon, + classNames, + color: variantProps["color"], + hideIcon, + placement, + state, + toast, + disableAnimation, + isProgressBarVisible: !!timeout, + total, + index, + getToastProps, + getTitleProps, + getContentProps, + getDescriptionProps, + getCloseButtonProps, + getIconProps, + getMotionDivProps, + getCloseIconProps, + getLoadingIconProps, + progressBarRef, + endContent, + slots, + isRegionExpanded, + liftHeight, + frontHeight, + initialHeight, + isLoading, + }; +} + +export type UseToastReturn = ReturnType; diff --git a/packages/components/toast/stories/toast.stories.tsx b/packages/components/toast/stories/toast.stories.tsx new file mode 100644 index 0000000000..8358f2b58e --- /dev/null +++ b/packages/components/toast/stories/toast.stories.tsx @@ -0,0 +1,410 @@ +import React, {useEffect} from "react"; +import {Meta} from "@storybook/react"; +import {cn, toast} from "@heroui/theme"; +import {Button} from "@heroui/button"; + +import {Toast, ToastProps, ToastProvider, addToast, closeAll} from "../src"; + +export default { + title: "Components/Toast", + component: Toast, + argTypes: { + variant: { + control: {type: "select"}, + options: ["flat", "bordered", "solid"], + }, + color: { + control: {type: "select"}, + options: ["default", "foreground", "primary", "secondary", "success", "warning", "danger"], + }, + radius: { + control: {type: "select"}, + options: ["none", "sm", "md", "lg", "full"], + }, + size: { + control: {type: "select"}, + options: ["sm", "md", "lg"], + }, + hideIcon: { + control: { + type: "boolean", + }, + }, + shadow: { + control: {type: "select"}, + options: ["sm", "md", "lg"], + }, + placement: { + control: {type: "select"}, + options: [ + "right-bottom", + "left-bottom", + "center-bottom", + "right-top", + "left-top", + "center-top", + ], + }, + hideCloseButton: { + control: { + type: "boolean", + }, + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + (Story) => { + useEffect(() => { + return () => { + closeAll(); + }; + }, []); + + return ; + }, + ], +} as Meta; + +const defaultProps = { + ...toast.defaultVariants, +}; + +const Template = (args: ToastProps) => { + return ( + <> + +
+ +
+ + ); +}; + +const ShowTimeoutProgressTemplate = (args: ToastProps) => { + return ( + <> + + + + ); +}; + +const WithEndContentTemplate = (args) => { + return ( + <> + + + ), + color: "warning", + variant: "faded", + ...args, + }); + }} + > + Toast + + + ); +}; + +const PlacementTemplate = (args: ToastProps) => { + return ( + <> + +
+ +
+ + ); +}; + +const DisableAnimationTemplate = (args: ToastProps) => { + return ( + <> + +
+ +
+ + ); +}; + +const PromiseToastTemplate = (args: ToastProps) => { + return ( + <> + +
+ +
+ + ); +}; + +const CustomToastComponent = (args) => { + const color = args.color; + const colorMap = { + primary: "before:bg-primary border-primary-200 dark:border-primary-100", + secondary: "before:bg-secondary border-secondary-200 dark:border-secondary-100", + success: "before:bg-success border-success-200 dark:border-success-100", + warning: "before:bg-warning border-warning-200 dark:border-warning-100", + danger: "before:bg-danger border-danger-200 dark:border-danger-100", + }; + + return ( + <> + + +
+ ), + color: color, + }); + }} + > + Toast + + + ); +}; + +const CustomToastTemplate = (args) => { + const colors = ["primary", "secondary", "warning", "danger", "success"]; + + return ( + <> + +
+ {colors.map((color, idx) => ( + + ))} +
+ + ); +}; + +const CustomCloseButtonTemplate = (args) => { + return ( + <> + + + + ); +}; + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; + +export const WithDescription = { + render: Template, + args: { + description: "Toast displayed successfully.", + ...defaultProps, + }, +}; + +export const WithCustomIcon = { + render: Template, + args: { + ...defaultProps, + title: "Custom Icon", + icon: ( + + + + + + + ), + }, +}; + +export const iconHidden = { + render: Template, + args: { + ...defaultProps, + hideIcon: true, + }, +}; + +export const DisableAnimation = { + render: DisableAnimationTemplate, + args: { + ...defaultProps, + }, +}; + +export const PromiseToast = { + render: PromiseToastTemplate, + args: { + ...defaultProps, + }, +}; + +export const ShowTimeoutProgress = { + render: ShowTimeoutProgressTemplate, + args: { + ...defaultProps, + }, +}; + +export const Placement = { + render: PlacementTemplate, + args: { + ...defaultProps, + }, +}; + +export const WithEndContent = { + render: WithEndContentTemplate, + args: { + ...defaultProps, + }, +}; + +export const CustomStyles = { + render: CustomToastTemplate, + args: { + ...defaultProps, + }, +}; + +export const CustomCloseButton = { + render: CustomCloseButtonTemplate, + args: { + ...defaultProps, + }, +}; diff --git a/packages/components/toast/tsconfig.json b/packages/components/toast/tsconfig.json new file mode 100644 index 0000000000..5d012f6e61 --- /dev/null +++ b/packages/components/toast/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "tailwind-variants": ["../../../node_modules/tailwind-variants"] + }, + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/toast/tsup.config.ts b/packages/components/toast/tsup.config.ts new file mode 100644 index 0000000000..3e2bcff6cc --- /dev/null +++ b/packages/components/toast/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "tsup"; + +export default defineConfig({ + clean: true, + target: "es2019", + format: ["cjs", "esm"], + banner: {js: '"use client";'}, +}); diff --git a/packages/core/react/package.json b/packages/core/react/package.json index 2b8b3d613c..6f5202b0ee 100644 --- a/packages/core/react/package.json +++ b/packages/core/react/package.json @@ -89,6 +89,7 @@ "@heroui/drawer": "workspace:*", "@heroui/form": "workspace:*", "@heroui/alert": "workspace:*", + "@heroui/toast": "workspace:*", "@react-aria/visually-hidden": "3.8.19" }, "peerDependencies": { diff --git a/packages/core/react/src/index.ts b/packages/core/react/src/index.ts index 2b29b0ac5e..50aec9e35c 100644 --- a/packages/core/react/src/index.ts +++ b/packages/core/react/src/index.ts @@ -47,6 +47,7 @@ export * from "@heroui/form"; export * from "@heroui/alert"; export * from "@heroui/drawer"; export * from "@heroui/input-otp"; +export * from "@heroui/toast"; /** * React Aria - Exports diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts index dee45f4401..68832da61e 100644 --- a/packages/core/theme/src/components/index.ts +++ b/packages/core/theme/src/components/index.ts @@ -41,3 +41,4 @@ export * from "./date-picker"; export * from "./alert"; export * from "./drawer"; export * from "./form"; +export * from "./toast"; diff --git a/packages/core/theme/src/components/toast.ts b/packages/core/theme/src/components/toast.ts new file mode 100644 index 0000000000..5160e132c9 --- /dev/null +++ b/packages/core/theme/src/components/toast.ts @@ -0,0 +1,388 @@ +import type {VariantProps} from "tailwind-variants"; + +import {tv} from "../utils/tv"; + +const toastRegion = tv({ + slots: { + base: "relative z-50", + }, + variants: { + disableAnimation: { + false: { + base: "", + }, + true: { + base: [ + "data-[placement=right-bottom]:bottom-0 data-[placement=right-bottom]:right-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=right-bottom]:fixed data-[placement=right-bottom]:flex data-[placement=right-bottom]:flex-col", + "data-[placement=left-bottom]:bottom-0 data-[placement=left-bottom]:left-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=left-bottom]:fixed data-[placement=left-bottom]:flex data-[placement=left-bottom]:flex-col", + "data-[placement=center-bottom]:bottom-0 data-[placement=center-bottom]:fixed w-full px-2 sm:w-auto sm:px-0 data-[placement=center-bottom]:flex data-[placement=center-bottom]:flex-col data-[placement=center-bottom]:left-1/2 data-[placement=center-bottom]:-translate-x-1/2", + "data-[placement=right-top]:top-0 data-[placement=right-top]:right-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=right-top]:fixed data-[placement=right-top]:flex data-[placement=right-top]:flex-col", + "data-[placement=left-top]:top-0 data-[placement=left-top]:left-0 w-full px-2 sm:w-auto sm:px-0 data-[placement=left-top]:fixed data-[placement=left-top]:flex data-[placement=left-top]:flex-col", + "data-[placement=center-top]:top-0 data-[placement=center-top]:fixed w-full px-2 sm:w-auto sm:px-0 data-[placement=center-top]:flex data-[placement=center-top]:flex-col data-[placement=center-top]:left-1/2 data-[placement=center-top]:-translate-x-1/2", + ], + }, + }, + }, + defaultVariants: { + disableAnimation: false, + }, +}); + +const toast = tv({ + slots: { + base: [ + "flex gap-x-4 items-center", + "group", + "cursor-pointer", + "relative", + "z-50", + "box-border", + "outline-none", + "p-3 sm:mx-1", + "my-1", + "w-full sm:w-[270px] md:w-[300px]", + "min-h-4", + ], + title: ["font-medium", "text-small", "me-4", "text-foreground"], + description: ["text-small", "me-4", "text-default-600"], + icon: ["w-6 h-6 fill-current"], + loadingIcon: ["w-6 h-6 fill-current"], + content: ["flex flex-grow flex-row gap-x-4 items-center relative"], + progressTrack: ["absolute top-0 inset-0 bg-transparent overflow-hidden"], + progressIndicator: ["h-full bg-default-400 opacity-20"], + motionDiv: [ + "fixed", + "px-4 sm:px-0", + "data-[placement=right-bottom]:bottom-0 data-[placement=right-bottom]:right-0 data-[placement=right-bottom]:mx-auto w-full sm:data-[placement=right-bottom]:w-max mb-1 sm:data-[placement=right-bottom]:mr-2", + "data-[placement=left-bottom]:bottom-0 data-[placement=left-bottom]:left-0 data-[placement=left-bottom]:mx-auto w-full sm:data-[placement=left-bottom]:w-max mb-1 sm:data-[placement=left-bottom]:ml-2", + "data-[placement=center-bottom]:bottom-0 data-[placement=center-bottom]:left-0 data-[placement=center-bottom]:right-0 w-full sm:data-[placement=center-bottom]:w-max sm:data-[placement=center-bottom]:mx-auto", + "data-[placement=right-top]:top-0 data-[placement=right-top]:right-0 data-[placement=right-top]:mx-auto w-full sm:data-[placement=right-top]:w-max sm:data-[placement=right-top]:mr-2", + "data-[placement=left-top]:top-0 data-[placement=left-top]:left-0 data-[placement=left-top]:mx-auto w-full sm:data-[placement=left-top]:w-max sm:data-[placement=left-top]:ml-2", + "data-[placement=center-top]:top-0 data-[placement=center-top]:left-0 data-[placement=center-top]:right-0 w-full sm:data-[placement=center-top]:w-max sm:data-[placement=center-top]:mx-auto", + ], + closeButton: [ + "opacity-0 pointer-events-none group-hover:pointer-events-auto p-0 group-hover:opacity-100 w-6 h-6 min-w-4 absolute -right-2 -top-2 items-center justify-center bg-transparent text-default-400 hover:text-default-600 border border-3 border-transparent", + "data-[hidden=true]:hidden", + ], + closeIcon: ["rounded-full w-full h-full p-0.5 border border-default-400 bg-default-100"], + }, + variants: { + size: { + sm: { + icon: "w-5 h-5", + loadingIcon: "w-5 h-5", + title: "text-sm font-medium", + description: "text-xs", + }, + md: { + title: "text-sm font-semibold", + description: "text-xs", + }, + lg: { + title: "text-md font-semibold", + description: "text-sm", + }, + }, + variant: { + flat: "bg-content1 border border-default-100", + solid: "bg-default text-default-foreground", + bordered: "bg-background border border-default-200", + }, + color: { + default: "", + foreground: { + progressIndicator: "h-full opacity-20 bg-foreground-400", + }, + primary: { + progressIndicator: "h-full opacity-20 bg-primary-400", + }, + secondary: { + progressIndicator: "h-full opacity-20 bg-secondary-400", + }, + success: { + progressIndicator: "h-full opacity-20 bg-success-400", + }, + warning: { + progressIndicator: "h-full opacity-20 bg-warning-400", + }, + danger: { + progressIndicator: "h-full opacity-20 bg-danger-400", + }, + }, + radius: { + none: { + base: "rounded-none", + progressTrack: "rounded-none", + }, + sm: { + base: "rounded-small", + progressTrack: "rounded-small", + }, + md: { + base: "rounded-medium", + progressTrack: "rounded-medium", + }, + lg: { + base: "rounded-large", + progressTrack: "rounded-large", + }, + full: { + base: "rounded-full", + closeButton: "-top-px -right-px", + progressTrack: "rounded-full", + }, + }, + disableAnimation: { + true: { + closeButton: "transition-none", + base: "data-[animation=exiting]:opacity-0", + }, + false: { + closeButton: "transition-opacity ease-in duration-300", + base: [ + "data-[animation=exiting]:transform", + "data-[animation=exiting]:delay-100", + "data-[animation=exiting]:data-[placement=right-bottom]:translate-x-28", + "data-[animation=exiting]:data-[placement=left-bottom]:-translate-x-28", + "data-[animation=exiting]:data-[placement=center-bottom]:translate-y-28", + "data-[animation=exiting]:data-[placement=right-top]:translate-x-28", + "data-[animation=exiting]:data-[placement=left-top]:-translate-x-28", + "data-[animation=exiting]:data-[placement=center-top]:-translate-y-28", + "data-[animation=exiting]:opacity-0", + "data-[animation=exiting]:duration-200", + ], + }, + }, + shadow: { + none: { + base: "shadow-none", + }, + sm: { + base: "shadow-small", + }, + md: { + base: "shadow-medium", + }, + lg: { + base: "shadow-large", + }, + }, + }, + defaultVariants: { + size: "md", + variant: "flat", + radius: "md", + shadow: "sm", + }, + compoundVariants: [ + // flat and color + { + variant: "flat", + color: "foreground", + class: { + base: "bg-foreground text-background", + closeButton: "text-foreground-400 hover:text-foreground-600", + closeIcon: "border border-foreground-400 bg-foreground-100", + title: "text-background-600", + description: "text-background-500", + }, + }, + { + variant: "flat", + color: "primary", + class: { + base: "bg-primary-50 text-primary-600 border-primary-100", + closeButton: "text-primary-400 hover:text-primary-600", + closeIcon: "border border-primary-400 bg-primary-100", + title: "text-primary-600", + description: "text-primary-500", + }, + }, + { + variant: "flat", + color: "secondary", + class: { + base: "bg-secondary-50 text-secondary-600 border-secondary-100", + closeButton: "text-secondary-400 hover:text-secondary-600", + closeIcon: "border border-secondary-400 bg-secondary-100", + title: "text-secondary-600", + description: "text-secondary-500", + }, + }, + { + variant: "flat", + color: "success", + class: { + base: "bg-success-50 text-success-600 border-success-100", + closeButton: "text-success-400 hover:text-success-600", + closeIcon: "border border-success-400 bg-success-100", + title: "text-success-600", + description: "text-success-500", + }, + }, + { + variant: "flat", + color: "warning", + class: { + base: "bg-warning-50 text-warning-600 border-warning-100", + closeButton: "text-warning-400 hover:text-warning-600", + closeIcon: "border border-warning-400 bg-warning-100", + title: "text-warning-600", + description: "text-warning-500", + }, + }, + { + variant: "flat", + color: "danger", + class: { + base: "bg-danger-50 text-danger-600 border-danger-100", + closeButton: "text-danger-400 hover:text-danger-600", + closeIcon: "border border-danger-400 bg-danger-100", + title: "text-danger-600", + description: "text-danger-500", + }, + }, + // bordered and color + { + variant: "bordered", + color: "foreground", + class: { + base: "bg-foreground border-foreground-400 text-background", + closeButton: "text-foreground-400 hover:text-foreground-600", + closeIcon: "border border-foreground-400 bg-foreground-100", + title: "text-background-600", + description: "text-background-500", + }, + }, + { + variant: "bordered", + color: "primary", + class: { + base: "border-primary-400 text-primary-600", + closeButton: "text-primary-400 hover:text-primary-600", + closeIcon: "border border-primary-400 bg-primary-100", + title: "text-primary-600", + description: "text-primary-500", + }, + }, + { + variant: "bordered", + color: "secondary", + class: { + base: "border-secondary-400 text-secondary-600", + closeButton: "text-secondary-400 hover:text-secondary-600", + closeIcon: "border border-secondary-400 bg-secondary-100", + title: "text-secondary-600", + description: "text-secondary-500", + }, + }, + { + variant: "bordered", + color: "success", + class: { + base: "border-success-400 text-success-600", + closeButton: "text-success-400 hover:text-success-600", + closeIcon: "border border-success-400 bg-success-100", + title: "text-success-600", + description: "text-success-500", + }, + }, + { + variant: "bordered", + color: "warning", + class: { + base: "border-warning-400 text-warning-600", + closeButton: "text-warning-400 hover:text-warning-600", + closeIcon: "border border-warning-400 bg-warning-100", + title: "text-warning-600", + description: "text-warning-500", + }, + }, + { + variant: "bordered", + color: "danger", + class: { + base: "border-danger-400 text-danger-600", + closeButton: "text-danger-400 hover:text-danger-600", + closeIcon: "border border-danger-400 bg-danger-100", + title: "text-danger-600", + description: "text-danger-500", + }, + }, + // solid and color + { + variant: "solid", + color: "foreground", + class: { + base: "bg-foreground text-background", + closeButton: "text-foreground-400 hover:text-foreground-600", + closeIcon: "border border-foreground-400 bg-foreground-100", + title: "text-background-600", + description: "text-background-500", + }, + }, + { + variant: "solid", + color: "primary", + class: { + base: "bg-primary-100 text-primary-600", + closeButton: "text-primary-400 hover:text-primary-600", + closeIcon: "border border-primary-400 bg-primary-100", + title: "text-primary-600", + description: "text-primary-500", + }, + }, + { + variant: "solid", + color: "secondary", + class: { + base: "bg-secondary-100 text-secondary-600", + closeButton: "text-secondary-400 hover:text-secondary-600", + closeIcon: "border border-secondary-400 bg-secondary-100", + title: "text-secondary-600", + description: "text-secondary-500", + }, + }, + { + variant: "solid", + color: "success", + class: { + base: "bg-success-100 text-success-600", + closeButton: "text-success-400 hover:text-success-600", + closeIcon: "border border-success-400 bg-success-100", + title: "text-success-600", + description: "text-success-500", + }, + }, + { + variant: "solid", + color: "warning", + class: { + base: "bg-warning-100 text-warning-600", + closeButton: "text-warning-400 hover:text-warning-600", + closeIcon: "border border-warning-400 bg-warning-100", + title: "text-warning-600", + description: "text-warning-500", + }, + }, + { + variant: "solid", + color: "danger", + class: { + base: "bg-danger-100 text-danger-600", + closeButton: "text-danger-400 hover:text-danger-600", + closeIcon: "border border-danger-400 bg-danger-100", + title: "text-danger-600", + description: "text-danger-500", + }, + }, + ], +}); + +export type ToastVariantProps = VariantProps; +export type ToastSlots = keyof ReturnType; + +export type ToastRegionVariantProps = VariantProps; +export type ToastRegionSlots = keyof ReturnType; + +export {toast, toastRegion}; diff --git a/packages/utilities/shared-icons/src/close.tsx b/packages/utilities/shared-icons/src/close.tsx index 13ef5c2b00..51681d6e2b 100644 --- a/packages/utilities/shared-icons/src/close.tsx +++ b/packages/utilities/shared-icons/src/close.tsx @@ -17,6 +17,7 @@ export const CloseIcon = ( return (