드롭다운을 구현할 일이 있어서 정리 후 남겨본다. 드롭다운은 웹 프론트를 하다보면 꽤나 빈번히 만들 일이 생기는데, 그때그때 다른 방식으로 하다보니 나 역시도 전문성이 생긴다기보다는 그냥 그때그때의 능력껏 처리했던 것 같다. 이 기회에 정리해두고 참고하고자 한다.

 

드롭다운 구현 원리

특정 div 요소에 mouseover 이벤트가 발생하면 show 클래스리스트를 추가하여 css에서 드롭다운 메뉴를 보이도록 변경하고, mouseout 이벤트가 발생하면 show 클래스리스트를 제거하여 css에서 드롭다운 메뉴를 사라지게 하는 방식으로 구현하였다.

mouseover, mouseout이 아니라 클릭으로 구현하고 싶다면 addEventListener() 메서드의 첫 번째 argument로 click 이벤트를 활용하자.

 

javascript에서 html요소 정의하기

웹페이지에서 헤더 부분은 페이지가 변경되더라도 계속해서 쓰여야 하기 때문에, 별도의 자바스크립트에서 구현해두고 여러 페이지에서 돌려 쓰고 있던 상황이었다. 헤더메뉴 가장 우측에는 유저 id가 나오고, 유저 id에 마우스를 올리면 드롭다운 메뉴가 내려와서 POLICY, LICENSE, LOGOUT 버튼이 보여지는 드롭다운을 구현했다.

const body = document.body;
const header = $el('header', {}, [

    // 앞부분 생략
    
    $el('div.header-menu', { id: "user-id-menu" }, [
        $el('span.header-text', `Hello, ${localStorage.getItem('user-id')}`), 
        $el('img', { src: "/SVG/arrow_drop_down.svg" }),
        // user-id-menu에 mouseover, mouseout이벤트를 감지해서 드랍다운 구현
        $el('div.user-id-dropdown-submenu', { id: "user-id-dropdown-submenu" }, [
            $el('a', {
                onclick: () => { alert("[개발 중] 사용자 정책 고지"); }
            }, 'POLICY'),
            $el('a', {
                onclick: () => { alert("[개발 중] Stable Diffusion 모델, ComfyUI, 생성된 이미지의 저작권 안내"); }
            }, 'LICENSE'),
            $el('a', {
                onclick: () => { this.userLogout(); }
            }, 'LOGOUT')
        ])
    ]),
]);

body.append(header);
this.initUserIdDropdown();

initUserIdDropdown() {
    const userIdMenu = document.getElementById('user-id-menu');

    function showUserIdMenuDropdown() {
        const userIdDropdownSubmenu = document.getElementById('user-id-dropdown-submenu');
        userIdDropdownSubmenu.classList.add('show');
    }

    function hideUserIdMenuDropdown() {
        const userIdDropdownSubmenu = document.getElementById('user-id-dropdown-submenu');
        userIdDropdownSubmenu.classList.remove('show');
    }

    userIdMenu.addEventListener('mouseover', showUserIdMenuDropdown);
    userIdMenu.addEventListener('mouseout', hideUserIdMenuDropdown);
}

export function $el(tag, propsOrChildren, children) {
	const split = tag.split(".");
	const element = document.createElement(split.shift());
	if (split.length > 0) {
		element.classList.add(...split);
	}

	if (propsOrChildren) {
		if (typeof propsOrChildren === "string") {
			propsOrChildren = { textContent: propsOrChildren };
		} else if (propsOrChildren instanceof Element) {
			propsOrChildren = [propsOrChildren];
		}
		if (Array.isArray(propsOrChildren)) {
			element.append(...propsOrChildren);
		} else {
			const {parent, $: cb, dataset, style} = propsOrChildren;
			delete propsOrChildren.parent;
			delete propsOrChildren.$;
			delete propsOrChildren.dataset;
			delete propsOrChildren.style;

			if (Object.hasOwn(propsOrChildren, "for")) {
				element.setAttribute("for", propsOrChildren.for)
			}
			if (style) {
				Object.assign(element.style, style);
			}
			if (dataset) {
				Object.assign(element.dataset, dataset);
			}

			Object.assign(element, propsOrChildren);
			if (children) {
				element.append(...(children instanceof Array ? children : [children]));
			}
			if (parent) {
				parent.append(element);
			}
			if (cb) {
				cb(element);
			}
		}
	}
	return element;
}

위의 코드에서 html 요소 정의에 사용된 $el 메서드는 오픈소스 프로젝트인 ComfyUI 코드를 분석하다 발견했는데, 이후에도 꽤나 잘 사용하고 있다.

 

css 코드

.header-menu {
  display: flex;
  margin-right: 15px;
  font-weight: bold;
  color: rgba(255, 255, 255, 1);
  line-height: 30px;
  font-weight: 700;
  font-size: 13px;

  cursor: pointer;
}

.header-menu a {
  color: inherit;
  text-decoration: none;
  display: inline-block;
  width: 100%;
  height: 100%;
}

.header-text:hover {
  background-size: 100% 2px;
  background-image: linear-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 1));
  background-repeat: no-repeat;
  background-position: left 0 bottom 0;
}

.header-text {
  padding-bottom: 2px;
  background-size: 0 2px;
}

.user-id-dropdown-submenu {
  display: none; /* 기본적으로 숨김 */
  position: absolute;
  top: 41px;

  background-color: rgba(0, 0, 0, 0.6);
  color: #333333;
  z-index: 1;
  overflow: hidden;
  max-height: 0;
  transition: max-height 0.7s ease;

  padding-top: 10px;
  padding-right: 5px;
  padding-left: 5px;
}

.user-id-dropdown-submenu a {
  color: rgba(255, 255, 255, 1);
  text-decoration: none;
  display: block;

  padding-right: 20px;
  padding-left: 20px;
  margin-bottom: 5px;
}

.user-id-dropdown-submenu a:hover {
  background-color: rgba(100, 100, 100, 0.6);
}

.user-id-dropdown-submenu.show {
  display: block;
  max-height: 200px;
}

기본적으로 user-id-dropdown-submenu가 display: none;으로 보이지 않다가, 자바스크립트에서 동적으로 show 클래스리스트를 추가하면 보이도록 만들어 두었다. 애니메이션을 넣고싶은데 이건 나중에 시간 날때 해보도록 하자.

728x90

문제상황

유저가 textarea에서 입력을 하면, 드랍다운에 추천 텍스트를 보여주고, 드랍다운 클릭을 통해 텍스트를 자동으로 채워주는 기능을 개발하고 있었다. 그런데 textarea가 비활성화되는 blur 이벤트가 click 이벤트보다 먼저 발생하여 드랍다운 메뉴가 먼저 사라지고, 유저는 드랍다운 메뉴가 있던 빈 자리를 클릭하게 되는 것이었다.

 

ChatGPT의 해결 방법 : setTimeout()

gpt는 setTimeout()을 사용할 것을 추천해주었다. 즉, blur 이벤트의 처리를 약간 지연시켜 click 이벤트가 먼저 발생하도록 만드는 것이다. setTimeout()을 이용해 blur 이벤트의 콜백 함수(드랍다운의 display를 none으로 설정)를 100ms정도 되는 짧은 시간 뒤에 실행되도록 지연시킨다. 이렇게 하면 click 이벤트가 먼저 발생하고, 그 후에 blur 이벤트가 발생하게 되어 드롭다운 메뉴 등을 정상적으로 선택할 수 있게 된다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Textarea Blur Event Example</title>
</head>
<body>
    <textarea id="myTextarea" rows="4" cols="50">Click outside of this textarea to see the issue.</textarea>
    <div id="dropdownMenu" style="display: none; border: 1px solid #000; padding: 10px; position: absolute;">
        <p>Dropdown Item 1</p>
        <p>Dropdown Item 2</p>
        <p>Dropdown Item 3</p>
    </div>

    <script>
        const textarea = document.getElementById('myTextarea');
        const dropdownMenu = document.getElementById('dropdownMenu');

        textarea.addEventListener('focus', () => {
            // Textarea가 포커스를 얻으면 드롭다운 메뉴를 보여줍니다.
            dropdownMenu.style.display = 'block';
        });

        textarea.addEventListener('blur', () => {
            // setTimeout을 사용하여 blur 이벤트의 처리를 지연시킵니다.
            setTimeout(() => {
                dropdownMenu.style.display = 'none';
            }, 100);
        });

        dropdownMenu.addEventListener('click', (event) => {
            // 드롭다운 메뉴가 클릭되었을 때의 동작을 정의합니다.
            alert('Dropdown item clicked!');
        });
    </script>
</body>
</html>

 

실제 해결 방법 : mousedown 이벤트로 blur 이벤트 리스너 제거

우선 기능 개발이 급해 gpt의 추천대로 setTimeout()을 사용했지만, 급한 불을 끄고 난 뒤에 일정 시간을 지연시키는 방법이 밀리세컨을 직접 저렇게 숫자로 코드에 넣는 것이 하드코딩스럽기도 하고 괜히 마음에 걸렸다. 나중에 수정한 방법은 mousedown 이벤트를 활용한 방법이다.

mousedown 이벤트는 사용자가 마우스 버튼을 누를 때 발생하므로, click 이벤트보다 먼저 발생한다. 이 이벤트를 활용해 사용자가 클릭하는 순간, blur 이벤트 리스너를 제거하여 클릭이 먼저 처리되도록 해결하였다. (나중에 gpt에게 다시 물어보니 mouseup 이벤트에서 다시 blur 이벤트 리스너를 추가하여 클릭이 완료된 후에 blur 이벤트가 발생할 수 있도록 원상복구 하란다. 출근하면 바꿔놔야겠다.)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Textarea Blur Event Example</title>
</head>
<body>
    <textarea id="myTextarea" rows="4" cols="50">Click outside of this textarea to see the issue.</textarea>
    <div id="dropdownMenu" style="display: none; border: 1px solid #000; padding: 10px; position: absolute;">
        <p>Dropdown Item 1</p>
        <p>Dropdown Item 2</p>
        <p>Dropdown Item 3</p>
    </div>

    <script>
        const textarea = document.getElementById('myTextarea');
        const dropdownMenu = document.getElementById('dropdownMenu');

        function hideDropdown() {
            dropdownMenu.style.display = 'none';
        }

        // blur 이벤트 리스너를 추가하는 함수
        function addBlurListener() {
            textarea.addEventListener('blur', hideDropdown);
        }

        // blur 이벤트 리스너를 제거하는 함수
        function removeBlurListener() {
            textarea.removeEventListener('blur', hideDropdown);
        }

        textarea.addEventListener('focus', () => {
            // Textarea가 포커스를 얻으면 드롭다운 메뉴를 보여줍니다.
            dropdownMenu.style.display = 'block';
        });

        textarea.addEventListener('mousedown', () => {
            // mousedown 시점에 blur 이벤트 리스너를 제거합니다.
            removeBlurListener();
        });

        document.addEventListener('mouseup', () => {
            // mouseup 시점에 blur 이벤트 리스너를 다시 추가합니다.
            addBlurListener();
        });

        dropdownMenu.addEventListener('click', (event) => {
            // 드롭다운 메뉴가 클릭되었을 때의 동작을 정의합니다.
            alert('Dropdown item clicked!');
        });

        // 초기에는 blur 이벤트 리스너가 활성화된 상태로 시작합니다.
        addBlurListener();
    </script>
</body>
</html>

 

결론

사실 두 방식 모두 잘 작동했기에 뭐가 더 나은 방법이라 말하긴 어렵다. 내부 구현이 어떻든 유저가 체감하는 퍼포먼스만 좋으면 되니까...ㅋㅋㅋ 그래도 역시 지연시킬 시간을 얼마를 주는게 좋을지 고민하는 것보단 동적으로 이벤트 리스너를 제거하고 다시 추가해주는게 좀 더 내스타일 해결방법이란 생각이 든다.

728x90

+ Recent posts