Skip to content

Commit

Permalink
feat(common): toast (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyechan99 committed Feb 13, 2024
1 parent e4f9973 commit bace2a8
Show file tree
Hide file tree
Showing 12 changed files with 459 additions and 4 deletions.
1 change: 1 addition & 0 deletions .storybook/preview-body.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="bmates-toaster"></div>
6 changes: 3 additions & 3 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@300;400;500;700;900&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@300;400;500;700;900&display=swap" rel="stylesheet" />
118 changes: 118 additions & 0 deletions src/components/common/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Theme, css } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';

import { DefaultVariantType } from '@/types/variant';

import { useToast } from '.';
import { ToastData } from './type';

interface ToastProps extends React.ComponentPropsWithoutRef<'li'> {
toast: ToastData;
}

export const Toast = React.forwardRef<HTMLLIElement, ToastProps>(({ toast, ...props }, ref) => {
const { toastId, variant = 'default', title, description, time = -1, action, data } = toast;
const [active, setActive] = React.useState<boolean>(false);
const { removeToast } = useToast();

React.useEffect(() => {
setActive(true);

if (time >= 0) {
const timer = setTimeout(() => {
close();
}, time);

return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toastId, time]);

const close = () => {
setActive(false);

setTimeout(() => {
removeToast(toastId);
}, 500);
};

return (
<ToastStyled
ref={ref}
variant={variant}
className={active ? 'active' : ''}
onClick={() => {
if (action) action(data);
close();
}}
data-toastData={data}
{...props}
>
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</ToastStyled>
);
});
Toast.displayName = 'Toast';

const ToastVariantStyles = ({ theme, variant }: { theme: Theme; variant: DefaultVariantType }) => {
switch (variant) {
case 'secondary':
return css`
background-color: ${theme.colors.secondary};
`;
case 'danger':
return css`
color: ${theme.colors.white};
background-color: ${theme.colors.danger};
`;
case 'warning':
return css`
color: ${theme.colors.white};
background-color: ${theme.colors.warning};
`;
case 'primary':
return css`
color: white;
background-color: ${theme.colors.primary};
`;
default:
return css`
color: ${theme.colors.black};
background-color: ${theme.colors.background};
`;
}
};

const ToastStyled = styled.li<{ variant?: DefaultVariantType }>`
padding: 1rem;
background-color: ${({ theme }) => theme.colors.white};
border-radius: 0.5rem;
border: 1px solid ${({ theme }) => theme.colors.gray['200']};
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 0 0px 3px ${({ theme }) => theme.colors.gray['400']};
transform: translateX(calc(100% + 2rem));
transition: all 0.25s cubic-bezier(0.75, -0.5, 0.25, 1.25);
cursor: pointer;
&.active {
transform: translateX(0);
}
${({ theme, variant }) => variant && ToastVariantStyles({ theme, variant })}
`;

export const ToastTitle = styled.div`
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
`;
export const ToastDescription = styled.div`
font-size: 0.75rem;
line-height: 1rem;
font-weight: 400;
`;
50 changes: 50 additions & 0 deletions src/components/common/Toast/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';

import { Action, State, TOAST_LIMIT, ToastDispatch } from './type';

export const ToastStateContext = React.createContext<State | null>(null);
export const ToastDispatchContext = React.createContext<ToastDispatch | null>(null);

export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [...state.toasts, action.toast].slice(0, TOAST_LIMIT),
};

case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map(t => (t.toastId === action.toast.toastId ? { ...t, ...action.toast } : t)),
};

case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter(t => t.toastId !== action.toastId),
};
}
};

/**
* Toast Context Provider
* @returns
*/
export const ToasterProvider = ({ children }: React.PropsWithChildren) => {
const [state, dispatch] = React.useReducer(reducer, {
toasts: [],
});

return (
<ToastStateContext.Provider value={state}>
<ToastDispatchContext.Provider value={dispatch}>{children}</ToastDispatchContext.Provider>
</ToastStateContext.Provider>
);
};
44 changes: 44 additions & 0 deletions src/components/common/Toast/Toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import { createPortal } from 'react-dom';

import { Toast } from './Toast';
import { ToasterProvider } from './ToastContext';
import { useToast } from './useToast';

/**
* Toast Context Provider
* @returns
*/
export const Toaster = () => {
const { toasts } = useToast();

return (
<ToasterProvider>
{toasts.length > 0 &&
createPortal(
<ToastList tabIndex={-1}>
{toasts.map(t => (
<Toast key={t.toastId} toast={t} />
))}
</ToastList>,
document.getElementById('toaster') || document.body,
)}
</ToasterProvider>
);
};
const ToastList = styled.ol`
list-style: none;
position: fixed;
display: flex;
margin: 0;
z-index: 100;
max-height: 100vh;
width: 100%;
padding: 1rem;
top: auto;
right: 0px;
bottom: 0px;
flex-direction: column;
max-width: 25rem;
gap: 1rem;
`;
5 changes: 5 additions & 0 deletions src/components/common/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { Toaster } from './Toaster';
export { Toast, ToastDescription, ToastTitle } from './Toast';

// eslint-disable-next-line react-refresh/only-export-components
export { useToast } from './useToast';
55 changes: 55 additions & 0 deletions src/components/common/Toast/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { DefaultVariantType } from '@/types/variant';

import { Toast } from '.';

export type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;

export const TOAST_LIMIT = 5;

// export Type

export type ToastData = {
/*
Toast unique number
*/
toastId: number;
/*
Toast Title
*/
title?: React.ReactNode;
/*
Toast Description
*/
description?: React.ReactNode;
/*
Toast Variant
*/
variant?: DefaultVariantType;
/*
Toast duration time.
if time < 0: primitive
*/
time?: number;

/*
Toast Custom Data.
It just for action callback
*/
data?: unknown;
/*
Toast Click Action.
Called when Toast is clicked.
*/
action?: (data: unknown) => void;
};

export interface State {
toasts: ToastData[];
}

export type Action =
| { type: 'ADD_TOAST'; toast: ToastData }
| { type: 'UPDATE_TOAST'; toast: ToastData }
| { type: 'REMOVE_TOAST'; toastId: number };

export type ToastDispatch = React.Dispatch<Action>;
58 changes: 58 additions & 0 deletions src/components/common/Toast/useToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';

import { reducer } from './ToastContext';
import { Action, State, ToastData } from './type';

type ToastProps = Omit<ToastData, 'toastId'>;

const listeners: Array<(state: State) => void> = [];

let memoryState: State = { toasts: [] };

function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach(listener => {
listener(memoryState);
});
}

let cnt = 1;
const toast = ({ ...props }: ToastProps) => {
const toastId = cnt++;
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
toastId,
},
});

return {
toastId,
};
};

const removeToast = (toastId: number) => {
dispatch({ type: 'REMOVE_TOAST', toastId });
};

export const useToast = () => {
const [state, setState] = useState<State>(memoryState);

useEffect(() => {
listeners.push(setState);

return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);

return {
...state,
toast,
removeToast,
};
};
1 change: 1 addition & 0 deletions src/components/common/Tooltip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const TooltipTrigger = styled.div<{ direction?: Direction }>`
background-color: ${({ theme }) => theme.colors.black};
animation: ${tooltip} 0.5s ease;
z-index: 200;
white-space: nowrap;
${props => {
if (props.direction === 'top' || props.direction === 'bottom') {
Expand Down
3 changes: 2 additions & 1 deletion src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export * from './Dropdown';
export * from './Form';
export * from './Input';
export * from './Label';
export * from './Switch';
export * from './Textarea';
export * from './Toast';
export * from './Tooltip';
export * from './Checkbox';
export * from './Switch';
Loading

0 comments on commit bace2a8

Please sign in to comment.