ClickAway Component?
Dropdown 이나 Popover 같은 컴포넌트를 만들면, 그 컴포넌트 이외의 영역을 눌렀을 때에도 열려져 있는 상태를 닫게 하고 싶은 경우가 있다.
그것을 위한 구현방법에 대해 이전 프로젝트에서 다른 사람들이 구현한 것을 구경해보았을 때 window 에 click listener 를 달아서 해결한다던지, open 상태에 따라 전체 영역을 감싸는 div 를 만들어 사용하는 방법이라던 지 (이전 프로젝트에서 썼던 방법) 등의 방법이 있었다.
ClickAway 라고 멋대로 이름을 짓긴 했는데, 바깥 영역을 눌렀을 때 어떤 동작을 하고싶음 이라는 것은 다양한 곳 (Dropdown, Popover, Modal 등) 에서 쓰일 수 있기 때문에 headless 컨셉의 공통 컴포넌트로 분리하여 사용하자고 이야기했다.
컨셉은 이전에 만들었던 Dropdown 에서 사용되고 있는 로직을 분리하는 것 이었는데 고민 사항은 아래와 같았다.
1. Dropdown Trigger 를 누르면 Trigger element 의 height 를 계산하여 List 의 layout 를 잡는데, Trigger 의 height 관련한 상태는 어디에 둘 것인가?
2. Open 과 관련된 상태를 ClickAway 에 포함시키는게 맞는가?
1 번은 Dropdown Trigger 의 위치에 따른 Dropdown List layout positioning 이므로 Dropdown 에서 다루는 것이 맞다고 판단했다.
2 번은 @mui/base 에 포함되어있는 ClickAwayListener 를 참고하니 onClickAway 라는 prop 으로 핸들러를 전달받아 사용시킨다.
즉 open 과 관련한 상태는 포함되어있지 않은 것인데 그럼 onClickAway 는 ClickAwayListener 로 감싸져 있는 컴포넌트가 사용되는 곳에서는 onClickAway 가 항상 발생하는 것인지 궁금해서 테스트해보았다. 결론은 그렇게 동작했다.
원하지 않는 Side Effect 라고 생각해서, 내가 만드는 컴포넌트에서는 Open 상태일 때만 ClickAway 를 감지하도록 하고 싶었다.
그래서 ClickAway 의 Open 상태를 두는 것이 맞다고 판단했다.
일단 같이 프로젝트를 진행하는 분들에게 리뷰는 받지 않은 상태이지만, ClickAway 의 코드는 아래와 같다.
import {
createContext,
Dispatch,
ReactNode,
useContext,
useReducer,
} from 'react';
import { css } from 'styled-components';
type ClickAwayState = {
isOpen: boolean;
};
type ClickAwayAction = { type: 'TOGGLE_OPEN_AWAY' };
const reducer = (state: ClickAwayState, action: ClickAwayAction) => {
switch (action.type) {
case 'TOGGLE_OPEN_AWAY':
return { isOpen: !state.isOpen };
default:
return state;
}
};
const initState = {
isOpen: false,
};
const ClickAwayContext = createContext<
[ClickAwayState, Dispatch<ClickAwayAction>]
>([initState, () => {}]);
export const useClickAwayContext = () => {
const context = useContext(ClickAwayContext);
return context;
};
const ClickAway: React.FC<{ children: ReactNode }> = ({ children }) => {
const clickAwayReducer = useReducer(reducer, initState);
const [state, dispatch] = clickAwayReducer;
return (
<ClickAwayContext.Provider value={clickAwayReducer}>
<div
css={css`
&::before {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
z-index: 90;
display: ${state.isOpen ? 'block' : 'none'};
}
`}
onClick={() => {
dispatch({ type: 'TOGGLE_OPEN_AWAY' });
}}
>
{children}
</div>
</ClickAwayContext.Provider>
);
};
export default ClickAway;
코드의 변경사항은 PR 에서 확인할 수 있다.