diff --git a/.changeset/lovely-eggs-juggle.md b/.changeset/lovely-eggs-juggle.md new file mode 100644 index 00000000000..c84e83e7ba3 --- /dev/null +++ b/.changeset/lovely-eggs-juggle.md @@ -0,0 +1,5 @@ +--- +"@razorpay/blade": minor +--- + +feat(blade): add toast component diff --git a/packages/blade/package.json b/packages/blade/package.json index d4a989760a5..82f15a7dee6 100644 --- a/packages/blade/package.json +++ b/packages/blade/package.json @@ -138,6 +138,7 @@ "use-presence": "1.1.0", "@use-gesture/react": "10.2.24", "@floating-ui/react": "0.25.4", + "react-hot-toast": "2.4.1", "@emotion/react": "11.11.1", "@table-library/react-table-library": "4.1.7", "tinycolor2": "1.6.0" @@ -284,6 +285,7 @@ "react-native-pager-view": "^6.2.1", "react-native-svg": "^12.3.0", "react-native-gesture-handler": "^2.9.0", + "react-hot-toast": "2.4.1", "@gorhom/bottom-sheet": "^4.4.6", "@gorhom/portal": "^1.0.14", "@razorpay/i18nify-js": "^1.4.0" diff --git a/packages/blade/src/components/Toast/Toast.native.tsx b/packages/blade/src/components/Toast/Toast.native.tsx new file mode 100644 index 00000000000..fa7440a9b67 --- /dev/null +++ b/packages/blade/src/components/Toast/Toast.native.tsx @@ -0,0 +1,17 @@ +import type { ToastProps } from './types'; +import { throwBladeError } from '~utils/logger'; + +const Toast = ( + _props: ToastProps & { + isVisible?: boolean; + }, +): React.ReactElement => { + throwBladeError({ + message: 'Toast is not yet implemented for native', + moduleName: 'Toast', + }); + + return <>; +}; + +export { Toast }; diff --git a/packages/blade/src/components/Toast/Toast.stories.tsx b/packages/blade/src/components/Toast/Toast.stories.tsx new file mode 100644 index 00000000000..59264ac4acb --- /dev/null +++ b/packages/blade/src/components/Toast/Toast.stories.tsx @@ -0,0 +1,209 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { Title } from '@storybook/addon-docs'; +import type { StoryFn, Meta } from '@storybook/react'; +import React from 'react'; +import { useToast } from './useToast'; +import { Toast } from './Toast'; +import type { ToastProps } from './'; +import { ToastContainer } from './'; +import StoryPageWrapper from '~utils/storybook/StoryPageWrapper'; +import { Sandbox } from '~utils/storybook/Sandbox'; +import { Box } from '~components/Box'; +import { Button } from '~components/Button'; +import { Heading, Text } from '~components/Typography'; + +const Page = (): React.ReactElement => { + return ( + + Usage + + {` + import { ToastContainer, useToast } from '@razorpay/blade/components'; + + function App(): React.ReactElement { + const toast = useToast(); + + // Integrating Blade Toast in your App + // 1. Render the ToastContainer component at the root of your app + // 2. Utilize the methods exposed via useToast hook to show/dismiss toasts + return ( + + + + + ); + } + + export default App; + `} + + + ); +}; + +export default { + title: 'Components/Toast', + component: Toast, + tags: ['autodocs'], + argTypes: { + isVisible: { + table: { + disable: true, + }, + }, + id: { + table: { + disable: true, + }, + }, + }, + parameters: { + docs: { + page: Page, + }, + }, +} as Meta; + +const texts = { + negative: 'Unable to fetch merchant details', + positive: 'Customer details failed successfully', + notice: 'Your KYC is pending', + information: 'Your transaction will be settled in 3 business days', + neutral: 'Your transaction will be settled in 3 business days', +} as const; + +const BasicToastTemplate: StoryFn = (args) => { + const toast = useToast(); + + if (args.type === 'promotional') { + args.content = {args.content}; + } + + return ( + + + After changing storybook controls, press the show "toast button" to see changes + + + + + ); +}; + +BasicToastTemplate.storyName = 'Basic'; +export const Basic = BasicToastTemplate.bind({}); +Basic.args = { + color: 'neutral', + type: 'informational', + autoDismiss: false, + content: 'Payment successful', + action: { + text: 'Okay', + onClick: ({ toastId }) => console.log(toastId), + }, +}; + +const ToastVariantsTemplate: StoryFn = () => { + const toast = useToast(); + const hasPromoToast = toast.toasts.some((t) => t.type === 'promotional'); + + const showInformationalToast = ({ color }: { color: ToastProps['color'] }) => { + toast.show({ + content: texts[color!], + color, + action: { + text: 'Okay', + onClick: ({ toastId }) => toast.dismiss(toastId), + }, + onDismissButtonClick: ({ toastId }) => console.log(`${toastId} Dismissed!`), + }); + }; + + const showPromotionalToast = () => { + toast.show({ + type: 'promotional', + content: ( + + Introducing TurboUPI + Promotional Toast + Lightning-fast payments with the new Razorpay Turbo UPI + + Turbo UPI allows end-users to complete their payment in-app, with no redirections or + dependence on third-party UPI apps. With Turbo UPI, payments will be 5x faster with a + significantly-improved success rate of 10%! + + + ), + action: { + text: 'Try TurboUPI', + onClick: ({ toastId }) => toast.dismiss(toastId), + }, + onDismissButtonClick: ({ toastId }) => console.log(`${toastId} Dismissed!`), + }); + }; + + return ( + + Show Informational Toasts: + + + + + + + + Show Promotional Toasts: + + Note: There can only be 1 promotional toast at a time + + + + + + + ); +}; + +export const ToastVariants = ToastVariantsTemplate.bind({}); +ToastVariants.storyName = 'Toast Variants'; diff --git a/packages/blade/src/components/Toast/Toast.web.tsx b/packages/blade/src/components/Toast/Toast.web.tsx new file mode 100644 index 00000000000..ffdb57c9fd9 --- /dev/null +++ b/packages/blade/src/components/Toast/Toast.web.tsx @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import toast from 'react-hot-toast'; +import type { FlattenSimpleInterpolation } from 'styled-components'; +import styled, { css, keyframes } from 'styled-components'; +import type { ToastProps } from './types'; +import { Box } from '~components/Box'; +import { Button } from '~components/Button'; +import { IconButton } from '~components/Button/IconButton'; +import { + AlertOctagonIcon, + AlertTriangleIcon, + CheckCircleIcon, + CloseIcon, + InfoIcon, +} from '~components/Icons'; +import BaseBox from '~components/Box/BaseBox'; +import { Text } from '~components/Typography'; +import { castWebType, makeMotionTime, useTheme } from '~utils'; +import getIn from '~utils/lodashButBetter/get'; +import { makeAccessible } from '~utils/makeAccessible'; + +const iconMap = { + positive: CheckCircleIcon, + negative: AlertOctagonIcon, + information: InfoIcon, + neutral: InfoIcon, + notice: AlertTriangleIcon, +}; + +const borderColorMap = { + positive: 'feedback.border.positive.intense', + negative: 'feedback.border.negative.intense', + notice: 'feedback.border.notice.intense', + information: 'feedback.border.information.intense', + neutral: 'feedback.border.neutral.intense', +} as const; + +const slideIn = keyframes` + from { + opacity: 0; + transform: translateY(100%); + } + + to { + opacity: 1; + transform: translateY(0); + } +`; + +const slideOut = keyframes` + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(100%); + } +`; + +const AnimatedFade = styled(BaseBox)<{ + animationType: FlattenSimpleInterpolation | null; + toastBorderColor: string; +}>(({ animationType, toastBorderColor }) => { + return css` + overflow: hidden; + border: 1px solid ${toastBorderColor}; + ${animationType} + `; +}); + +const Toast = ({ + type, + color = 'neutral', + leading, + action, + content, + onDismissButtonClick, + isVisible, + id, +}: ToastProps & { + isVisible?: boolean; +}): React.ReactElement => { + const { theme } = useTheme(); + const Icon = leading || iconMap[color]; + const isPromotional = type === 'promotional'; + const actionButton = action ? ( + + + + ) : null; + + const enter = css` + opacity: 0; + animation: ${slideIn} ${makeMotionTime(theme.motion.duration.gentle)} + ${castWebType(theme.motion.easing.entrance.effective)} forwards; + `; + + const exit = css` + opacity: 1; + animation: ${slideOut} ${makeMotionTime(theme.motion.duration.moderate)} + ${castWebType(theme.motion.easing.exit.effective)} forwards; + `; + + return ( + + {Icon ? ( + + + + ) : null} + + {isPromotional ? ( + content + ) : ( + + {content} + + )} + {isPromotional && actionButton} + + + {!isPromotional && actionButton} + ) => { + event.stopPropagation(); + onDismissButtonClick?.({ event, toastId: id! }); + toast.dismiss(id); + }} + icon={CloseIcon} + /> + + + ); +}; + +export { Toast }; diff --git a/packages/blade/src/components/Toast/ToastContainer.native.tsx b/packages/blade/src/components/Toast/ToastContainer.native.tsx new file mode 100644 index 00000000000..b799a30c56a --- /dev/null +++ b/packages/blade/src/components/Toast/ToastContainer.native.tsx @@ -0,0 +1,12 @@ +import { throwBladeError } from '~utils/logger'; + +const ToastContainer = (): React.ReactElement => { + throwBladeError({ + message: 'ToastContainer is not yet implemented for native', + moduleName: 'ToastContainer', + }); + + return <>; +}; + +export { ToastContainer }; diff --git a/packages/blade/src/components/Toast/ToastContainer.web.tsx b/packages/blade/src/components/Toast/ToastContainer.web.tsx new file mode 100644 index 00000000000..b39d7290927 --- /dev/null +++ b/packages/blade/src/components/Toast/ToastContainer.web.tsx @@ -0,0 +1,318 @@ +import type { ToastPosition, ToasterProps, Toast } from 'react-hot-toast'; +import { resolveValue, useToaster } from 'react-hot-toast'; +import React from 'react'; +import styled from 'styled-components'; +import { + PEEKS, + MAX_TOASTS, + SCALE_FACTOR, + GUTTER, + PEEK_GUTTER, + TOAST_MAX_WIDTH, + TOAST_Z_INDEX, + MIN_TOAST_MOBILE, + MIN_TOAST_DESKTOP, + CONTAINER_GUTTER_MOBILE, + CONTAINER_GUTTER_DESKTOP, +} from './constants'; +import { makeMotionTime, makeSize, useTheme } from '~utils'; +import BaseBox from '~components/Box/BaseBox'; +import type { Theme } from '~components/BladeProvider'; +import { useIsMobile } from '~utils/useIsMobile'; +import { metaAttribute } from '~utils/metaAttribute'; + +type CalculateYPositionProps = { + toast: Toast; + index: number; + isExpanded: boolean; + reverseOrder?: boolean; +}; + +const StyledToastWrapper = styled(BaseBox)<{ + isVisible: boolean; + index: number; + isExpanded: boolean; + isPromotional: boolean; +}>(({ isVisible, index, isExpanded, isPromotional }) => { + let opacity = isVisible ? 1 : 0; + // Only make the PEEKING and MAX_TOASTS toasts visible, + // Every other toasts should be hidden + if (index < PEEKS + MAX_TOASTS) { + opacity = 1; + } else if (isPromotional || isExpanded) { + opacity = 1; + } else { + opacity = 0; + } + + return { + '& > *': { + pointerEvents: opacity === 1 ? 'auto' : 'none', + }, + opacity, + }; +}); + +const getPositionStyle = ( + position: ToastPosition, + offset: number, + scale: number, + theme: Theme, +): React.CSSProperties => { + const top = position.includes('top'); + const verticalStyle: React.CSSProperties = top ? { top: 0 } : { bottom: 0 }; + const horizontalStyle: React.CSSProperties = position.includes('center') + ? { + justifyContent: 'center', + } + : position.includes('right') + ? { + justifyContent: 'flex-end', + } + : {}; + + return { + left: 0, + right: 0, + display: 'flex', + position: 'absolute', + transformOrigin: 'center', + transition: `${makeMotionTime(theme.motion.duration.gentle)} ${ + theme.motion.easing.standard.effective + }`, + transitionProperty: 'transform, opacity, height', + transform: `translateY(${offset * (top ? 1 : -1)}px) scale(${scale})`, + ...verticalStyle, + ...horizontalStyle, + }; +}; + +function isPromotionalToast(toast: Toast): boolean { + // @ts-expect-error + return toast.type == 'promotional'; +} + +const Toaster: React.FC = ({ + reverseOrder, + position = 'top-center', + toastOptions, + containerClassName, +}) => { + const { toasts, handlers } = useToaster(toastOptions); + const { theme } = useTheme(); + const [frontToastHeight, setFrontToastHeight] = React.useState(0); + const [hasManuallyExpanded, setHasManuallyExpanded] = React.useState(false); + const isMobile = useIsMobile(); + const minToasts = isMobile ? MIN_TOAST_MOBILE : MIN_TOAST_DESKTOP; + const containerGutter = isMobile ? CONTAINER_GUTTER_MOBILE : CONTAINER_GUTTER_DESKTOP; + + const infoToasts = React.useMemo(() => toasts.filter((toast) => !isPromotionalToast(toast)), [ + toasts, + ]); + const promoToasts = React.useMemo(() => toasts.filter((toast) => isPromotionalToast(toast)), [ + toasts, + ]); + + // always keep promo toasts at the bottom of the stack + const recomputedToasts = React.useMemo(() => [...infoToasts, ...promoToasts], [ + infoToasts, + promoToasts, + ]); + + const hasPromoToast = promoToasts.length > 0 && promoToasts[0]?.visible; + const promoToastHeight = promoToasts[0]?.height ?? 0; + const isExpanded = hasManuallyExpanded || recomputedToasts.length <= minToasts; + + React.useLayoutEffect(() => { + // find the first toast which is visible + const firstToast = infoToasts.find((t, index) => t.visible && index === 0); + if (firstToast) { + setFrontToastHeight(firstToast.height ?? 0); + } + }, [infoToasts]); + + // calculate total height of all toasts + const totalHeight = React.useMemo(() => { + return ( + recomputedToasts + // only consider visible recomputedToasts + .filter((toast) => toast.visible) + .reduce((prevHeight, toast) => prevHeight + (toast.height ?? 0), 0) + + recomputedToasts.length * GUTTER + ); + }, [recomputedToasts]); + + // Stacking logic explained in detail: + // https://www.loom.com/share/522d9a445e2f41e1886cce4decb9ab9d?sid=4287acf6-8d44-431b-93e1-c1a0d40a0aba + // + // 1. 3 toasts can be stacked on top of each other + // 2. After 3 toasts, the toasts will be scaled down and peek from behind + // 3. There can be maximum of 3 toasts peeking from behind + // 4. After 3 peeking toasts, the toasts will be hidden + // 5. If there is a promo toast, all toasts will be lifted up + // 6. Promo toasts will always be on the bottom + const calculateYPosition = React.useCallback( + ({ toast, index }: CalculateYPositionProps) => { + // find the current toast index + const toastIndex = infoToasts.findIndex((t) => t.id === toast.id); + // number of toasts before this toast + const toastsBefore = infoToasts.filter((toast, i) => i < toastIndex && toast.visible).length; + + let scale = Math.max(0.7, 1 - toastsBefore * SCALE_FACTOR); + // first toast should always have a scale of 1 + if (index < MAX_TOASTS) { + scale = 1; + } + + // y position of toast, + let offset = infoToasts + .filter((toast) => toast.visible) + .slice(0, toastsBefore) + .reduce((y, toast) => { + // if the toast is expanded, add the height of the toast + gutter + if (isExpanded) { + return y + (toast.height ?? 0) + GUTTER; + } + // if the toast is not expanded, add only the peek gutter + return y + PEEK_GUTTER; + }, 0); + + // lift all info toasts up if there is a promo toast + if (hasPromoToast) { + offset += GUTTER + promoToastHeight; + } + + // if this is a promo toast, then put it at the bottom and force the scale to 1 + if (isPromotionalToast(toast)) { + offset = 0; + scale = 1; + } + + return { offset, scale: isExpanded ? 1 : scale }; + }, + [hasPromoToast, infoToasts, isExpanded, promoToastHeight], + ); + + const handleMouseEnter = (): void => { + if (isMobile) return; + setHasManuallyExpanded(true); + handlers.startPause(); + }; + + const handleMouseLeave = (): void => { + if (isMobile) return; + setHasManuallyExpanded(false); + handlers.endPause(); + }; + + const handleToastClick = (): void => { + if (!isMobile) return; + setHasManuallyExpanded((prev) => { + const next = !prev; + if (next) { + handlers.startPause(); + } else { + handlers.endPause(); + } + return next; + }); + }; + + return ( + + {/* + * Mouseover container, + * fills in the gap between toasts so that mouseleave doesn't trigger in the gaps + */} + + {recomputedToasts.map((toast, index) => { + const toastPosition = toast.position ?? position; + const isPromotional = isPromotionalToast(toast); + const { offset, scale } = calculateYPosition({ + toast, + isExpanded, + reverseOrder, + index, + }); + const positionStyle = getPositionStyle(toastPosition, offset, scale, theme); + // recalculate height of toast + const ref = (el: HTMLDivElement): void => { + if (el && typeof toast.height !== 'number') { + const height = el.getBoundingClientRect().height; + handlers.updateHeight(toast.id, height); + } + }; + + let toastHeight = toast.height; + if (index > MAX_TOASTS - 1 && !isPromotional) { + toastHeight = frontToastHeight; + } + if (isExpanded) { + toastHeight = toast.height; + } + + return ( + { + if (isPromotional) return; + handleMouseEnter(); + }} + onMouseLeave={() => { + if (isPromotional) return; + handleMouseLeave(); + }} + onClick={() => { + if (isPromotional) return; + handleToastClick(); + }} + > + + {resolveValue(toast.message, { ...toast, index })} + + + ); + })} + + ); +}; + +const ToastContainer = (): React.ReactElement => { + return ; +}; + +export { ToastContainer }; diff --git a/packages/blade/src/components/Toast/__tests__/Toast.test.stories.tsx b/packages/blade/src/components/Toast/__tests__/Toast.test.stories.tsx new file mode 100644 index 00000000000..a7f85b2e544 --- /dev/null +++ b/packages/blade/src/components/Toast/__tests__/Toast.test.stories.tsx @@ -0,0 +1,237 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-extraneous-dependencies */ +import type { StoryFn } from '@storybook/react'; +import { within, userEvent } from '@storybook/testing-library'; +import { expect, jest } from '@storybook/jest'; +import React from 'react'; +import type { ToastProps } from '../types'; +import { useToast } from '../useToast'; +import { ToastContainer } from '../ToastContainer'; +import type { Toast } from '../Toast'; +import { Button } from '~components/Button'; +import { Box } from '~components/Box'; + +const onDismissButtonClick = jest.fn(); +const ToastExample = (props: ToastProps): React.ReactElement => { + const toast = useToast(); + + React.useEffect(() => { + toast.dismiss(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + ); +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export const TestToastShow: StoryFn = (): React.ReactElement => { + return ; +}; + +TestToastShow.play = async () => { + const { getByRole, queryByText } = within(document.body); + await sleep(1000); + + const toastContent = 'Payment successful'; + await expect(queryByText(toastContent)).not.toBeInTheDocument(); + const button = getByRole('button', { name: 'Show Toast' }); + await userEvent.click(button); + await sleep(400); + await expect(queryByText(toastContent)).toBeVisible(); + await sleep(4000); + await expect(queryByText(toastContent)).not.toBeVisible(); +}; + +export const TestToastDismiss: StoryFn = (): React.ReactElement => { + onDismissButtonClick.mockReset(); + return ; +}; + +TestToastDismiss.play = async () => { + const { getByRole, queryByText } = within(document.body); + await sleep(1000); + + const toastContent = 'Payment successful'; + await expect(queryByText(toastContent)).not.toBeInTheDocument(); + const button = getByRole('button', { name: 'Show Toast' }); + await userEvent.click(button); + await sleep(400); + await expect(queryByText(toastContent)).toBeVisible(); + const dismissButton = getByRole('button', { name: 'Dismiss toast' }); + await userEvent.click(dismissButton); + await expect(onDismissButtonClick).toBeCalledTimes(1); + await sleep(400); + await expect(queryByText(toastContent)).not.toBeVisible(); +}; + +export const ToastHover: StoryFn = (): React.ReactElement => { + onDismissButtonClick.mockReset(); + return ; +}; + +ToastHover.play = async () => { + const { getByRole, queryByText, getByTestId } = within(document.body); + await sleep(1000); + + const toastContent = 'Payment successful'; + await expect(queryByText(toastContent)).not.toBeInTheDocument(); + const button = getByRole('button', { name: 'Show Toast' }); + await userEvent.click(button); + await sleep(400); + await expect(queryByText(toastContent)).toBeVisible(); + // hover into toast container + const toastContainer = getByTestId('toast-mouseover-container')!; + await userEvent.hover(toastContainer); + // wait for 2s, while hovering over the toast container the toasts should not dismiss + await sleep(2000); + await expect(queryByText(toastContent)).toBeVisible(); + // hover out of toast container + await userEvent.unhover(toastContainer); + await sleep(1000); + await expect(queryByText(toastContent)).not.toBeVisible(); +}; + +export const ToastStacking: StoryFn = (): React.ReactElement => { + const toast = useToast(); + + React.useEffect(() => { + toast.dismiss(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + + + + + + + ); +}; + +ToastStacking.play = async () => { + const { getByRole, getAllByRole, queryByText, getByTestId } = within(document.body); + await sleep(1000); + + const button1 = getByRole('button', { name: 'Show 1' }); + const button2 = getByRole('button', { name: 'Show 2' }); + const button3 = getByRole('button', { name: 'Show 3' }); + const button4 = getByRole('button', { name: 'Show 4' }); + const promoButton = getByRole('button', { name: 'Show Promo' }); + + // fire 3 toasts + await userEvent.click(button1); + await userEvent.click(button2); + await userEvent.click(button3); + + // wait for 400ms + await sleep(400); + + // expect 3 toasts to be visible + await expect(queryByText('Toast 1')).toBeVisible(); + await expect(queryByText('Toast 2')).toBeVisible(); + await expect(queryByText('Toast 3')).toBeVisible(); + + // expect 3 toasts to be stacked on top of each other + const toastContainer = getByTestId('toast-mouseover-container')!; + await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(120); + + // fire 4th toast + await userEvent.click(button4); + await sleep(400); + await expect(queryByText('Toast 4')).toBeVisible(); + + await expect(toastContainer.getBoundingClientRect().height).toBeLessThan(40); + + const toast4 = queryByText('Toast 4')! as Element; + + // hover into toast container + await userEvent.hover(toast4); + await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(160); + await sleep(400); + await userEvent.unhover(toast4); + await expect(toastContainer.getBoundingClientRect().height).toBeLessThan(40); + + // fire promo toast + await userEvent.click(promoButton); + await sleep(400); + await expect(queryByText('Promo Toast')).toBeVisible(); + await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(30); + + // hover over promo toast, should not increase height + const promoToast = queryByText('Promo Toast')! as Element; + await userEvent.hover(promoToast); + await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(30); + // unhover + await userEvent.unhover(promoToast); + await sleep(400); + await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(30); + + // dissmiss two info toasts + await userEvent.click(getAllByRole('button', { name: 'Dismiss toast' })[0]); + await userEvent.click(getAllByRole('button', { name: 'Dismiss toast' })[1]); + + await sleep(400); + await expect(queryByText('Toast 3')).not.toBeVisible(); + await expect(queryByText('Toast 4')).not.toBeVisible(); + + await expect(toastContainer.getBoundingClientRect().height).toBeGreaterThan(130); +}; + +export default { + title: 'Components/Interaction Tests/Toast', + parameters: { + controls: { + disable: true, + }, + a11y: { disable: true }, + essentials: { disable: true }, + actions: { disable: true }, + }, +}; diff --git a/packages/blade/src/components/Toast/_decisions/decisions.md b/packages/blade/src/components/Toast/_decisions/decisions.md index df66bdf1f9c..6806ced3fc7 100644 --- a/packages/blade/src/components/Toast/_decisions/decisions.md +++ b/packages/blade/src/components/Toast/_decisions/decisions.md @@ -83,7 +83,7 @@ type ToastProps = { /** * @default `neutral` */ - color?: 'neutral' | 'positive' | 'negative' | 'warning' | 'information' + color?: 'neutral' | 'positive' | 'negative' | 'notice' | 'information' /** * Content of the toast diff --git a/packages/blade/src/components/Toast/constants.ts b/packages/blade/src/components/Toast/constants.ts new file mode 100644 index 00000000000..f6b82ee2e8c --- /dev/null +++ b/packages/blade/src/components/Toast/constants.ts @@ -0,0 +1,22 @@ +import { size } from '~tokens/global'; + +export const TOAST_MAX_WIDTH = size['360']; +// higher than modal +export const TOAST_Z_INDEX = 2000; + +// Space between the toasts +export const GUTTER = 12; +// Space between the collapsed toast's peek +export const PEEK_GUTTER = 12; +// Gap between the toast container and the page body +export const CONTAINER_GUTTER_MOBILE = 16; +export const CONTAINER_GUTTER_DESKTOP = 24; +// How much to scale down the peeking toasts +export const SCALE_FACTOR = 0.05; +// While collapsed, how many toasts to show +export const MAX_TOASTS = 1; +// Minimum toasts to show on mobile and desktop +export const MIN_TOAST_MOBILE = 1; +export const MIN_TOAST_DESKTOP = 3; +// While collapsed, how many toasts should be peeking +export const PEEKS = 3; diff --git a/packages/blade/src/components/Toast/index.ts b/packages/blade/src/components/Toast/index.ts new file mode 100644 index 00000000000..df9b1d09820 --- /dev/null +++ b/packages/blade/src/components/Toast/index.ts @@ -0,0 +1,3 @@ +export * from './ToastContainer'; +export * from './useToast'; +export * from './types'; diff --git a/packages/blade/src/components/Toast/types.ts b/packages/blade/src/components/Toast/types.ts new file mode 100644 index 00000000000..8f4daa3fa89 --- /dev/null +++ b/packages/blade/src/components/Toast/types.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type React from 'react'; +import type { ButtonProps } from '~components/Button'; +import type { FeedbackColors } from '~tokens/theme/theme'; + +type ToastProps = { + /** + * @default `informational` + */ + type?: 'informational' | 'promotional'; + + /** + * Content of the toast + */ + content: React.ReactNode; + + /** + * @default `neutral` + */ + color?: FeedbackColors; + + /** + * Can be used to render an icon + */ + leading?: React.ComponentType; + + /** + * If true, the toast will be dismissed after few seconds + * + * Duration for promotional toast is 8s + * Duration for informational toast is 4s + * + * @default false + */ + autoDismiss?: boolean; + + /** + * Duration in milliseconds for which the toast will be visible + * + * @default 4000 for informational toast + * @default 8000 for promotional toast + */ + duration?: number; + + /** + * Called when the toast is dismissed or duration runs out + */ + onDismissButtonClick?: ({ + event, + toastId, + }: { + event: React.MouseEvent; + toastId: string; + }) => void; + + /** + * Primary action of toast + */ + action?: { + text: string; + onClick?: ({ event, toastId }: { event: ButtonProps['onClick']; toastId: string }) => void; + isLoading?: boolean; + }; + + /** + * Forwarded to react-hot-toast + * + * This can be used to programatically update toasts by providing an id + */ + id?: string; +}; + +export type { ToastProps }; diff --git a/packages/blade/src/components/Toast/useToast.tsx b/packages/blade/src/components/Toast/useToast.tsx new file mode 100644 index 00000000000..3886e7113eb --- /dev/null +++ b/packages/blade/src/components/Toast/useToast.tsx @@ -0,0 +1,70 @@ +import type { Toast } from 'react-hot-toast'; +import toast, { useToasterStore } from 'react-hot-toast'; +import type { ToastProps } from './types'; +import { Toast as ToastComponent } from './Toast'; +import { logger } from '~utils/logger'; + +type BladeToast = Omit & ToastProps; +type UseToastReturn = { + toasts: BladeToast[]; + show: (props: ToastProps) => string; + dismiss: (id?: string) => void; +}; + +const useToast = (): UseToastReturn => { + const { toasts } = useToasterStore(); + const show = (props: ToastProps): string => { + props.type = props.type ?? 'informational'; + + // Do not show promotional toasts if there is already one + if ( + toasts.find((t) => { + // @ts-expect-error - react-hot-toast doesn't recognize our promotional type + return t.type === 'promotional'; + }) && + props.type === 'promotional' + ) { + if (__DEV__) { + logger({ + message: 'There can only be one promotional toast at a time', + type: 'warn', + moduleName: 'Toast', + }); + } + return ''; + } + + const isPromoToast = props.type === 'promotional'; + if (props.autoDismiss === undefined) { + // Promotional toasts should not auto dismiss + props.autoDismiss = !isPromoToast; + } + + if (props.duration === undefined) { + // Set default durations + if (isPromoToast) { + props.duration = 8000; + } else { + props.duration = 4000; + } + } + + // If autoDismiss is false, set duration to infinity + if (!props.autoDismiss) { + props.duration = Infinity; + } + + return toast.custom(({ visible, id }) => { + return ; + }, props); + }; + + return { + toasts: (toasts as unknown) as BladeToast[], + show, + dismiss: toast.dismiss, + }; +}; + +export type { UseToastReturn }; +export { useToast }; diff --git a/packages/blade/src/components/index.ts b/packages/blade/src/components/index.ts index 5c3c0d6887c..6add468277f 100644 --- a/packages/blade/src/components/index.ts +++ b/packages/blade/src/components/index.ts @@ -39,6 +39,7 @@ export * from './Table'; export * from './Tabs'; export * from './Tag'; export * from './Tooltip'; +export * from './Toast'; export * from './types'; export * from './Typography'; export * from './VisuallyHidden'; diff --git a/yarn.lock b/yarn.lock index d862797671a..e7da1d54dae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6801,12 +6801,12 @@ schema-utils "^3.0.0" source-map "^0.7.3" -"@razorpay/i18nify-js@^1.4.0": +"@razorpay/i18nify-js@1.4.0", "@razorpay/i18nify-js@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@razorpay/i18nify-js/-/i18nify-js-1.4.0.tgz#4988e9a10b55c6fbe1b045afa7f0f240eea01845" integrity sha512-JY1g3Kn4e8ktsMbrDHOh8TEtPvJOT/A6FKGfNcaEVZ7uJM5Ew9fwP5j6orSF7FaGEZ8PVEIoCsycKhwm9fLfrA== -"@razorpay/i18nify-react@^4.0.0": +"@razorpay/i18nify-react@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@razorpay/i18nify-react/-/i18nify-react-4.0.0.tgz#f7cd2a019cb5b201c9086398486e492971d16ca5" integrity sha512-dOLilNaWS9397R/1hZM4ffFJDmqLUPkqZBp+Bmra3wo8tdhbk43fTAMrZhk1o9EU+WqxiP9Wup8mnZQ33qP0/g== @@ -18010,6 +18010,11 @@ gonzales-pe@^4.3.0: dependencies: minimist "^1.2.5" +goober@^2.1.10: + version "2.1.14" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.14.tgz#4a5c94fc34dc086a8e6035360ae1800005135acd" + integrity sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -27001,6 +27006,13 @@ react-github-button@^0.1.11: dependencies: prop-types "^15.5.10" +react-hot-toast@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-inspector@^6.0.0: version "6.0.2" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d"