Merge pull request #95 from snowball-tools/andrehadianto/T-4869-toast
[T-4869] implement toast component
This commit is contained in:
commit
3718e260f5
@ -10,6 +10,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
|
@ -62,7 +62,7 @@ export const buttonTheme = tv(
|
|||||||
'text-elements-on-tertiary',
|
'text-elements-on-tertiary',
|
||||||
'border',
|
'border',
|
||||||
'border-border-interactive/10',
|
'border-border-interactive/10',
|
||||||
'bg-transparent',
|
'bg-controls-tertiary',
|
||||||
'hover:bg-controls-tertiary-hovered',
|
'hover:bg-controls-tertiary-hovered',
|
||||||
'hover:border-border-interactive-hovered',
|
'hover:border-border-interactive-hovered',
|
||||||
'hover:border-border-interactive-hovered/[0.14]',
|
'hover:border-border-interactive-hovered/[0.14]',
|
||||||
|
@ -9,7 +9,7 @@ import { cloneIcon } from 'utils/cloneIcon';
|
|||||||
/**
|
/**
|
||||||
* Represents the properties of a base button component.
|
* Represents the properties of a base button component.
|
||||||
*/
|
*/
|
||||||
interface ButtonBaseProps {
|
export interface ButtonBaseProps {
|
||||||
/**
|
/**
|
||||||
* The optional left icon element for a component.
|
* The optional left icon element for a component.
|
||||||
* @type {ReactNode}
|
* @type {ReactNode}
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||||
|
|
||||||
|
export const CheckRoundFilledIcon = (props: CustomIconProps) => {
|
||||||
|
return (
|
||||||
|
<CustomIcon
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM15.774 10.1333C16.1237 9.70582 16.0607 9.0758 15.6332 8.72607C15.2058 8.37635 14.5758 8.43935 14.226 8.86679L10.4258 13.5116L9.20711 12.2929C8.81658 11.9024 8.18342 11.9024 7.79289 12.2929C7.40237 12.6834 7.40237 13.3166 7.79289 13.7071L9.79289 15.7071C9.99267 15.9069 10.2676 16.0129 10.5498 15.9988C10.832 15.9847 11.095 15.8519 11.274 15.6333L15.774 10.1333Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</CustomIcon>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||||
|
|
||||||
|
export const InfoRoundFilledIcon = (props: CustomIconProps) => {
|
||||||
|
return (
|
||||||
|
<CustomIcon
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM10 11C10 10.4477 10.4477 10 11 10H12C12.5523 10 13 10.4477 13 11V16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16V12C10.4477 12 10 11.5523 10 11ZM12 7C11.4477 7 11 7.44772 11 8C11 8.55228 11.4477 9 12 9C12.5523 9 13 8.55228 13 8C13 7.44772 12.5523 7 12 7Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</CustomIcon>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||||
|
|
||||||
|
export const LoadingIcon = (props: CustomIconProps) => {
|
||||||
|
return (
|
||||||
|
<CustomIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10.0002 1.66669C10.4605 1.66669 10.8336 2.03978 10.8336 2.50002V5.00002C10.8336 5.46026 10.4605 5.83335 10.0002 5.83335C9.54 5.83335 9.1669 5.46026 9.1669 5.00002V2.50002C9.1669 2.03978 9.54 1.66669 10.0002 1.66669ZM15.8928 4.10746C16.2182 4.4329 16.2182 4.96054 15.8928 5.28597L14.125 7.05374C13.7996 7.37918 13.272 7.37918 12.9465 7.05374C12.6211 6.7283 12.6211 6.20067 12.9465 5.87523L14.7143 4.10746C15.0397 3.78203 15.5674 3.78203 15.8928 4.10746ZM4.10768 4.10746C4.43312 3.78203 4.96076 3.78203 5.28619 4.10746L7.05396 5.87523C7.3794 6.20067 7.3794 6.7283 7.05396 7.05374C6.72852 7.37918 6.20088 7.37918 5.87545 7.05374L4.10768 5.28597C3.78224 4.96054 3.78224 4.4329 4.10768 4.10746ZM1.66666 10.0006C1.66666 9.54035 2.03975 9.16725 2.49999 9.16725H4.99999C5.46023 9.16725 5.83332 9.54035 5.83332 10.0006C5.83332 10.4608 5.46023 10.8339 4.99999 10.8339H2.49999C2.03975 10.8339 1.66666 10.4608 1.66666 10.0006ZM14.1667 10.0006C14.1667 9.54035 14.5398 9.16725 15 9.16725H17.5C17.9602 9.16725 18.3333 9.54035 18.3333 10.0006C18.3333 10.4608 17.9602 10.8339 17.5 10.8339H15C14.5398 10.8339 14.1667 10.4608 14.1667 10.0006ZM7.05396 12.9463C7.3794 13.2717 7.3794 13.7994 7.05396 14.1248L5.28619 15.8926C4.96076 16.218 4.43312 16.218 4.10768 15.8926C3.78224 15.5671 3.78224 15.0395 4.10768 14.7141L5.87545 12.9463C6.20088 12.6209 6.72852 12.6209 7.05396 12.9463ZM12.9465 12.9463C13.272 12.6209 13.7996 12.6209 14.125 12.9463L15.8928 14.7141C16.2182 15.0395 16.2182 15.5671 15.8928 15.8926C15.5674 16.218 15.0397 16.218 14.7143 15.8926L12.9465 14.1248C12.6211 13.7994 12.6211 13.2717 12.9465 12.9463ZM10.0002 14.1667C10.4605 14.1667 10.8336 14.5398 10.8336 15V17.5C10.8336 17.9603 10.4605 18.3334 10.0002 18.3334C9.54 18.3334 9.1669 17.9603 9.1669 17.5V15C9.1669 14.5398 9.54 14.1667 10.0002 14.1667Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</CustomIcon>
|
||||||
|
);
|
||||||
|
};
|
@ -9,3 +9,6 @@ export * from './WarningIcon';
|
|||||||
export * from './SearchIcon';
|
export * from './SearchIcon';
|
||||||
export * from './CrossIcon';
|
export * from './CrossIcon';
|
||||||
export * from './GlobeIcon';
|
export * from './GlobeIcon';
|
||||||
|
export * from './CheckRoundFilledIcon';
|
||||||
|
export * from './InfoRoundFilledIcon';
|
||||||
|
export * from './LoadingIcon';
|
||||||
|
@ -55,7 +55,7 @@ export const Input = ({
|
|||||||
const renderLeftIcon = useMemo(() => {
|
const renderLeftIcon = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<div className={iconContainerCls({ class: 'left-0 pl-4' })}>
|
<div className={iconContainerCls({ class: 'left-0 pl-4' })}>
|
||||||
{cloneIcon(leftIcon, { className: iconCls(), ariaHidden: true })}
|
{cloneIcon(leftIcon, { className: iconCls(), 'aria-hidden': true })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [cloneIcon, iconCls, iconContainerCls, leftIcon]);
|
}, [cloneIcon, iconCls, iconContainerCls, leftIcon]);
|
||||||
@ -63,7 +63,7 @@ export const Input = ({
|
|||||||
const renderRightIcon = useMemo(() => {
|
const renderRightIcon = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<div className={iconContainerCls({ class: 'pr-4 right-0' })}>
|
<div className={iconContainerCls({ class: 'pr-4 right-0' })}>
|
||||||
{cloneIcon(rightIcon, { className: iconCls(), ariaHidden: true })}
|
{cloneIcon(rightIcon, { className: iconCls(), 'aria-hidden': true })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [cloneIcon, iconCls, iconContainerCls, rightIcon]);
|
}, [cloneIcon, iconCls, iconContainerCls, rightIcon]);
|
||||||
@ -73,7 +73,7 @@ export const Input = ({
|
|||||||
<div className={helperTextCls()}>
|
<div className={helperTextCls()}>
|
||||||
{state &&
|
{state &&
|
||||||
cloneIcon(<WarningIcon className={helperIconCls()} />, {
|
cloneIcon(<WarningIcon className={helperIconCls()} />, {
|
||||||
ariaHidden: true,
|
'aria-hidden': true,
|
||||||
})}
|
})}
|
||||||
<p>{helperText}</p>
|
<p>{helperText}</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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<typeof simpleToastTheme>;
|
@ -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 <CheckRoundFilledIcon />;
|
||||||
|
if (variant === 'error') return <WarningIcon />;
|
||||||
|
if (variant === 'warning') return <WarningIcon />;
|
||||||
|
if (variant === 'info') return <InfoRoundFilledIcon />;
|
||||||
|
return <LoadingIcon />; // variant === 'loading'
|
||||||
|
}, [variant]);
|
||||||
|
|
||||||
|
const renderCta = useMemo(() => {
|
||||||
|
if (!hasCta) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1.5 ml-2">
|
||||||
|
{cta.map(({ buttonLabel, ...props }, index) => (
|
||||||
|
<Button key={index} {...props}>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [cta]);
|
||||||
|
|
||||||
|
const renderCloseButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<div onClick={() => onDismiss(id)} className={closeIconCls()}>
|
||||||
|
<CrossIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastPrimitive.Root {...props} asChild>
|
||||||
|
<motion.li
|
||||||
|
animate={{
|
||||||
|
y: 'var(--radix-toast-swipe-move-y, 0)',
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
className={wrapperCls({ class: className })}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
initial={{ y: '100%', opacity: 0 }}
|
||||||
|
>
|
||||||
|
{cloneIcon(Icon, { className: iconCls() })}
|
||||||
|
<ToastPrimitive.Title asChild>
|
||||||
|
<p className={titleCls()}>{title}</p>
|
||||||
|
</ToastPrimitive.Title>
|
||||||
|
{renderCta}
|
||||||
|
{renderCloseButton}
|
||||||
|
</motion.li>
|
||||||
|
</ToastPrimitive.Root>
|
||||||
|
);
|
||||||
|
};
|
@ -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 (
|
||||||
|
<Provider {...props}>
|
||||||
|
{children}
|
||||||
|
<Viewport className="fixed inset-x-0 bottom-0 px-4 py-10" />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
25
packages/frontend/src/components/shared/Toast/Toaster.tsx
Normal file
25
packages/frontend/src/components/shared/Toast/Toaster.tsx
Normal file
@ -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 }) => (
|
||||||
|
<SimpleToast key={id} {...(props as SimpleToastProps)} id={id} />
|
||||||
|
)),
|
||||||
|
[toasts],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider>
|
||||||
|
{renderToasts}
|
||||||
|
<Viewport className="z-toast fixed inset-x-0 bottom-0 mx-auto w-fit px-4 pb-10" />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
2
packages/frontend/src/components/shared/Toast/index.ts
Normal file
2
packages/frontend/src/components/shared/Toast/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './Toaster';
|
||||||
|
export * from './useToast';
|
192
packages/frontend/src/components/shared/Toast/useToast.tsx
Normal file
192
packages/frontend/src/components/shared/Toast/useToast.tsx
Normal file
@ -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<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
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<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
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<State>(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 };
|
@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { Toaster } from 'react-hot-toast';
|
|
||||||
import { GQLClient } from 'gql-client';
|
import { GQLClient } from 'gql-client';
|
||||||
|
|
||||||
import { ThemeProvider } from '@material-tailwind/react';
|
import { ThemeProvider } from '@material-tailwind/react';
|
||||||
@ -11,6 +10,7 @@ import '@fontsource/inter';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import reportWebVitals from './reportWebVitals';
|
import reportWebVitals from './reportWebVitals';
|
||||||
import { GQLClientProvider } from './context/GQLClientContext';
|
import { GQLClientProvider } from './context/GQLClientContext';
|
||||||
|
import { Toaster } from 'components/shared/Toast';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement,
|
document.getElementById('root') as HTMLElement,
|
||||||
@ -26,7 +26,7 @@ root.render(
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<GQLClientProvider client={gqlClient}>
|
<GQLClientProvider client={gqlClient}>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="bottom-center" />
|
<Toaster />
|
||||||
</GQLClientProvider>
|
</GQLClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
@ -26,6 +26,7 @@ import {
|
|||||||
renderInlineNotifications,
|
renderInlineNotifications,
|
||||||
} from './renders/inlineNotifications';
|
} from './renders/inlineNotifications';
|
||||||
import { renderInputs } from './renders/input';
|
import { renderInputs } from './renders/input';
|
||||||
|
import { renderToast, renderToastsWithCta } from './renders/toast';
|
||||||
import { renderTooltips } from './renders/tooltip';
|
import { renderTooltips } from './renders/tooltip';
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
@ -47,6 +48,16 @@ const Page = () => {
|
|||||||
|
|
||||||
<div className="w-full h border border-gray-200 px-20 my-10" />
|
<div className="w-full h border border-gray-200 px-20 my-10" />
|
||||||
|
|
||||||
|
{/* Toast */}
|
||||||
|
<div className="flex flex-col gap-10 items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Toasts</h1>
|
||||||
|
{renderToastsWithCta()}
|
||||||
|
{renderToast()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full h border border-gray-200 px-20 my-10" />
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
<div className="flex flex-col gap-10 items-center justify-between">
|
<div className="flex flex-col gap-10 items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Tooltip</h1>
|
<h1 className="text-2xl font-bold">Tooltip</h1>
|
||||||
<div className="flex w-full flex-wrap max-w-[680px] justify-center gap-10">
|
<div className="flex w-full flex-wrap max-w-[680px] justify-center gap-10">
|
||||||
|
66
packages/frontend/src/pages/components/renders/toast.tsx
Normal file
66
packages/frontend/src/pages/components/renders/toast.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex gap-10">
|
||||||
|
{(['success', 'error', 'warning', 'info', 'loading'] as const).map(
|
||||||
|
(variant, index) => (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
toast({
|
||||||
|
onDismiss: dismiss,
|
||||||
|
id: `${variant}_${index}`,
|
||||||
|
title: 'Project created',
|
||||||
|
cta: [
|
||||||
|
{
|
||||||
|
buttonLabel: 'Button',
|
||||||
|
size: 'xs',
|
||||||
|
variant: 'tertiary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buttonLabel: 'Button',
|
||||||
|
size: 'xs',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
variant,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
key={`${variant}_${index}`}
|
||||||
|
>
|
||||||
|
{variant} with cta
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderToast = () => {
|
||||||
|
const { toast, dismiss } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-10">
|
||||||
|
{(['success', 'error', 'warning', 'info', 'loading'] as const).map(
|
||||||
|
(variant, index) => (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
toast({
|
||||||
|
onDismiss: dismiss,
|
||||||
|
id: `${variant}_${index}`,
|
||||||
|
title: 'Project created',
|
||||||
|
variant,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
key={`${variant}_${index}`}
|
||||||
|
>
|
||||||
|
{variant}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -161,6 +161,9 @@ export default withMT({
|
|||||||
3.25: '0.8125rem',
|
3.25: '0.8125rem',
|
||||||
3.5: '0.875rem',
|
3.5: '0.875rem',
|
||||||
},
|
},
|
||||||
|
zIndex: {
|
||||||
|
toast: '9999',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
17
yarn.lock
17
yarn.lock
@ -3509,6 +3509,23 @@
|
|||||||
"@radix-ui/react-roving-focus" "1.0.4"
|
"@radix-ui/react-roving-focus" "1.0.4"
|
||||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
"@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":
|
"@radix-ui/react-tooltip@^1.0.7":
|
||||||
version "1.0.7"
|
version "1.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"
|
||||||
|
Loading…
Reference in New Issue
Block a user