04하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식
가리키고 있는 항목 위에 머무른 시간 값이 정해진 값을 넘은 상태가 되면 그 옆에 하위 목록이 나타나고, 다른 항목으로 옮겨간 상태가 되면 머무른 시간 값이 다시 0부터 셈해집니다.
쓰임여러 단계로 나뉜 편집 메뉴, 파일 형식별 하위 목록
scheduleSub()CASCADE_MS = 260place(sub, mr.right - 2, tr.top - 4)

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint">우클릭 → 정렬에 머무르기</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item cm__item--active" type="button" role="menuitem" id="cmSortTrigger">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M184 88v104a8 8 0 0 1-13.66 5.66l-32-32a8 8 0 0 1 0-11.32L170.34 122A8 8 0 0 1 184 88M72 88v104a8 8 0 0 0 13.66 5.66l32-32a8 8 0 0 0 0-11.32L85.66 122A8 8 0 0 0 72 88" opacity=".2"/><path d="M226.83 82.34a8 8 0 0 0-8.72 1.73l-32 32a8 8 0 0 0 0 11.32l32 32A8 8 0 0 0 232 154V90a8 8 0 0 0-5.17-7.66M216 134.69L197.31 116L216 97.31ZM37.86 116l32-32a8 8 0 0 1 13.66 5.66v64A8 8 0 0 1 69.86 159l-32-32a8 8 0 0 1 0-11m14.28 5.66L59.86 130V102Z"/></g></svg></span>
<span>정렬</span>
<span class="cm__caret"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg></span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
<div class="cm__sub" id="cmSub" role="menu">
<button class="cm__item" type="button" role="menuitem"><span>이름순</span></button>
<button class="cm__item" type="button" role="menuitem"><span>날짜순</span></button>
<button class="cm__item" type="button" role="menuitem"><span>크기순</span></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 부모 항목 위에 머무른 시간이 CASCADE_MS 를 넘는지 타이머로 재고, 넘으면 그 항목의
// 오른쪽 경계 좌표 값을 하위 목록의 왼쪽 좌표 값으로 그대로 물려 옆에 이어 붙인다 (openSub()).
@include stage($stage-paper);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-blue-deep;
color: $subject-cream;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .85; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 156px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.94);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__sub {
position: fixed;
top: 0;
left: 0;
min-width: 132px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.9);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-pop, transform $dur-quick $ease-pop;
z-index: 6;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible, &--active { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__caret { margin-left: auto; width: 12px; height: 12px; color: rgba($subject-ink, .55); }
.cm__caret svg { display: block; width: 100%; height: 100%; }
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 132px; }
.cm__sub { min-width: 112px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__sub { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #0a4fe8;
color: #fff7e6;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.85;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 156px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.94);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__sub {
position: fixed;
top: 0;
left: 0;
min-width: 132px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.9);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
z-index: 6;
}
.cm__sub.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible, .cm__item--active {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__caret {
margin-left: auto;
width: 12px;
height: 12px;
color: rgba(23, 20, 26, 0.55);
}
.cm__caret svg {
display: block;
width: 100%;
height: 100%;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 132px;
}
.cm__sub {
min-width: 112px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__sub {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var sub = document.getElementById('cmSub');
var trigger = document.getElementById('cmSortTrigger');
var root = document.getElementById('cm');
var GAP = 6;
var CASCADE_MS = 260;
function place(el, x, y) {
var w = el.offsetWidth, h = el.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - w - GAP));
var py = Math.max(GAP, Math.min(y, vh - h - GAP));
el.style.left = px.toFixed(0) + 'px';
el.style.top = py.toFixed(0) + 'px';
return { left: px, top: py, right: px + w, width: w };
}
var hoverTimer = null;
function openMenu(x, y) {
menu.style.left = '0px'; menu.style.top = '0px';
var box = place(menu, x, y);
menu.classList.add('is-open');
}
function closeAll() {
menu.classList.remove('is-open');
sub.classList.remove('is-open');
clearHover();
}
function clearHover() { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
function scheduleSub() {
clearHover();
// 계산 겹: 정렬 항목 위에 머무른 시간을 CASCADE_MS 로 재고, 그 시간을 넘겨야만 서브메뉴를 연다
// (즉시 열지 않음 — 지나가는 커서에 반응하지 않게).
hoverTimer = setTimeout(function () {
var mr = menu.getBoundingClientRect();
var tr = trigger.getBoundingClientRect();
place(sub, mr.right - 2, tr.top - 4);
sub.classList.add('is-open');
}, CASCADE_MS);
}
trigger.addEventListener('pointerenter', scheduleSub);
trigger.addEventListener('pointerleave', function () { clearHover(); });
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
closeAll();
openMenu(e.clientX, e.clientY);
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && !sub.contains(e.target) && e.target !== field) {
closeAll();
}
});
// 자동 루프: 메뉴를 연 뒤 정렬 항목 위에 CASCADE_MS 이상 머물러 서브메뉴가 계단식으로 이어 붙는
// 것을 보여주고, 잠시 뒤 닫는다.
var step = 0, autoTimer = null;
function autoTick() {
var phase = step % 4;
if (phase === 0) {
var r = field.getBoundingClientRect();
openMenu(r.left + r.width * 0.5, r.top + r.height * 0.6);
} else if (phase === 1) {
scheduleSub();
} else if (phase === 3) {
closeAll();
}
step++;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } clearHover(); }
if (!reduce) { autoTimer = setInterval(autoTick, CASCADE_MS + 140); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./SubmenuCascadeOpen.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_SORT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M184 88v104a8 8 0 0 1-13.66 5.66l-32-32a8 8 0 0 1 0-11.32L170.34 122A8 8 0 0 1 184 88M72 88v104a8 8 0 0 0 13.66 5.66l32-32a8 8 0 0 0 0-11.32L85.66 122A8 8 0 0 0 72 88" opacity=".2"/><path d="M226.83 82.34a8 8 0 0 0-8.72 1.73l-32 32a8 8 0 0 0 0 11.32l32 32A8 8 0 0 0 232 154V90a8 8 0 0 0-5.17-7.66M216 134.69L197.31 116L216 97.31ZM37.86 116l32-32a8 8 0 0 1 13.66 5.66v64A8 8 0 0 1 69.86 159l-32-32a8 8 0 0 1 0-11m14.28 5.66L59.86 130V102Z"/></g></svg>';
const ICON_CARET = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type SubmenuCascadeOpenProps = {
color?: string;
};
export default function SubmenuCascadeOpen({
color = "#2f6df6",
}: SubmenuCascadeOpenProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
const sub = root.querySelector<HTMLDivElement>(`.${styles.sub}`);
const trigger = root.querySelector<HTMLButtonElement>(`.${styles.trigger}`);
if (!field || !menu || !sub || !trigger) return;
const GAP = 6;
const CASCADE_MS = 260;
function place(el: HTMLElement, x: number, y: number) {
const w = el.offsetWidth, h = el.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - w - GAP));
const py = Math.max(GAP, Math.min(y, vh - h - GAP));
el.style.left = px.toFixed(0) + "px";
el.style.top = py.toFixed(0) + "px";
}
let hoverTimer: number | null = null;
function openMenu(x: number, y: number) {
menu!.style.left = "0px"; menu!.style.top = "0px";
place(menu!, x, y);
menu!.classList.add(styles.open);
}
function clearHover() { if (hoverTimer !== null) { window.clearTimeout(hoverTimer); hoverTimer = null; } }
function closeAll() { menu!.classList.remove(styles.open); sub!.classList.remove(styles.subOpen); clearHover(); }
// 계산 겹: 정렬 항목 위에 머무른 시간을 CASCADE_MS 로 재고, 그 시간을 넘겨야만 서브메뉴를
// 연다(즉시 열지 않음 — 지나가는 커서에 반응하지 않게).
function scheduleSub() {
clearHover();
hoverTimer = window.setTimeout(() => {
const mr = menu!.getBoundingClientRect();
const tr = trigger!.getBoundingClientRect();
place(sub!, mr.right - 2, tr.top - 4);
sub!.classList.add(styles.subOpen);
}, CASCADE_MS);
}
trigger.addEventListener("pointerenter", scheduleSub);
const onLeave = () => clearHover();
trigger.addEventListener("pointerleave", onLeave);
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); closeAll(); openMenu(e.clientX, e.clientY); }
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && !sub!.contains(e.target as Node) && e.target !== field) closeAll();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
const phase = step % 4;
if (phase === 0) {
const r = field!.getBoundingClientRect();
openMenu(r.left + r.width * 0.5, r.top + r.height * 0.6);
} else if (phase === 1) {
scheduleSub();
} else if (phase === 3) {
closeAll();
}
step++;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } clearHover(); }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, CASCADE_MS + 140); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
trigger.removeEventListener("pointerenter", scheduleSub);
trigger.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>우클릭 → 정렬에 머무르기</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={`${styles.item} ${styles.active} ${styles.trigger}`} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_SORT }} />
<span>정렬</span>
<span className={styles.caret} dangerouslySetInnerHTML={{ __html: ICON_CARET }} />
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
<div className={styles.sub} role="menu">
<button type="button" className={styles.item} role="menuitem"><span>이름순</span></button>
<button type="button" className={styles.item} role="menuitem"><span>날짜순</span></button>
<button type="button" className={styles.item} role="menuitem"><span>크기순</span></button>
</div>
</div>
</div>
);
}04 하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식 — 측정 누적면적 15.367% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 컨텍스트 메뉴 UI 9종 — 클릭 좌표 값이 메뉴 위치를 정하는 방식
이 데모가 속한 글
컨텍스트 메뉴 UI 9종 — 클릭 좌표 값이 메뉴 위치를 정하는 방식컨텍스트 메뉴 UI는 마우스 오른쪽 버튼이나 길게 누르기로 여는 작은 목록인데, 아홉 개 중 하나는 클릭한 화면 좌표 값이 메뉴가 나타날 위치 값을 그대로 정하고, 하나는 그 위치 값이 화면 가장자리에 가까운 상태가 이 글의 데모 전부 보기


