diff --git a/packages/frontend/package.json b/packages/frontend/package.json index cb695d91..53252773 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -11,6 +11,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", diff --git a/packages/frontend/src/components/shared/Button/Button.theme.ts b/packages/frontend/src/components/shared/Button/Button.theme.ts index 6c9614c6..194c6550 100644 --- a/packages/frontend/src/components/shared/Button/Button.theme.ts +++ b/packages/frontend/src/components/shared/Button/Button.theme.ts @@ -62,7 +62,7 @@ export const buttonTheme = tv( 'text-elements-on-tertiary', 'border', 'border-border-interactive/10', - 'bg-transparent', + 'bg-controls-tertiary', 'hover:bg-controls-tertiary-hovered', 'hover:border-border-interactive-hovered', 'hover:border-border-interactive-hovered/[0.14]', diff --git a/packages/frontend/src/components/shared/Button/Button.tsx b/packages/frontend/src/components/shared/Button/Button.tsx index 968cca72..90d9085c 100644 --- a/packages/frontend/src/components/shared/Button/Button.tsx +++ b/packages/frontend/src/components/shared/Button/Button.tsx @@ -9,7 +9,7 @@ import { cloneIcon } from 'utils/cloneIcon'; /** * Represents the properties of a base button component. */ -interface ButtonBaseProps { +export interface ButtonBaseProps { /** * The optional left icon element for a component. * @type {ReactNode} diff --git a/packages/frontend/src/components/shared/CustomIcon/CheckRoundFilledIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/CheckRoundFilledIcon.tsx new file mode 100644 index 00000000..145e68e1 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/CheckRoundFilledIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const CheckRoundFilledIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/InfoRoundFilledIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/InfoRoundFilledIcon.tsx new file mode 100644 index 00000000..d7eb24c4 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/InfoRoundFilledIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const InfoRoundFilledIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/LoadingIcon.tsx b/packages/frontend/src/components/shared/CustomIcon/LoadingIcon.tsx new file mode 100644 index 00000000..012fa986 --- /dev/null +++ b/packages/frontend/src/components/shared/CustomIcon/LoadingIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { CustomIcon, CustomIconProps } from './CustomIcon'; + +export const LoadingIcon = (props: CustomIconProps) => { + return ( + + + + ); +}; diff --git a/packages/frontend/src/components/shared/CustomIcon/index.ts b/packages/frontend/src/components/shared/CustomIcon/index.ts index 034c9cda..46fd03ad 100644 --- a/packages/frontend/src/components/shared/CustomIcon/index.ts +++ b/packages/frontend/src/components/shared/CustomIcon/index.ts @@ -10,3 +10,6 @@ export * from './SearchIcon'; export * from './CrossIcon'; export * from './GlobeIcon'; export * from './CalendarIcon'; +export * from './CheckRoundFilledIcon'; +export * from './InfoRoundFilledIcon'; +export * from './LoadingIcon'; diff --git a/packages/frontend/src/components/shared/Input/Input.tsx b/packages/frontend/src/components/shared/Input/Input.tsx index f5bbdca5..d2d5459a 100644 --- a/packages/frontend/src/components/shared/Input/Input.tsx +++ b/packages/frontend/src/components/shared/Input/Input.tsx @@ -55,7 +55,7 @@ export const Input = ({ const renderLeftIcon = useMemo(() => { return (
- {cloneIcon(leftIcon, { className: iconCls(), ariaHidden: true })} + {cloneIcon(leftIcon, { className: iconCls(), 'aria-hidden': true })}
); }, [cloneIcon, iconCls, iconContainerCls, leftIcon]); @@ -63,7 +63,7 @@ export const Input = ({ const renderRightIcon = useMemo(() => { return (
- {cloneIcon(rightIcon, { className: iconCls(), ariaHidden: true })} + {cloneIcon(rightIcon, { className: iconCls(), 'aria-hidden': true })}
); }, [cloneIcon, iconCls, iconContainerCls, rightIcon]); @@ -73,7 +73,7 @@ export const Input = ({
{state && cloneIcon(, { - ariaHidden: true, + 'aria-hidden': true, })}

{helperText}

diff --git a/packages/frontend/src/components/shared/Toast/SimpleToast.theme.ts b/packages/frontend/src/components/shared/Toast/SimpleToast.theme.ts new file mode 100644 index 00000000..bac805ce --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/SimpleToast.theme.ts @@ -0,0 +1,58 @@ +import { VariantProps, tv } from 'tailwind-variants'; + +export const simpleToastTheme = tv( + { + slots: { + wrapper: [ + 'flex', + 'items-center', + 'py-2', + 'pl-2', + 'pr-1.5', + 'gap-2', + 'rounded-full', + 'mx-auto', + 'mt-3', + 'w-fit', + 'overflow-hidden', + 'bg-surface-high-contrast', + 'shadow-sm', + ], + icon: ['flex', 'items-center', 'justify-center', 'w-5', 'h-5'], + closeIcon: [ + 'cursor-pointer', + 'flex', + 'items-center', + 'justify-center', + 'w-6', + 'h-6', + 'text-elements-on-high-contrast', + ], + title: ['text-sm', 'text-elements-on-high-contrast'], + }, + variants: { + variant: { + success: { + icon: ['text-elements-success'], + }, + error: { + icon: ['text-elements-danger'], + }, + warning: { + icon: ['text-elements-warning'], + }, + info: { + icon: ['text-elements-info'], + }, + loading: { + icon: ['text-elements-info'], + }, + }, + }, + }, + { + responsiveVariants: true, + }, +); + +export type SimpleToastTheme = VariantProps; diff --git a/packages/frontend/src/components/shared/Toast/SimpleToast.tsx b/packages/frontend/src/components/shared/Toast/SimpleToast.tsx new file mode 100644 index 00000000..7c10f855 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/SimpleToast.tsx @@ -0,0 +1,94 @@ +import React, { useMemo } from 'react'; +import * as ToastPrimitive from '@radix-ui/react-toast'; +import { ToastProps } from '@radix-ui/react-toast'; +import { motion } from 'framer-motion'; +import { simpleToastTheme, type SimpleToastTheme } from './SimpleToast.theme'; +import { + LoadingIcon, + CheckRoundFilledIcon, + CrossIcon, + InfoRoundFilledIcon, + WarningIcon, +} from 'components/shared/CustomIcon'; +import { Button, type ButtonOrLinkProps } from 'components/shared/Button'; +import { cloneIcon } from 'utils/cloneIcon'; + +type CtaProps = ButtonOrLinkProps & { + buttonLabel: string; +}; +export interface SimpleToastProps extends ToastProps { + id: string; + title: string; + variant?: SimpleToastTheme['variant']; + cta?: CtaProps[]; + onDismiss: (toastId: string) => void; +} + +export const SimpleToast = ({ + id, + className, + title, + variant = 'success', + cta = [], + onDismiss, + ...props +}: SimpleToastProps) => { + const hasCta = cta.length > 0; + const { + wrapper: wrapperCls, + icon: iconCls, + closeIcon: closeIconCls, + title: titleCls, + } = simpleToastTheme({ variant }); + + const Icon = useMemo(() => { + if (variant === 'success') return ; + if (variant === 'error') return ; + if (variant === 'warning') return ; + if (variant === 'info') return ; + return ; // variant === 'loading' + }, [variant]); + + const renderCta = useMemo(() => { + if (!hasCta) return null; + return ( +
+ {cta.map(({ buttonLabel, ...props }, index) => ( + + ))} +
+ ); + }, [cta]); + + const renderCloseButton = useMemo( + () => ( +
onDismiss(id)} className={closeIconCls()}> + +
+ ), + [id], + ); + + return ( + + + {cloneIcon(Icon, { className: iconCls() })} + +

{title}

+
+ {renderCta} + {renderCloseButton} +
+
+ ); +}; diff --git a/packages/frontend/src/components/shared/Toast/ToastProvider.tsx b/packages/frontend/src/components/shared/Toast/ToastProvider.tsx new file mode 100644 index 00000000..d45d6e89 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/ToastProvider.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { + Provider, + Viewport, + type ToastProviderProps, +} from '@radix-ui/react-toast'; + +export const ToastProvider = ({ children, ...props }: ToastProviderProps) => { + return ( + + {children} + + + ); +}; diff --git a/packages/frontend/src/components/shared/Toast/Toaster.tsx b/packages/frontend/src/components/shared/Toast/Toaster.tsx new file mode 100644 index 00000000..66556956 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/Toaster.tsx @@ -0,0 +1,25 @@ +import React, { ComponentPropsWithoutRef, useMemo } from 'react'; +import { Provider, Viewport } from '@radix-ui/react-toast'; +import { SimpleToast, SimpleToastProps } from './SimpleToast'; +import { useToast } from './useToast'; + +interface ToasterProps extends ComponentPropsWithoutRef<'div'> {} + +export const Toaster = ({}: ToasterProps) => { + const { toasts } = useToast(); + + const renderToasts = useMemo( + () => + toasts.map(({ id, ...props }) => ( + + )), + [toasts], + ); + + return ( + + {renderToasts} + + + ); +}; diff --git a/packages/frontend/src/components/shared/Toast/index.ts b/packages/frontend/src/components/shared/Toast/index.ts new file mode 100644 index 00000000..6404e342 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/index.ts @@ -0,0 +1,2 @@ +export * from './Toaster'; +export * from './useToast'; diff --git a/packages/frontend/src/components/shared/Toast/useToast.tsx b/packages/frontend/src/components/shared/Toast/useToast.tsx new file mode 100644 index 00000000..bd0be17e --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/useToast.tsx @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import React from 'react'; +import { type ToastProps } from '@radix-ui/react-toast'; +import { SimpleToastProps } from './SimpleToast'; + +const TOAST_LIMIT = 3; +const TOAST_REMOVE_DELAY_DEFAULT = 7000; + +type ToasterToast = ToastProps & + SimpleToastProps & { + id: string; + }; + +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', +} as const; + +let count = 0; + +const genId = () => { + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); +}; + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType['ADD_TOAST']; + toast: ToasterToast; + } + | { + type: ActionType['UPDATE_TOAST']; + toast: Partial; + } + | { + type: ActionType['DISMISS_TOAST']; + toastId?: ToasterToast['id']; + duration?: ToasterToast['duration']; + } + | { + type: ActionType['REMOVE_TOAST']; + toastId?: ToasterToast['id']; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string, duration: number) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: 'REMOVE_TOAST', + toastId: toastId, + }); + }, duration ?? TOAST_REMOVE_DELAY_DEFAULT); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case 'UPDATE_TOAST': + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id + ? ({ ...t, ...action.toast } as ToasterToast) + : t, + ), + }; + + case 'DISMISS_TOAST': { + const { toastId, duration = TOAST_REMOVE_DELAY_DEFAULT } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId, duration); + } else { + state.toasts.forEach((_toast) => { + addToRemoveQueue(_toast.id, duration); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(_state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +const dispatch = (action: Action) => { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +}; + +const toast = (props: ToasterToast) => { + if (!props.duration) { + props.duration = 2000; + } + const id = genId(); + + const update = (_props: ToasterToast) => + dispatch({ + type: 'UPDATE_TOAST', + toast: { ..._props, id }, + }); + const dismiss = () => + dispatch({ type: 'DISMISS_TOAST', toastId: id, duration: props.duration }); + + dispatch({ + type: 'ADD_TOAST', + toast: { + ...props, + id, + open: true, + onOpenChange: (open: boolean) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +}; + +const useToast = () => { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + }; +}; + +export { toast, useToast }; diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx index da29f575..d0c0d9b7 100644 --- a/packages/frontend/src/index.tsx +++ b/packages/frontend/src/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import assert from 'assert'; -import { Toaster } from 'react-hot-toast'; import { GQLClient } from 'gql-client'; import { ThemeProvider } from '@material-tailwind/react'; @@ -11,6 +10,7 @@ import '@fontsource/inter'; import App from './App'; import reportWebVitals from './reportWebVitals'; import { GQLClientProvider } from './context/GQLClientContext'; +import { Toaster } from 'components/shared/Toast'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, @@ -26,7 +26,7 @@ root.render( - + , diff --git a/packages/frontend/src/pages/components/index.tsx b/packages/frontend/src/pages/components/index.tsx index 355e0dd0..dad58b4b 100644 --- a/packages/frontend/src/pages/components/index.tsx +++ b/packages/frontend/src/pages/components/index.tsx @@ -27,6 +27,7 @@ import { } from './renders/inlineNotifications'; import { renderInputs } from './renders/input'; import { DatePicker } from 'components/shared/DatePicker'; +import { renderToast, renderToastsWithCta } from './renders/toast'; import { renderTooltips } from './renders/tooltip'; const Page = () => { @@ -48,6 +49,16 @@ const Page = () => {
+ {/* Toast */} +
+

Toasts

+ {renderToastsWithCta()} + {renderToast()} +
+ +
+ + {/* Tooltip */}

Tooltip

diff --git a/packages/frontend/src/pages/components/renders/toast.tsx b/packages/frontend/src/pages/components/renders/toast.tsx new file mode 100644 index 00000000..fb19e33a --- /dev/null +++ b/packages/frontend/src/pages/components/renders/toast.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Button } from 'components/shared/Button'; +import { useToast } from 'components/shared/Toast'; + +export const renderToastsWithCta = () => { + const { toast, dismiss } = useToast(); + + return ( +
+ {(['success', 'error', 'warning', 'info', 'loading'] as const).map( + (variant, index) => ( + + ), + )} +
+ ); +}; + +export const renderToast = () => { + const { toast, dismiss } = useToast(); + + return ( +
+ {(['success', 'error', 'warning', 'info', 'loading'] as const).map( + (variant, index) => ( + + ), + )} +
+ ); +}; diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index cce3380a..ffd17636 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -161,6 +161,9 @@ export default withMT({ 3.25: '0.8125rem', 3.5: '0.875rem', }, + zIndex: { + toast: '9999', + }, }, }, plugins: [], diff --git a/yarn.lock b/yarn.lock index f19d7c80..ce8ae5dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3548,6 +3548,23 @@ "@radix-ui/react-roving-focus" "1.0.4" "@radix-ui/react-use-controllable-state" "1.0.1" +"@radix-ui/react-toast@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.5.tgz#f5788761c0142a5ae9eb97f0051fd3c48106d9e6" + integrity sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-collection" "1.0.3" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" "@radix-ui/react-tooltip@^1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"