π μ€λ΅λ°(Snackbar) μ»΄ν¬λνΈλ?
μ€λ΅λ°(Snackbar)λ μ¬μ©μκ° μνν μμ μ λν κ°λ¨ν νΌλλ°± λ©μμ§λ₯Ό μΌμμ μΌλ‘ νλ©΄μ νμνλ UI μ»΄ν¬λνΈμ λλ€. μΌλ°μ μΌλ‘ νλ©΄ νλ¨(λλ μλ¨)μ μ κΉ λνλ¬λ€ μ¬λΌμ§λ©°, μ¬μ©μμ νλ¦μ ν¬κ² λ°©ν΄νμ§ μκ³ μ 보λ₯Ό μ λ¬νλ λ° μ¬μ©λ©λλ€.
π― μ£Όμ κΈ°λ₯
- μ§§μ λ©μμ§: κ°κ²°ν ν μ€νΈ (μ: "μ μ₯λμμ΅λλ€", "μ€λ₯κ° λ°μνμ΅λλ€").
- μΌμμ μΌλ‘ νμλ¨: μΌμ μκ°(μ: 3μ΄) ν μλμΌλ‘ μ¬λΌμ§.
- λΉμ°¨λ¨μ : μ¬μ©μμ μΈν°λμ μ λ§μ§ μμ.
- μ νμ μ‘μ λ²νΌ: "λλ리기" κ°μ κ°λ¨ν λμ λ²νΌ ν¬ν¨ κ°λ₯.
βοΈκ΅¬ν λ΄μ©
Providerλ₯Ό νμ©ν μ€λ΅λ° λ λλ§
const SnackbarContext = createContext<SnackbarState>([])
const SnackbarSetContext = createContext<Dispatch<{
type: SnackbarActionType
payload: Record<string, any>
}>>(() => {})
const SnackbarReducerMap: Record<SnackbarActionType, (state: SnackbarActionType, payload: any)> => {
upsert: (state, payload: Partial<Snackbar>) => {
const targetIndex = state.findIndex(item => item.id === payload.id)
// νκ²μ΄ μ΄λ―Έ μλ μνλ©΄ μν κ°±μ
if (targetIndex > -1) {
return state.map((item, index) => {
if (i === targetIndex) {
return {
...state[targetIndex],
...payload
}
}
})
return item
}
return [...state, {...DefaultSnackbar, ...paylaod}]
}
remove: (state, {id}: {id: string}) => {
const targetIndex = state.findIndex(item => item.id === id)
return state.filter((item, index) => index !== targetIndex)
}
}
export const useSetSnackbar = () => {
const dispatch = useContext(SnackbarSetContext)
const createSnackbar = useCallback((id: string, children: ReactNode) => {
const newItem : Snackbar = {
id,
children,
isOpen: true,
timeoutId: window.setTimeout(() => {
dispatch({type: 'upsert', payload: {id, isOpen:false, timeoutId: null}})
}, SNACKBAR_DURATION),
}
newItem.onMouseEnter = () => {
if (newItem.timeoutId) clearTimeout(newItem.timeoutId)
}
newItem.onMouseLeave = () => {
newItem.timeoutId = window.setTimeout(() => {
dispatch({type: 'upsert', payload: {id, isOpen: false, timeoutId: null}})
}, SNACKBAR_DURATION)
}
dispatch({type: 'upsert', payload: newItem})
}, []);
const removeSnackbar = useCallback((id: string) => {
dispatch({type: 'remove', payload: {id}})
}, []);
return {
createSnackbar,
removeSnackbar,
}
}
- 컨ν
μ€νΈμ λ λλ§ μ΅μ νλ₯Ό μν΄
state
λ₯Ό κ°μ§λ 컨ν μ€νΈμdispatch
λ₯Ό λΆλ¦¬ - μ€λ΅λ°λ₯Ό μμ± ν λμλ,
id
μchildren
μ λ°μμisOpen
μtrue
λ‘ κ΄λ¦¬ timeoutId
λsetTimeout
μ id κ°setTimeout
μ μΌμ μκ° νμ ν΄λΉ μμ΄ν μ μμ΄λλ‘isOpen
μfalse
λ‘ μ λ°μ΄νΈ νλ ν¨μ μμ±
onMouseEnter
λnewItem
μtimeOutId
λ₯Ό μ§μonMouseLeave
λnewItem
μsetTimeOut
μ μλ‘ λ±λ‘
Providerλ₯Ό νμ©ν μ€λ΅λ° λ λλ§
const useSnackbar = (children: ReactNode) => {
const timeoutId = useRef<number | null>(null)
const [status, setStatus] = useState<SnackbarStatus>(null)
const openSnackbar = useCallback(() => {
setStatus('open')
timeoutId.current = window.setTimeout(() => setStatus('close'), SNACKBAR_DURATION)
}, [])
const handleMouseEnter = () => {
if (timeoutId.current) clearTimeout(timeoutId.current)
}
const handleMouseLeave = () => {
timeoutId.current = window.setTimeout(() => {
setStatus('close')
}, SNACKBAR_DURATION)
}
return {
snackbar: !!status
? createPortal(
<SnackbarItem
status={status}
setStatus={setStatus}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</SnackbarItem>,
document.querySelector('#snackbarRoot')!,
)
: null,
open: openSnackbar,
}
}
- κΈ°μ‘΄ νλ‘λ°μ΄λ보λ€λ 보μΌλ¬ νλ μ΄νΈκ° λΉκ΅μ λ¨μνλ€λ μ₯μ μ΄ μμ
timeoutID
,status
,handleMouse...
λ± μνλ₯Ό μ 체μ μΌλ‘ κ΄λ¦¬ν νμ μμ΄ μ€λ΅λ° μ»΄ν¬λνΈ λ΄λΆμμ κ΄λ¦¬- νμ§λ§, κ°κ°μ μ»΄ν¬λνΈμμ κ΄λ¦¬νκΈ° λλ¬Έμ μ 체μ μΌλ‘ μνλ₯Ό μ‘°μ ν΄μΌν λλ (ex: μ€λ΅λ°μ μ΅λ κ°―μ μ‘°μ ) μ‘°κΈ λ 볡μ‘ν λ‘μ§μ΄ νμν μ μμ
μ€λ΅λ°μ μνκ΄λ¦¬ νλ‘μΈμ€
export type Snackbar = {
id: string
children: ReactNode
isOpen: boolean
timeoutId: number | null
onMouseEnter?: EventHandler<any>
onMouseLeave?: EventHandler<any>
}
const SnackbarItems = ({
id, children, isOpen, onMouseLeave, onMouseEnter
}: Snackbar) => {
const { createSnackbar, removeSnackbar } = useSetSnackbar()
const elemRef = useRef<HTMLDivElement>(null);
const [animationClassName, setAnimationClassName] = useState<string[]>()
// enter => show => show exit => μμ
const handleAnimationEnd = () => {
if (elemRef.current?.className.includes('enter')) {
setAnimationClassName(['show'])
}
else {
removeSnackbar(id)
}
}
useEffect(() => {
setAnimationClassName(isOpen ? ['enter']: ['show', 'exit'])
}, [isOpen]);
return (
<div ref={elemRef} className={cx('SnackbarItem', animationClassName)} id={id}
onAnimationEnd={handleAnimationEnd}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
<button onClick={() => removeSnackbar(id)}>λ«κΈ°</button>
</div>
)
}
isOpen
κ°μ κΈ°λ°μΌλ‘ μ²μ μ€λ΅λ°κ° μ΄λ¦΄ λ,enter
, 첫 μ λλ©μ΄μ μ΄ λλ λ,enter
,show
, μ λλ©μ΄μ μ΄ λλ λ,show
,exit
λ‘ λ³κ²½- μ€λ΅λ°κ° μΆκ°λ λ, 보μ¬μ§κ³ μμ λ, μΈκ°μ§μ μνλ₯Ό λλμ΄ μ λλ©μ΄μ
κ΄λ¦¬
- μ€λ΅λ°κ° μ²μ λ€μ΄μ¬ λλ class μ enter λ₯Ό μΆκ°, isOpen μ΄ false κ° λμ λ, exitλ₯Ό μΆκ°νκ³ , exit κ° μ¬λΌ μ‘μ λ, μ€λ΅λ° μ κ±°
// animation
&.enter {
animation: enter 500ms ease-out forwards;
}
&.show {
transform: translateY(0);
opacity: 1;
}
&.exit {
animation: exit 500ms ease-out forwards;
}
@keyframes enter {
0% {
transform: translate(0, 50px);
opacity: 0;
}
100% {
transform: translate(0, 0);
opacity: 1;
}
}
@keyframes exit {
0% {
transform: translate(0, 0);
opacity: 1;
}
100% {
transform: translate(0, 50px);
opacity: 0;
}
}
'κ°λ°' μΉ΄ν κ³ λ¦¬μ λ€λ₯Έ κΈ
[UI] ImageSlider [UIμμ λ§λ€κΈ°] (0) | 2025.04.27 |
---|---|
[UI] Popover[UIμμ λ§λ€κΈ°] (0) | 2025.04.20 |
[UI] Scroll Spy [UI μμ λ§λ€κΈ°] (0) | 2025.03.30 |
[UI] Scroll Box (0) | 2025.03.28 |
[UI] 무ν μ€ν¬λ‘€(feat: UI μμ μ§μ λ§λ€κΈ°) (0) | 2025.03.03 |