-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[NDD-312] Toast 개발 완료 (10h / 8h) #171
Changes from all commits
0540e8c
25e58d2
47f9fa0
3edd41d
167a0cb
0713add
cc83052
d6c2f4a
cb0330b
a47941e
85e5a08
f5f55f3
32b7734
a85d9cf
98ba903
020f773
c37e73d
3ada210
5a45371
cfc0ed6
9853786
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { css, keyframes } from '@emotion/react'; | ||
import { theme } from '@styles/theme'; | ||
|
||
const TOAST_GAP = '0.75rem'; | ||
export const ToastProgressBarStyle = { | ||
info: css` | ||
background-color: ${theme.colors.toast.info}; | ||
`, | ||
success: css` | ||
background-color: ${theme.colors.toast.success}; | ||
`, | ||
warning: css` | ||
background-color: ${theme.colors.toast.warning}; | ||
`, | ||
error: css` | ||
background-color: ${theme.colors.toast.error}; | ||
`, | ||
default: css` | ||
background-color: ${theme.colors.toast.default}; | ||
`, | ||
}; | ||
|
||
export const ToastTypeIconName = { | ||
info: 'info-round', | ||
success: 'check-round', | ||
warning: 'warning', | ||
error: 'bomb', | ||
default: null, | ||
}; | ||
|
||
export const ToastPositionStyle = { | ||
topLeft: css` | ||
top: ${TOAST_GAP}; | ||
left: ${TOAST_GAP}; | ||
`, | ||
topRight: css` | ||
top: ${TOAST_GAP}; | ||
right: ${TOAST_GAP}; | ||
`, | ||
topCenter: css` | ||
top: ${TOAST_GAP}; | ||
left: 50%; | ||
transform: translateX(-50%); | ||
z-index: 10000; | ||
`, | ||
bottomLeft: css` | ||
bottom: ${TOAST_GAP}; | ||
left: ${TOAST_GAP}; | ||
`, | ||
bottomRight: css` | ||
bottom: ${TOAST_GAP}; | ||
right: ${TOAST_GAP}; | ||
`, | ||
bottomCenter: css` | ||
bottom: ${TOAST_GAP}; | ||
left: 50%; | ||
transform: translateX(-50%); | ||
z-index: 10000; | ||
`, | ||
}; | ||
|
||
export const ToastFadeOutUpAnimation = keyframes` | ||
from { | ||
opacity: 1; | ||
transform: translateY(0); | ||
} | ||
to { | ||
opacity: 0; | ||
transform: translateY(-1.5rem); | ||
} | ||
`; | ||
|
||
export const ToastProgressBarAnimation = keyframes` | ||
from { | ||
transform: scaleX(1); | ||
} | ||
to { | ||
transform: scaleX(0); | ||
} | ||
`; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { css } from '@emotion/react'; | ||
import { ToastPositionStyle } from '@foundation/Toast/Toast.styles'; | ||
import useToastContainer from '@foundation/Toast/useToastContainer'; | ||
import ToastItem from '@foundation/Toast/ToastItem'; | ||
|
||
export const ToastContainer = () => { | ||
const { getToastPositionGroupToRender } = useToastContainer(); | ||
const positionGroup = getToastPositionGroupToRender(); | ||
|
||
return Array.from(positionGroup).map(([position, toasts]) => ( | ||
<div | ||
key={position} | ||
css={[ | ||
css` | ||
position: fixed; | ||
display: flex; | ||
flex-direction: column; | ||
row-gap: 0.5rem; | ||
z-index: 9999; | ||
`, | ||
ToastPositionStyle[position], | ||
]} | ||
> | ||
{toasts.map((toastProps) => ( | ||
<ToastItem key={toastProps.toastId} {...toastProps} /> | ||
))} | ||
</div> | ||
)); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import { useRef, useState } from 'react'; | ||
import { ToastEvent, ToastProps } from '@foundation/Toast/type'; | ||
import { css } from '@emotion/react'; | ||
import { theme } from '@styles/theme'; | ||
import { Box, Icon } from '@foundation/index'; | ||
import { collapseToast } from '@foundation/Toast/collapseToast'; | ||
import { eventManager } from '@foundation/Toast/eventManger'; | ||
import { | ||
ToastFadeOutUpAnimation, | ||
ToastProgressBarAnimation, | ||
ToastProgressBarStyle, | ||
ToastTypeIconName, | ||
} from '@foundation/Toast/Toast.styles'; | ||
|
||
const ToastItem: React.FC<ToastProps> = ({ | ||
toastId, | ||
text, | ||
autoClose = 3000, | ||
closeOnClick = true, | ||
type = 'default', | ||
pauseOnHover = true, | ||
}) => { | ||
const toastRef = useRef<HTMLDivElement>(null); | ||
const [isExiting, setIsExiting] = useState(false); | ||
const [isPaused, setIsPaused] = useState(false); | ||
|
||
const handleExitingAnimationEnd = () => { | ||
collapseToast(toastRef.current!, () => { | ||
eventManager.emit(ToastEvent.Delete, toastId); | ||
}); | ||
}; | ||
|
||
const handleProgressAnimationEnd = () => { | ||
autoClose && setIsExiting(true); | ||
}; | ||
|
||
const handleClick = () => { | ||
closeOnClick && handleProgressAnimationEnd(); | ||
}; | ||
|
||
const handleMouseEnter = () => { | ||
pauseOnHover && autoClose && setIsPaused(true); | ||
}; | ||
|
||
const handleMouseLeave = () => { | ||
if (pauseOnHover && autoClose) { | ||
setIsPaused(false); | ||
} | ||
}; | ||
|
||
const IconType = () => | ||
type === 'default' ? null : ( | ||
<Icon id={ToastTypeIconName[type]} width="20" height="20" /> | ||
); | ||
|
||
return ( | ||
<div ref={toastRef}> | ||
<Box | ||
onClick={handleClick} | ||
onMouseEnter={handleMouseEnter} | ||
onMouseLeave={handleMouseLeave} | ||
onAnimationEnd={handleExitingAnimationEnd} | ||
css={[ | ||
css` | ||
position: relative; | ||
display: flex; | ||
flex-direction: column; | ||
row-gap: 0.5rem; | ||
min-width: 20rem; | ||
border-radius: 0.5rem; | ||
overflow: hidden; | ||
background-color: ${theme.colors.surface.default}; | ||
animation: ${isExiting | ||
? css` | ||
${ToastFadeOutUpAnimation} 0.8s forwards | ||
` | ||
: 'none'}; | ||
`, | ||
]} | ||
> | ||
<div | ||
css={css` | ||
display: flex; | ||
align-items: center; | ||
column-gap: 1rem; | ||
padding: 1rem; | ||
`} | ||
> | ||
<IconType /> | ||
{text} | ||
</div> | ||
<div | ||
onAnimationEnd={handleProgressAnimationEnd} | ||
css={[ | ||
css` | ||
position: absolute; | ||
bottom: 0; | ||
height: 0.3125rem; | ||
width: 100%; | ||
transform-origin: left; | ||
animation: ${autoClose | ||
? css` | ||
${ToastProgressBarAnimation} ${autoClose}ms linear | ||
forwards | ||
` | ||
: 'none'}; | ||
animation-play-state: ${isPaused ? 'paused' : 'running'}; | ||
`, | ||
ToastProgressBarStyle[type], | ||
]} | ||
/> | ||
</Box> | ||
</div> | ||
); | ||
}; | ||
|
||
export default ToastItem; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
export function collapseToast( | ||
node: HTMLDivElement, | ||
done: () => void, | ||
duration = 1000 | ||
) { | ||
const { scrollHeight, style } = node; | ||
|
||
requestAnimationFrame(() => { | ||
style.height = scrollHeight + 'px'; | ||
style.transition = `all ${duration}ms`; | ||
|
||
requestAnimationFrame(() => { | ||
style.height = '0'; | ||
setTimeout(done, duration); | ||
}); | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { EventManager } from '@foundation/Toast/type'; | ||
|
||
export const eventManager: EventManager = { | ||
list: new Map(), // 이벤트 리스너 저장 | ||
emitQueue: new Map(), // 이벤트 지연을 위한 큐 | ||
|
||
// 새로운 이벤트 리스너 등록 | ||
on(event, callback) { | ||
this.list.has(event) | ||
? this.list.get(event)!.push(callback) | ||
: this.list.set(event, [callback]); | ||
|
||
return this; | ||
}, | ||
|
||
// 콜백이 있는경우 해당하는 리스너 제거, 없는 경우 이벤트에 대한 모든 리스너 제거 | ||
off(event, callback) { | ||
if (callback) { | ||
const cb = this.list.get(event)?.filter((cb) => cb !== callback); | ||
cb && this.list.set(event, cb); | ||
return this; | ||
} | ||
this.list.delete(event); | ||
|
||
return this; | ||
}, | ||
|
||
//대기중인 이벤트를 취소시킬 때 필요함 | ||
cancelEmit(event) { | ||
const timers = this.emitQueue.get(event); | ||
if (timers) { | ||
timers.forEach(clearTimeout); | ||
this.emitQueue.delete(event); | ||
} | ||
|
||
return this; | ||
}, | ||
|
||
// 이벤트 발생시키기 | ||
// 타입문제는 일단 보류중! (라이브러리 내부도 해결 안되어있음) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [p-5] 타입의 세계란 너무 어려운것입니다... |
||
emit(event, ...args: never[]) { | ||
this.list.has(event) && | ||
this.list.get(event)!.forEach((callback) => { | ||
const timer = setTimeout(() => { | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-expect-error | ||
return callback(...args); | ||
}, 0); | ||
|
||
this.emitQueue.has(event) | ||
? this.emitQueue.get(event)!.push(timer) | ||
: this.emitQueue.set(event, [timer]); | ||
}); | ||
}, | ||
}; | ||
Comment on lines
+41
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [p-5] 블로그글을 읽으면서 보고 있는데 "콜백함수가 동기함수가 아닐 경우를 대비" 이게 어떤 경우인지 모르겠습니다. 비동기 함수가 콜백으로 들어왔을때 해당 주기를 밀어내지 않으면 어떤 사이드 이펙트가 있는건가요? |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import { ToastEvent, ToastProps, ToastType } from '@foundation/Toast/type'; | ||
import { eventManager } from '@foundation/Toast/eventManger'; | ||
|
||
type ToastFunctionProps = Omit<ToastProps, 'toastId' | 'type'>; | ||
type ToastOptions = Omit<ToastProps, 'toastId' | 'type' | 'text'>; | ||
const generateUniqueId = () => { | ||
return Date.now().toString(36) + Math.random().toString(36).substring(4); | ||
}; | ||
|
||
const emitToast = (type: ToastType, toastProps: ToastFunctionProps) => { | ||
const id = generateUniqueId(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [p-5] useId라는 hook은 어떤가요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 사용하고싶었지만... 이곳이 훅이 아니라서 안됩니다ㅜㅜ |
||
eventManager.emit(ToastEvent.Add, { | ||
...toastProps, | ||
toastId: id, | ||
type, | ||
}); | ||
}; | ||
|
||
export const toast = { | ||
default: (text: string, toastOptions?: ToastOptions) => | ||
emitToast('default', { text: text, ...toastOptions }), | ||
info: (text: string, toastOptions?: ToastOptions) => | ||
emitToast('info', { text: text, ...toastOptions }), | ||
success: (text: string, toastOptions?: ToastOptions) => | ||
emitToast('success', { text: text, ...toastOptions }), | ||
warning: (text: string, toastOptions?: ToastOptions) => | ||
emitToast('warning', { text: text, ...toastOptions }), | ||
error: (text: string, toastOptions?: ToastOptions) => | ||
emitToast('error', { text: text, ...toastOptions }), | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[p-5] 사용자 지정의 eventManager를 새로 만드신거군요?!