Skip to content

Commit

Permalink
feat(ui): #1807: add Toast v2 UI component
Browse files Browse the repository at this point in the history
  • Loading branch information
VanishMax committed Sep 30, 2024
1 parent 85cf0bd commit b0264f0
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 2 deletions.
4 changes: 2 additions & 2 deletions packages/ui/src/Icon/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,7 +13,7 @@ export interface IconProps {
* <Icon IconComponent={ChevronRight} />
* ```
*/
IconComponent: LucideIcon;
IconComponent: LucideIcon | FC;
/**
* - `sm`: 16px square
* - `md`: 24px square
Expand Down
68 changes: 68 additions & 0 deletions packages/ui/src/Toast/Context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createContext, useContext, useState } from 'react';
import { ToastProps } from './Toast.tsx';

interface ToastContextValue {
toasts: Map<string, ToastProps>;
addToast: (toast: ToastProps) => string;
removeToast: (id: string) => void;
updateToast: (id: string, props: ToastProps) => void;
}

export const ToastContext = createContext<ToastContextValue>({} as ToastContextValue);

export const useToastContext = () => useContext(ToastContext);

export const useSetupToastContext = (): ToastContextValue => {
const [toasts, setToasts] = useState<Map<string, ToastProps>>(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 };
};
};
29 changes: 29 additions & 0 deletions packages/ui/src/Toast/Provider.tsx
Original file line number Diff line number Diff line change
@@ -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);

Check failure on line 15 in packages/ui/src/Toast/Provider.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

return (
<ToastContext.Provider value={contextValue}>
{children}

<RadixToastProvider>
{[...contextValue.toasts.keys()].map(id => (
<Toast key={id} id={id} />
))}
<RadixToastViewport />
</RadixToastProvider>
</ToastContext.Provider>
);
};
122 changes: 122 additions & 0 deletions packages/ui/src/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToastRoot open={true} duration={5000} $actionType={actionType} onOpenChange={onClose}>
{density === 'sparse' && icon && <Icon IconComponent={icon} size='md' />}
{density === 'compact' ? (
<RadixToastTitle asChild>
<Text small color={() => 'inherit'}>
{title}
</Text>
</RadixToastTitle>
) : (
<Info>
<RadixToastTitle asChild>
<Text strong color={() => 'inherit'}>
{title}
</Text>
</RadixToastTitle>
{description && (
<RadixToastDescription asChild>
<Text small color={() => 'inherit'}>
{description}
</Text>
</RadixToastDescription>
)}
</Info>
)}
<RadixToastClose asChild>
<IconAdornment type='button'>
<Icon IconComponent={X} size='md' />
</IconAdornment>
</RadixToastClose>
</ToastRoot>
);
};
63 changes: 63 additions & 0 deletions packages/ui/src/Toast/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ToastProvider> = {
component: ToastProvider,
tags: ['autodocs', '!dev'],
argTypes: {},
};
export default meta;

type Story = StoryObj<typeof ToastProvider>;

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 (
<div>
<Button onClick={onOpenClassic}>Open classic toast</Button>
<Button onClick={onOpenRerendering}>Open re-rendering toast</Button>
</div>
);
};

export const Basic: Story = {
args: {},

render: function Render() {
return (
<ToastProvider>
<ToastRenderer />
</ToastProvider>
);
},
};
3 changes: 3 additions & 0 deletions packages/ui/src/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { ToastProvider } from './Provider';
export type { ToastProps } from './Toast';
export { useToast } from './Context';

0 comments on commit b0264f0

Please sign in to comment.