다중 레이어 드롭다운 메뉴는 사용자에게 다양한 옵션을 보여주는 방식으로 하나의 메인 메뉴 아래에 여러 하위 메뉴를 계층화하여 나타내는 것이다.
쿠팡, 11번가 같은 쇼핑몰 사이트와 대학교 사이트 같이 많은 정보를 제공하는 서비스에서 주로 사용한다.
11번가 | 한국외대 |
---|---|
![]() | ![]() |
하지만 이런 다중 레이어 메뉴를 이용할 때 사용자가 원하는 동작을 하지 않을 때가 있다.
바로 대각선으로 이동하는 경우다.
쿠팡 사이트에서 드롭다운 메뉴를 탐색해보았다.
대각선으로 이동하면 다른 메뉴가 인식되어 하위 메뉴가 변경된다.
가전디지털의 하위 메뉴에서 TV/영상 가전으로 바로 이동하고 싶지만 마우스가 홈인테리어 메뉴를 지나면서 해당 탭을 인식해 하위 메뉴가 변경되어 버린다.
TV/영상 가전으로 이동하려면 메인 탭의 바로 옆에 있는 하위 메뉴로 이동하여 고정한 후에 탐색할 수 있다.
대부분의 사이트에서 이러한 불편함을 확인할 수 있었다.
사용자 경험을 개선하기 위해 대각선으로 이동할 때도 현재 메뉴탭이 유지되는 기능을 구현해보았다.
드롭다운 메뉴에서 탭에 mouseover 이벤트가 발생했을 때 대각선 허용 범위를 구한다.
사진에서 빨간 점은 마우스 좌표, 파란 선은 대각선 허용 범위를 의미한다.
실패한 과정도 모두 풀어썼기 때문에 (O)로 표시한 것만 보아도 무방하다.
마우스의 x좌표와 현재 메뉴를 기준으로 밑변의 길이를 구하고 일정 각만큼 이동 가능 범위를 구한다. (X)
파란색 삼각형 내부에서 mouseover 이벤트가 delay 내에 발생하면 하위 메뉴가 바뀌지 않고 유지될 것이다.
현재 마우스 좌표를 기준으로 밑변의길이 구하기
- event.target.getBoundingClientRect().right
- event.clientX
1. offsetX, offsetY: 요소 영역을 기준으로 내부 좌표를 표시한다.
2. getBoundingClientRect: 엘리먼트의 고정 위치
3. clientX, clientY: 스크롤 상관없이 사용자에게 보여지는 브라우저 페이지를 기준으로 좌표를 표시한다.
4. pageX, pageY: 스크롤을 포함한 페이지를 기준으로 좌표를 표시한다.
5. screenX, screenY: 모니터 스크린을 기준으로 좌표를 표시한다.
삼각비를 이용해 삼각형의 좌표를 구한다.
밑변의 길이를 알고 있으므로 대각선 이동각을 60도로 하여 높이를 구한다.
dot1: {x: clientX, y: clientY}
dot2: {x: clientX + 밑변, y:clientY - 높이}
dot3: {x: clientX + 밑변, y:clientY + 높이}
makeTrianglePos(mousePos, bottomWidth) {
const height = bottomWidth * Math.tan(60);
const dot2 = { x: mousePos.x + bottomWidth, y: mousePos.y - height };
const dot3 = { x: mousePos.x + bottomWidth, y: mousePos.y + height };
return { dot1: mousePos, dot2, dot3 };
}
좌표 계산 코드
setTrianglePos({ target, clientX, clientY }) {
const bottomWidth = target.getBoundingClientRect().right - clientX;
const mousePos = { x: clientX, y: clientY };
const { dot1, dot2, dot3 } = this.makeTrianglePos(mousePos, bottomWidth);
this.#pos = { dot1, dot2, dot3 };
}
🔥 대각선 이동 범위가 너무 좁다.
{ x: target.getBoundingClientRect().top, y: getBoundingRect().top + (getBoundingRect().bottom-getBoundingRect().top)/2 }
{x: clientX + 밑변, y:clientY - 높이}
{x: clientX + 밑변, y:clientY + 높이}
🔥 첫번째 방식보다는 대각선 이동 범위가 조금 더 넓어졌지만 벗어나는 케이스가 생긴다.
하위 메뉴의 처음과 끝을 기준으로 이동 범위를 정한다.(O)
{ x: target.getBoundingClientRect().top, y: getBoundingRect().top + (getBoundingRect().bottom-getBoundingRect().top)/2 }
{ x: document.querySelector(’two-depth’).getBoundingClientRect().left, y: Array.from(document.querySelector(’two-depth’).children)[0].getBoundingClientRect().top, }
{ x: document.querySelector(’two-depth’).getBoundingClientRect().left, y: Array.from(document.querySelector(’two-depth’).children).at(-1).getBoundingClientRect().bottom, }
getAreaOfTriangle(dot1, dot2, dot3) {
const l = dot1.x * dot2.y + dot2.x * dot3.y + dot3.x * dot1.y;
const r = dot2.x * dot1.y + dot3.x * dot2.y + dot1.x * dot3.y;
return 0.5 * Math.abs(l - r);
}
checkTriangleInPoint(dot1Pos, dot2Pos, dot3Pos, checkPos) {
const basicArea = this.getAreaOfTriangle(dot1Pos, dot2Pos, dot3Pos);
const dot12 = this.getAreaOfTriangle(dot1Pos, dot2Pos, checkPos);
const dot13 = this.getAreaOfTriangle(dot1Pos, dot3Pos, checkPos);
const dot23 = this.getAreaOfTriangle(dot2Pos, dot3Pos, checkPos);
// 좌표가 설정되지 않은 초기상태
if (basicArea === 0) return false;
return dot12 + dot13 + dot23 <= basicArea;
}
dropSubMenu({ target, clientX, clientY }) {
const mousePos = { x: clientX, y: clientY };
if (
!this.checkTriangleInPoint(
this.#pos.dot1,
this.#pos.dot2,
this.#pos.dot3,
mousePos
)
) {
this.controller.dropSubMenu(target.innerText);
}
}
setTrianglePos({ target }) {
(...)
this.controller.dropSubMenu(target.innerText);
}
하위 메뉴는 2개 이상일 수도 있다. 기능의 확장성을 위해 class로 ‘one-depth’나 ‘two-depth’를 찾는 것이 아닌 Element.nextElementSibling
을 이용해 하위 메뉴를 탐색하도록 수정했다.
공백(whiteSpace), 텍스트(#text)를 가리지 않고 다음에 있는 것을 가져온다.
let el = document.getElementById("div-1").nextSibling;
Element.nextElementSibling
은 부모의 children
에서 탐색한 값을 리턴한다.
두 경우 모두 현재 node나 element가 마지막인 경우 nextSibling을 null로 리턴한다.
ex) 다음 경우에서 childNodes
과 children
은 어떻게 다를까?
<ul>
<li>A</li>
<li>B</li>
</ul>
console.log(document.getElementTagName("ul").childNodes.length) // 5
console.log(document.getElementTagName("ul").children) // 2
childNodes
는#text
, LI
, #text
, LI
, #text
로 총 5개다. 공백은 text로 읽힌다.children
은 LI
, LI
로 총 2개다. setTrianglePos({ target }) {
const nextDepth = target.closest("ul").nextElementSibling;
if (!nextDepth?.clientWidth) return;
const { top, bottom, left } = target.getBoundingClientRect();
const dot1 = { x: left, y: top + (bottom - top) / 2 };
const dot2 = {
x: nextDepth.getBoundingClientRect().left,
y: Array.from(nextDepth.children)[0].getBoundingClientRect().top,
};
const dot3 = {
x: nextDepth.getBoundingClientRect().left,
y: Array.from(nextDepth.children).at(-1).getBoundingClientRect().bottom,
};
this.#pos = { dot1, dot2, dot3 };
this.controller.dropSubMenu(target.innerText);
}
현재 메뉴에서 delay 내에 대각선 이동이 가능한 경우
현재 메뉴에서 delay 후에 대각선 이동이 가능한 경우
setEvent() {
const DIAGNOL_MOVEMENT_DELAY = 100;
const setPos = debouncing(
this.setTrianglePos.bind(this),
DIAGNOL_MOVEMENT_DELAY
);
this.categoryBtn.addEventListener(
"mouseover",
({ target, clientX, clientY }) => {
(...)
if (target.tagName === "LI") {
setPos({ target, clientX, clientY });
this.dropSubMenu({ target, clientX, clientY });
}
}
);
}
Element.nextElementSibling
을 이용하여 로직을 구현한다. 개선 전 | 개선 후 |
---|---|
![]() | ![]() |
🪄 Smart Multi Layer Dropdown Menu 보러가기
완성된 다중 레이어 드롭다운 메뉴는 위의 링크에서 확인할 수 있다.
canvas로 마우스 좌표에 따라 마우스 이동 범위를 그려봤다.
canvas 코드 if (this.canvas.getContext) {
const ctx = this.canvas.getContext("2d");
ctx.beginPath();
ctx.moveTo(dot1.x, dot1.y);
ctx.lineTo(dot2.x, dot2.y);
ctx.lineTo(dot3.x, dot3.y);
ctx.lineTo(dot1.x, dot1.y);
ctx.stroke();
}
예상 동작 | 실제 동작 |
---|---|
![]() | ![]() |
Element.getBoundingClientRect().right
의 값은 0이 된다.{x: clientX, y: clientY}
{x: 0, y:clientY - height}
{x: 0, y:clientY + height}
this.categoryBtn.addEventListener(
"mouseover",
debouncing(this.setTrianglePos.bind(this))
);
setEvent() {
this.oneDepth.addEventListener(
"mouseover",
debouncing(this.setTrianglePos.bind(this), 1000)
);
}
// 매개변수 clientX, clientY는 이벤트 발생 당시 값이 전달된다.
setTrianglePos({ target, clientX, clientY }) {
// getBoundingClientRect 값은 delay 이후 콜백함수가 실행되는 시점의 값을 가져온다.
const bottomWidth = this.oneDepth.getBoundingClientRect().right - clientX;
(...)
}
💡 clientX, clientY는 event가 발생할 때 매개변수로 전달되는 값이므로 그 시점의 좌표가 전달되지만 콜백함수가 실행되는 시점은 delay 이후이므로 내부 로직은 delay 이후의 DOM을 탐색한다.