From b0264f0533e189ef13a75c1ab1644671ebbe2566 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Mon, 30 Sep 2024 14:32:17 +0100 Subject: [PATCH] feat(ui): #1807: add Toast v2 UI component --- packages/ui/src/Icon/index.tsx | 4 +- packages/ui/src/Toast/Context.ts | 68 +++++++++++++ packages/ui/src/Toast/Provider.tsx | 29 ++++++ packages/ui/src/Toast/Toast.tsx | 122 ++++++++++++++++++++++++ packages/ui/src/Toast/index.stories.tsx | 63 ++++++++++++ packages/ui/src/Toast/index.tsx | 3 + 6 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/Toast/Context.ts create mode 100644 packages/ui/src/Toast/Provider.tsx create mode 100644 packages/ui/src/Toast/Toast.tsx create mode 100644 packages/ui/src/Toast/index.stories.tsx create mode 100644 packages/ui/src/Toast/index.tsx diff --git a/packages/ui/src/Icon/index.tsx b/packages/ui/src/Icon/index.tsx index 1a8f7efb01..0de23de0c2 100644 --- a/packages/ui/src/Icon/index.tsx +++ b/packages/ui/src/Icon/index.tsx @@ -1,5 +1,5 @@ import { LucideIcon } from 'lucide-react'; -import { ComponentProps } from 'react'; +import { ComponentProps, FC } from 'react'; import { DefaultTheme, useTheme } from 'styled-components'; export type IconSize = 'sm' | 'md' | 'lg'; @@ -13,7 +13,7 @@ export interface IconProps { * * ``` */ - IconComponent: LucideIcon; + IconComponent: LucideIcon | FC; /** * - `sm`: 16px square * - `md`: 24px square diff --git a/packages/ui/src/Toast/Context.ts b/packages/ui/src/Toast/Context.ts new file mode 100644 index 0000000000..cb0fcabfd8 --- /dev/null +++ b/packages/ui/src/Toast/Context.ts @@ -0,0 +1,68 @@ +import { createContext, useContext, useState } from 'react'; +import { ToastProps } from './Toast.tsx'; + +interface ToastContextValue { + toasts: Map; + addToast: (toast: ToastProps) => string; + removeToast: (id: string) => void; + updateToast: (id: string, props: ToastProps) => void; +} + +export const ToastContext = createContext({} as ToastContextValue); + +export const useToastContext = () => useContext(ToastContext); + +export const useSetupToastContext = (): ToastContextValue => { + const [toasts, setToasts] = useState>(new Map()); + + const addToast = (toast: ToastProps): string => { + const id = performance.now().toString() + toast.title; + setToasts(new Map(toasts.set(id, toast))); + return id; + }; + + const removeToast = (id: string) => { + toasts.delete(id); + setToasts(new Map(toasts)); + }; + + const updateToast = (id: string, toast: ToastProps) => { + toasts.set(id, toast); + setToasts(new Map(toasts)); + }; + + return { + toasts, + addToast, + updateToast, + removeToast, + }; +}; + +export const useToastProps = (id: string): ToastProps => { + const { toasts } = useToastContext(); + const toast = toasts.get(id); + if (!toast) { + throw new Error(`No toast found with id: ${id}`); + } + return toast; +}; + +interface ToastInstance { + id: string; + close: () => void; + update: (toast: ToastProps) => void; +} + +export const useToast = () => { + const { addToast, removeToast, updateToast } = useToastContext(); + + return (toast: ToastProps): ToastInstance => { + const id = addToast(toast); + + const close = () => removeToast(id); + const update = (toast: ToastProps) => updateToast(id, toast); + + return { id, close, update }; + }; +}; diff --git a/packages/ui/src/Toast/Provider.tsx b/packages/ui/src/Toast/Provider.tsx new file mode 100644 index 0000000000..f6557ecec4 --- /dev/null +++ b/packages/ui/src/Toast/Provider.tsx @@ -0,0 +1,29 @@ +import { + ToastProvider as RadixToastProvider, + ToastViewport as RadixToastViewport, +} from '@radix-ui/react-toast'; +import { ToastContext, useSetupToastContext } from './Context.ts'; +import { Toast } from './Toast.tsx'; +import { ReactNode } from 'react'; + +export interface ToastProviderProps { + children: ReactNode; +} + +export const ToastProvider = ({ children }: ToastProviderProps) => { + const contextValue = useSetupToastContext(); + console.log(contextValue.toasts); + + return ( + + {children} + + + {[...contextValue.toasts.keys()].map(id => ( + + ))} + + + + ); +}; diff --git a/packages/ui/src/Toast/Toast.tsx b/packages/ui/src/Toast/Toast.tsx new file mode 100644 index 0000000000..2fadf2e975 --- /dev/null +++ b/packages/ui/src/Toast/Toast.tsx @@ -0,0 +1,122 @@ +import type { FC } from 'react'; +import { X, type LucideIcon } from 'lucide-react'; +import { DefaultTheme, styled } from 'styled-components'; +import { + Root as RadixToastRoot, + Close as RadixToastClose, + Title as RadixToastTitle, + Description as RadixToastDescription, +} from '@radix-ui/react-toast'; +import { useDensity } from '../hooks/useDensity'; +import { ActionType } from '../utils/ActionType.ts'; +import { Text } from '../Text'; +import { Icon } from '../Icon'; +import { useToastContext, useToastProps } from './Context.ts'; + +const getBackground = (theme: DefaultTheme, actionType: ActionType) => { + if (actionType === 'unshield') { + return theme.color.unshield.light; + } + if (actionType === 'accent') { + return theme.color.secondary.light; + } + if (actionType === 'destructive') { + return theme.color.destructive.light; + } + return theme.color.primary.light; +}; + +const ToastRoot = styled(RadixToastRoot)<{ + $actionType: ActionType; +}>` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: ${props => props.theme.spacing(3)}; + padding: ${props => props.theme.spacing(3)}; + border-radius: ${props => props.theme.borderRadius.sm}; + background-color: ${props => getBackground(props.theme, props.$actionType)}; + color: ${props => props.theme.color.primary.dark}; + transition: transform 0.05s; + + &[data-swipe='move'] { + transform: translateX(var(--radix-toast-swipe-move-x)); + } +`; + +const Info = styled.div` + display: flex; + flex-grow: 1; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + text-align: left; + gap: ${props => props.theme.spacing(1)}; +`; + +const IconAdornment = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: ${props => props.theme.spacing(1)}; + width: ${props => props.theme.spacing(6)}; + height: ${props => props.theme.spacing(6)}; + border-radius: ${props => props.theme.borderRadius.full}; + background-color: transparent; +`; + +export interface ToastProps { + actionType?: ActionType; + icon?: LucideIcon | FC; + title: string; + description?: string; +} + +export interface ToastInnerProps { + id: string; +} + +export const Toast = ({ id }: ToastInnerProps) => { + const { actionType = 'default', icon, description, title } = useToastProps(id); + const { removeToast } = useToastContext(); + const density = useDensity(); + + const onClose = (value: boolean) => { + if (!value) { + removeToast(id); + } + }; + + return ( + + {density === 'sparse' && icon && } + {density === 'compact' ? ( + + 'inherit'}> + {title} + + + ) : ( + + + 'inherit'}> + {title} + + + {description && ( + + 'inherit'}> + {description} + + + )} + + )} + + + + + + + ); +}; diff --git a/packages/ui/src/Toast/index.stories.tsx b/packages/ui/src/Toast/index.stories.tsx new file mode 100644 index 0000000000..767a1e95e2 --- /dev/null +++ b/packages/ui/src/Toast/index.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CheckCircle, AlertCircle } from 'lucide-react'; +import { ToastProvider, useToast } from '.'; +import { Button } from '../Button'; + +const meta: Meta = { + component: ToastProvider, + tags: ['autodocs', '!dev'], + argTypes: {}, +}; +export default meta; + +type Story = StoryObj; + +const ToastRenderer = () => { + const openToast = useToast(); + + const onOpenClassic = () => { + openToast({ + title: 'Hello world', + description: 'This is a toast message', + icon: CheckCircle, + }); + }; + + const onOpenRerendering = () => { + const toast = openToast({ + title: 'Re-rendering toast', + actionType: 'destructive', + description: 'This is a toast message. It will re-render in 2 second', + icon: AlertCircle, + }); + + setTimeout(() => { + toast.update({ + title: 'Re-rendering toast', + icon: CheckCircle, + actionType: 'accent', + description: 'Wow, not it is updated!', + }); + }, 2000); + }; + + return ( +
+ + +
+ ); +}; + +export const Basic: Story = { + args: {}, + + render: function Render() { + return ( + + + + ); + }, +}; diff --git a/packages/ui/src/Toast/index.tsx b/packages/ui/src/Toast/index.tsx new file mode 100644 index 0000000000..f9e26724c3 --- /dev/null +++ b/packages/ui/src/Toast/index.tsx @@ -0,0 +1,3 @@ +export { ToastProvider } from './Provider'; +export type { ToastProps } from './Toast'; +export { useToast } from './Context';