diff --git a/packages/components/toast/README.md b/packages/components/toast/README.md new file mode 100644 index 0000000000..4e348bc039 --- /dev/null +++ b/packages/components/toast/README.md @@ -0,0 +1,24 @@ +# @nextui-org/toast + +Toast Component helps to provide feedback on user-actions. + +> This is an internal utility, not intended for public usage. + +## Installation + +```sh +yarn add @nextui-org/toast +# or +npm i @nextui-org/toast +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). diff --git a/packages/components/toast/package.json b/packages/components/toast/package.json new file mode 100644 index 0000000000..2b86beb11c --- /dev/null +++ b/packages/components/toast/package.json @@ -0,0 +1,60 @@ +{ + "name": "@nextui-org/toast", + "version": "2.0.0", + "description": "adding toast", + "keywords": [ + "toast" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/toast" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/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": { + "@nextui-org/system": ">=2.0.0", + "@nextui-org/theme": ">=2.0.0", + "react": ">=18", + "react-dom": ">=18" + }, + "dependencies": { + "@nextui-org/react-utils": "workspace:*", + "@nextui-org/shared-utils": "workspace:*", + "@nextui-org/shared-icons": "workspace:*", + "@react-aria/button": "3.9.5", + "@react-aria/toast": "3.0.0-beta.15", + "@react-aria/utils": "3.24.1", + "@react-stately/toast": "3.0.0-beta.5" + }, + "devDependencies": { + "@nextui-org/system": "workspace:*", + "@nextui-org/theme": "workspace:*", + "@nextui-org/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..a602590488 --- /dev/null +++ b/packages/components/toast/src/index.ts @@ -0,0 +1,13 @@ +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 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..e50151f3cb --- /dev/null +++ b/packages/components/toast/src/toast-provider.tsx @@ -0,0 +1,60 @@ +import {ToastOptions, ToastQueue, useToastQueue} from "@react-stately/toast"; +import {ToastVariantProps} from "@nextui-org/theme"; + +import {ToastRegion} from "./toast-region"; +import {ToastType} from "./use-toast"; + +let globalToastQueue: ToastQueue | null = null; + +interface ToastProviderProps { + maxVisibleToasts?: number; +} + +export const getToastQueue = (maxVisibleToasts: number) => { + if (!globalToastQueue) { + globalToastQueue = new ToastQueue({ + maxVisibleToasts, + }); + } + + return globalToastQueue; +}; + +export const ToastProvider = ({maxVisibleToasts = 5}: ToastProviderProps) => { + const toastQueue = useToastQueue(getToastQueue(maxVisibleToasts)); + + if (toastQueue.visibleToasts.length == 0) { + return null; + } + + return <>{}; +}; + +export const addToast = ({ + title, + description, + priority, + timeout, + ...config +}: { + title: string; + description: string; +} & ToastOptions & + ToastVariantProps) => { + if (!globalToastQueue) { + return; + } + + const content: ToastType = { + title, + description, + config: config, + }; + + const options: Partial = { + timeout, + priority, + }; + + globalToastQueue.add(content, options); +}; diff --git a/packages/components/toast/src/toast-region.tsx b/packages/components/toast/src/toast-region.tsx new file mode 100644 index 0000000000..69b4635b5c --- /dev/null +++ b/packages/components/toast/src/toast-region.tsx @@ -0,0 +1,31 @@ +import {useRef} from "react"; +import {useToastRegion, AriaToastRegionProps} from "@react-aria/toast"; +import {QueuedToast, ToastState} from "@react-stately/toast"; + +import Toast from "./toast"; +import {ToastType} from "./use-toast"; + +interface ToastRegionProps extends AriaToastRegionProps { + toastQueue: ToastState; +} + +export function ToastRegion({toastQueue, ...props}: ToastRegionProps) { + const ref = useRef(null); + const {regionProps} = useToastRegion(props, toastQueue, ref); + + return ( + <> +
+ {toastQueue.visibleToasts.map((toast: QueuedToast) => { + return ( + + ); + })} +
+ + ); +} diff --git a/packages/components/toast/src/toast.tsx b/packages/components/toast/src/toast.tsx new file mode 100644 index 0000000000..ca21196436 --- /dev/null +++ b/packages/components/toast/src/toast.tsx @@ -0,0 +1,54 @@ +import {forwardRef} from "@nextui-org/system"; +import {Button, ButtonProps} from "@nextui-org/button"; +import {CloseIcon} from "@nextui-org/shared-icons"; + +import {Progress} from "../../../core/react/src"; + +import {UseToastProps, useToast} from "./use-toast"; + +export interface ToastProps extends UseToastProps {} + +const Toast = forwardRef<"div", ToastProps>((props, ref) => { + const { + Component, + Icon, + domRef, + endContent, + closeProgressBarValue, + getToastProps, + getContentProps, + getTitleProps, + getDescriptionProps, + getProgressBarProps, + getCloseButtonProps, + getIconProps, + } = useToast({ + ...props, + ref, + }); + + return ( + +
+ +
+
{props.toast.content.title}
+
{props.toast.content.description}
+
+
+ + {endContent} + +
+ ); +}); + +Toast.displayName = "NextUI.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..bddd0af822 --- /dev/null +++ b/packages/components/toast/src/use-toast.ts @@ -0,0 +1,185 @@ +import type {SlotsToClasses, ToastSlots, ToastVariantProps} from "@nextui-org/theme"; + +import {HTMLNextUIProps, PropGetter, mapPropsVariants} from "@nextui-org/system"; +import {toast as toastTheme} from "@nextui-org/theme"; +import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; +import {clsx, objectToDeps} from "@nextui-org/shared-utils"; +import {ReactNode, useCallback, useEffect, useMemo, useState} from "react"; +import {useToast as useToastAria, AriaToastProps} from "@react-aria/toast"; +import {mergeProps} from "@react-aria/utils"; +import {QueuedToast, ToastState} from "@react-stately/toast"; +import {InfoFilledIcon} from "@nextui-org/shared-icons"; + +export type ToastType = { + title: string; + description: string; + config: ToastVariantProps; +}; + +interface Props extends HTMLNextUIProps<"div"> { + /** + * Ref to the DOM node. + */ + ref?: ReactRef; + toast: QueuedToast; + state: ToastState; + classNames?: SlotsToClasses; + /** + * Content to be displayed in the end side of the alert + */ + endContent?: ReactNode; +} + +export type UseToastProps = Props & + ToastVariantProps & + Omit, "div">; + +export function useToast(originalProps: UseToastProps) { + const [props, variantProps] = mapPropsVariants(originalProps, toastTheme.variantKeys); + + const [closeProgressBarValue, setCloseProgressBarValue] = useState(0); + const [isToastClicked, setIsToastClicked] = useState(false); + + useEffect(() => { + const interval = setInterval(async () => { + if (isToastClicked) { + return; + } + setCloseProgressBarValue(closeProgressBarValue + 10); + }, Number(props.toast.timeout) / 20); + + return () => { + clearInterval(interval); + }; + }); + + const {ref, as, className, classNames, toast, endContent, ...otherProps} = props; + + const Component = as || "div"; + let Icon = InfoFilledIcon; + + const domRef = useDOMRef(ref); + const baseStyles = clsx(className, classNames?.base); + const {toastProps, contentProps, titleProps, descriptionProps, closeButtonProps} = useToastAria( + props, + props.state, + domRef, + ); + + const styles = useMemo( + () => + toastTheme({ + ...variantProps, + className, + }), + [objectToDeps(variantProps), className], + ); + + const slots = useMemo( + () => + toastTheme({ + ...variantProps, + }), + [objectToDeps(variantProps)], + ); + + const getToastProps: PropGetter = useCallback( + (props = {}) => ({ + ref: domRef, + className: slots.base({class: clsx(baseStyles, classNames?.base)}), + onMouseDown: () => { + if (!toast.timer) { + return; + } + setIsToastClicked(true); + toast.timer.pause(); + }, + onMouseUp: () => { + if (!toast.timer) { + return; + } + setIsToastClicked(false); + toast.timer.resume(); + }, + ...mergeProps(props, otherProps, toastProps), + }), + [slots, classNames, toastProps], + ); + + const getIconProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.content({class: classNames?.icon}), + ...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}), + ...mergeProps(props, otherProps, closeButtonProps), + }), + [closeButtonProps], + ); + + const isProgressBarHidden = toast.timeout ? "block" : "hidden"; + const progressBarProps = { + classNames: { + track: "bg-default-200", + indicator: "bg-default-700/40", + }, + radius: "none", + isDisabled: true, + }; + + const getProgressBarProps: PropGetter = useCallback( + (props = {}) => ({ + className: slots.progressBar({class: clsx(isProgressBarHidden, classNames?.progressBar)}), + ...mergeProps(props, otherProps, progressBarProps), + }), + [], + ); + + return { + Component, + Icon, + styles, + domRef, + classNames, + closeProgressBarValue, + getToastProps, + getTitleProps, + getContentProps, + getDescriptionProps, + getProgressBarProps, + getCloseButtonProps, + getIconProps, + endContent, + }; +} + +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..95305d7d05 --- /dev/null +++ b/packages/components/toast/stories/toast.stories.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import {Meta} from "@storybook/react"; +import {toast} from "@nextui-org/theme"; +import {Button} from "@nextui-org/button"; + +import {Toast, ToastProps, ToastProvider, addToast} from "../src"; + +export default { + title: "Components/Toast", + component: Toast, + argTypes: { + variant: { + control: {type: "select"}, + options: ["faded", "flat", "bordered", "solid"], + }, + color: { + control: {type: "select"}, + options: ["default", "primary", "secondary", "success", "warning", "danger"], + }, + radius: { + control: {type: "select"}, + options: ["none", "sm", "md", "lg", "full"], + }, + size: { + control: {type: "select"}, + options: ["sm", "md", "lg"], + }, + isDisabled: { + control: { + type: "boolean", + }, + }, + }, +} as Meta; + +const defaultProps = { + ...toast.defaultVariants, +}; + +const Template = (args: ToastProps) => ( + <> + + + +); + +export const Default = { + render: Template, + args: { + ...defaultProps, + variant: "bordered", + color: "danger", + }, +}; 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/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..e79096739b --- /dev/null +++ b/packages/core/theme/src/components/toast.ts @@ -0,0 +1,246 @@ +import type {VariantProps} from "tailwind-variants"; + +import {tv} from "../utils/tv"; + +const toast = tv({ + slots: { + wrapper: [ + "flex", + "w-screen", + "min-h-10", + "fixed", + "inset-0", + "z-50", + "overflow-x-auto", + "justify-center", + ], + base: [ + "flex", + "flex-col", + "relative", + "bg-white", + "z-50", + "w-full", + "box-border", + "outline-none", + "p-2 px-4 mx-1", + "my-1", + "sm:mx-4", + "sm:my-4", + "max-w-[542px]", + "rounded-md", + "text-white", + "shadow-inner", + ], + icon: ["w-6 h-6"], + content: ["flex flex-grow flex-row gap-x-1 items-center"], + title: ["font-medium", "ms-4"], + description: ["font-light", "ms-4"], + progressBar: [ + "absolute", + "h-[2px]", + "right-0", + "bottom-0", + "w-full", + "overflow-hidden", + "bg-black-500", + "rounded-none", + ], + closeButton: [ + "w-4 h-4 min-w-4 p-0.5 absolute -right-1 -top-1 flex items-center justify-center bg-default-100 hover:bg-default-200 text-default-400 hover:text-default-600 border border-1 border-default-400", + ], + }, + variants: { + size: { + xs: "", + }, + variant: { + flat: "bg-default", + faded: "bg-default border border-1 border-default-400", + solid: "bg-default shadow-inner", + bordered: "bg-white dark:bg-black border border-1 border-default-400", + }, + color: { + default: "", + primary: "", + secondary: "", + success: "", + warning: "", + danger: "", + }, + }, + defaultVariants: { + size: "xs", + variant: "flat", + }, + compoundVariants: [ + // flat and color + { + variant: "flat", + color: "primary", + class: { + base: "bg-primary-100/40 text-primary-400", + }, + }, + { + variant: "flat", + color: "secondary", + class: { + base: "bg-secondary-100/40 text-secondary-400", + }, + }, + { + variant: "faded", + color: "success", + class: { + base: "bg-success-100/40 text-success-400", + }, + }, + { + variant: "faded", + color: "warning", + class: { + base: "bg-warning-100/40 text-warning-400", + }, + }, + { + variant: "faded", + color: "danger", + class: { + base: "bg-danger-100/40 text-danger-400", + }, + }, + // faded and color + { + variant: "faded", + color: "primary", + class: { + base: "bg-primary-100/40 text-primary-400 border-primary-400", + closeButton: "bg-primary-100 hover:bg-primary-200 border-primary-400 text-primary-400", + }, + }, + { + variant: "faded", + color: "secondary", + class: { + base: "bg-secondary-100/40 text-secondary-400 border-secondary-400", + closeButton: + "bg-secondary-100 hover:bg-secondary-200 border-secondary-400 text-secondary-400", + }, + }, + { + variant: "faded", + color: "success", + class: { + base: "bg-success-100/40 text-success-400 border-success-400", + closeButton: "bg-success-100 hover:bg-success-200 border-success-400 text-success-400", + }, + }, + { + variant: "faded", + color: "warning", + class: { + base: "bg-warning-100/40 text-warning-400 border-warning-400", + closeButton: "bg-warning-100 hover:bg-warning-200 border-warning-400 text-warning-400", + }, + }, + { + variant: "faded", + color: "danger", + class: { + base: "bg-danger-100/40 text-danger-400 border-danger-400", + closeButton: "bg-danger-100 hover:bg-danger-200 border-danger-400 text-danger-400", + }, + }, + // bordered and color + { + variant: "bordered", + color: "primary", + class: { + base: "border-primary-400 text-primary-400", + closeButton: + "bg-secondary-100 hover:bg-secondary-200 border-secondary-400 text-secondary-400", + }, + }, + { + variant: "bordered", + color: "secondary", + class: { + base: "border-secondary-400 text-secondary-400", + closeButton: + "bg-secondary-100 hover:bg-secondary-200 border-secondary-400 text-secondary-400", + }, + }, + { + variant: "bordered", + color: "success", + class: { + base: "border-success-400 text-success-400", + closeButton: "bg-success-100 hover:bg-success-200 border-success-400 text-success-400", + }, + }, + { + variant: "bordered", + color: "warning", + class: { + base: "border-warning-400 text-warning-400", + closeButton: "bg-warning-100 hover:bg-warning-200 border-warning-400 text-warning-400", + }, + }, + { + variant: "bordered", + color: "danger", + class: { + base: "border-danger-400 text-danger-400", + closeButton: "bg-danger-100 hover:bg-danger-200 border-danger-400 text-danger-400", + }, + }, + // solid and color + { + variant: "solid", + color: "primary", + class: { + base: "bg-primary-100 text-default-600", + closeButton: "bg-primary-100 hover:bg-primary-200 border-primary-400 text-primary-400", + }, + }, + { + variant: "solid", + color: "secondary", + class: { + base: "bg-secondary-100 text-default-600", + closeButton: + "bg-secondary-100 hover:bg-secondary-200 border-secondary-400 text-secondary-400", + }, + }, + { + variant: "solid", + color: "success", + class: { + base: "bg-success-100 text-default-600", + closeButton: "bg-success-100 hover:bg-success-200 border-success-400 text-success-400", + }, + }, + { + variant: "solid", + color: "warning", + class: { + base: "bg-warning-100 text-default-600", + closeButton: "bg-warning-100 hover:bg-warning-200 border-warning-400 text-warning-400", + }, + }, + { + variant: "solid", + color: "danger", + class: { + base: "bg-danger-100 text-default-600", + closeButton: "bg-danger-100 hover:bg-danger-200 border-danger-400 text-danger-400", + }, + }, + ], +}); + +export type ToastVariantProps = VariantProps; +export type ToastSlots = keyof ReturnType; + +export {toast}; 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 (