바쁘다는 핑계로 너무 오랜만에 글을 올리는 것 같네요. 😥
1일 1커밋 그거 뭐 별일인가 하는 생각으로 살고는 있지만, 그것관 별개로 770일간 쌓아 올린 탑이 무너지는 건 슬픈 일이라 일하면서도 꾸준히 contribute은 진행하고 있습니다.
그러다 보니 퇴근하고 코드 좀 치면 산책(매일 한 시간 이상은 뒷산을 걷습니다) 나갈 시간이라 블로그에 통 신경을 못 쓰고 있었네요.
라이브 데모
ua에 내장된 window.confirm
, window.alert
, window.prompt
등 클래식함을 아득히 지나쳐 낡은 디자인을 가진 창들을 간단하게 대체하는 방법입니다.
폴더 구조를 포함한 코드는 Github에서 확인하실 수 있습니다.
전역 상태 관리 도구로 zustand를 사용했는데, 보일러 플레이트가 적으니 사용하시는 스택으로 옮겨가시기 편하지 않을까 싶습니다.
export type ResponseHandler < T = unknown > = ( value : T | PromiseLike < T >) => void ;
export type DialogType = " alert " | " confirm " | " prompt " ;
export interface DialogStore < T = unknown > {
title : string ;
setTitle ( text : string ) : void ;
description : string ;
setDescription ( description : string ) : void ;
type : DialogType ;
setType ( state : DialogType ) : void ;
revealed : boolean ;
setRevealed : ( show : boolean ) => void ;
responseHandler ? : ResponseHandler < T >;
setResponseHandler ( responseHandler : ResponseHandler < T >) : void ;
}
store의 타입입니다.
자세한 설명은 아래에서 하겠습니다.
import create from " zustand " ;
import { DialogStore } from " ../@types/useDialogStore " ;
export default create < DialogStore > ( ( set ) => ( {
title : "" ,
setTitle ( title ) {
set ( ( prev ) => ( { ... prev , title } )) ;
},
description : "" ,
setDescription ( description ) {
set ( ( prev ) => ( { ... prev , description } )) ;
},
type : " alert " ,
setType ( type ) {
set ( ( prev ) => ( { ... prev , type } )) ;
},
revealed : false ,
setRevealed ( revealed ) {
set ( ( prev ) => ( { ... prev , revealed } )) ;
},
setResponseHandler ( responseHandler ) {
set ( ( prev ) => ( { ... prev , responseHandler } )) ;
},
} )) ;
WEBSITE says
가 들어갈 title
과 내용을 저장할 description
을 제일 먼저 만들었습니다.
다음으로 alert
, confirm
, prompt
세 타입 중 현재 띄운 창이 어떤 것일지를 저장할 곳도 만들었습니다.
마지막으로 창의 표시 여부 상태를 저장할 곳을 만들고, 기존에 alert
등이 동작하는 것처럼 후의 동작을 await
으로 막기 위해 Promise
를 사용할 건데, executor 중 resolve
를 responseHandler
에 담을 예정입니다.
import useDialogStore from " ../store/useDialogStore " ;
import { DialogType } from " ../@types/useDialogStore " ;
export default function useDialog () {
const {
setTitle ,
setDescription ,
setRevealed ,
setType ,
responseHandler ,
setResponseHandler ,
} = useDialogStore () ;
const onInteractionEnd = ( value : string | boolean ) => {
setRevealed ( false ) ;
responseHandler ?. ( value ) ;
setTitle ( "" ) ;
setDescription ( "" ) ;
};
const setAttributes = (
type : DialogType ,
title : string ,
description : string
) => {
setRevealed ( true ) ;
setTitle ( title ) ;
setDescription ( description ) ;
setType ( type ) ;
};
const confirm = ( title : string , description = "" ) => {
setAttributes ( " confirm " , title , description ) ;
return new Promise < boolean > ( ( res ) => {
setResponseHandler ( res ) ;
} ) ;
};
const alert = ( title : string , description = "" ) => {
setAttributes ( " alert " , title , description ) ;
return new Promise < boolean > ( ( res ) => {
setResponseHandler ( res ) ;
} ) ;
};
const prompt = ( title : string , description = "" ) => {
setAttributes ( " prompt " , title , description ) ;
return new Promise < boolean > ( ( res ) => {
setResponseHandler ( res ) ;
} ) ;
};
return {
confirm ,
alert ,
prompt ,
onInteractionEnd ,
};
}
이제 훅을 만들어주면 준비는 거의 끝났습니다.
alert
, confirm
, prompt
를 호출하면 제목 등을 설정한 뒤 Promise
를 반환하며, 창이 닫힐 때 onInteractionEnd
를 호출하도록 만들 겁니다.
import { memo , useCallback , useRef } from " react " ;
import { createPortal } from " react-dom " ;
import useDialogStore from " ../store/useDialogStore " ;
import useDialog from " ../hooks/useDialog " ;
import " ./Dialog.css " ;
export default function Dialog () {
const dialogRoot = document . getElementById ( " dialog " ) as HTMLElement ;
const inputRef = useRef < HTMLInputElement > ( null ) ;
const { revealed , title , description , type } = useDialogStore () ;
const { onInteractionEnd } = useDialog () ;
const handleConfirmClick = useCallback ( () => {
if ( type === " prompt " ) {
onInteractionEnd ( inputRef . current ?. value || "" ) ;
return ;
}
onInteractionEnd ( true ) ;
}, [ inputRef . current , type , onInteractionEnd ]) ;
const handleCancelClick = useCallback ( () => {
if ( type === " prompt " ) {
onInteractionEnd ( "" ) ;
return ;
}
onInteractionEnd ( false ) ;
}, [ type , onInteractionEnd ]) ;
const DialogComponent = memo ( () => (
<>
< div className = " dialog-backdrop " onClick = { handleCancelClick } />
< section className = " dialog " >
< h2 className = " dialog__title " > { title } </ h2 >
{ description && (
< p className = " dialog__description " > { description } </ p >
) }
{ type === " prompt " && (
< form onSubmit = { handleConfirmClick } >
< input
autoFocus
type = " text "
className = " dialog__input "
ref = { inputRef }
/>
</ form >
) }
< div className = " dialog__buttons " >
< button
type = " button "
className = " dialog__button dialog__button--confirm "
onClick = { handleConfirmClick }
>
OK
</ button >
{ type !== " alert " && (
< button
type = " button "
className = " dialog__button dialog__button--cancel "
onClick = { handleCancelClick }
>
Cancel
</ button >
) }
</ div >
</ section >
</>
)) ;
return createPortal ( revealed ? < DialogComponent /> : null , dialogRoot ) ;
}
type이 alert일 땐 확인만 뜨게 하는 등 각 상황에 맞게 마크업을 짜줬습니다.
굳이 상태를 만들 필요는 없을 것 같아 input
은 useRef
로 관리하여, 창이 닫힐 때 그 값을 꺼내오도록 하였습니다.
< div id = " dialog " ></ div >
포탈을 열었으니 #dialog
를 추가해주는 것도 잊으면 안 됩니다.
. dialog-backdrop {
position : fixed ;
top : 0 ;
right : 0 ;
bottom : 0 ;
left : 0 ;
background : rgba ( 0 , 0 , 0 , 0.5 );
}
. dialog {
--dialog-primary : # e57373 ;
position : fixed ;
top : 50 % ;
left : 50 % ;
width : clamp ( 0 px , 90 vw , 400 px );
padding : 16 px ;
background-color : # fff ;
transform : translate3d ( -50 % , -50 % , 0 );
box-sizing : border-box ;
border-radius : 8 px ;
line-height : 1.5 ;
}
. dialog__title {
margin : 0 0 0.5 rem 0 ;
font-size : 1.5 rem ;
font-weight : bold ;
line-height : 1.5 ;
}
. dialog__description {
margin : 0.5 rem 0 ;
line-height : 1.3 ;
}
. dialog__input {
display : block ;
width : 100 % ;
margin : 0.5 rem 0 ;
padding : 0.4 rem 0.8 rem ;
border : 2 px solid # 121212 ;
border-radius : 0.3 rem ;
outline : 0 ;
font-size : 1.1 rem ;
box-sizing : border-box ;
}
. dialog__input : focus {
border-color : var ( --dialog-primary );
}
. dialog__buttons {
display : flex ;
justify-content : flex-end ;
gap : 10 px ;
}
. dialog__button {
padding : 5 px 10 px ;
border-radius : 4 px ;
cursor : pointer ;
}
. dialog__button--confirm {
border : 1 px solid var ( --dialog-primary );
background-color : var ( --dialog-primary );
}
. dialog__button--cancel {
border : 1 px solid # e1e1e1 ;
background-color : # fff ;
color : var ( --dialog-primary );
}
@media ( prefers-color-scheme : dark ) {
. dialog {
--dialog-primary : # ffcdd2 ;
background-color : # 121212 ;
color : # f1f1f1 ;
}
. dialog__input {
background-color : # 121212 ;
color : # f1f1f1 ;
border-color : # f1f1f1 ;
}
. dialog__button--confirm {
color : # 121212 ;
}
. dialog__button--cancel {
border : 1 px solid # 4f4f4f ;
background-color : # 121212 ;
color : var ( --dialog-primary );
}
}
너무 밋밋하지만 않게 디자인해뒀습니다.
마크업에서도 확인할 수 있지만, 조금 더 창에 집중할 수 있도록 배경을 어둡게 만드는 작업도 진행했습니다.
prefers-color-scheme
은 여기서만 작업해두면 창이 뜨는 페이지와 어울리지 않을 수 있어 작업하는 게 맞을지 고민을 잠깐 했는데, 잠깐 고민해보니 ua가 띄우는 창도 그냥 사용자가 선호하는 색상에 따라 배경색이 결정되니 큰 상관이 없지 않을까 싶어 작업해뒀습니다.
import { useCallback , useState } from " react " ;
import useDialog from " ./hooks/useDialog " ;
export default function App () {
const { confirm , alert , prompt } = useDialog () ;
const [ message , setMessage ] = useState ( "" ) ;
const showConfirm = useCallback ( async () => {
const confirmed = await confirm (
" Are you sure? " ,
" This can't be undone. "
) ;
setMessage ( confirmed ? " Sure! " : " Nope. " ) ;
}, []) ;
const showAlert = useCallback ( async () => {
await alert ( " Hello there! " ) ;
setMessage ( " Will update after alert " ) ;
}, []) ;
const showPrompt = useCallback ( async () => {
const inputted = await prompt ( " What's your name? " ) ;
setMessage ( ` Your name is ${ inputted } ` ) ;
}, []) ;
return (
< div style = { { textAlign : " center " } } >
< button type = " button " onClick = { showConfirm } >
Confirm
</ button >
< button type = " button " onClick = { showAlert } >
Alert
</ button >
< button type = " button " onClick = { showPrompt } >
Prompt
</ button >
< h1 > { message } </ h1 >
</ div >
) ;
}
이제 끝입니다!
비록 매번 async
함수를 만들고 await
을 매번 걸어줘야 한다는 게 안타깝긴 하지만, 디자인적으로 훨씬 자유롭게 컴포넌트들을 활용할 수 있으니 충분히 그만한 가치가 있지 않나 싶습니다.
ⓒ 2022. Marshall K All rights reserved