07Right-click context menu
Right-clicking a row springs a menu at the pointer, a highlight walks down its items, and Escape dismisses it.
Best forWhen open, rename, and delete belong at the pointer instead of stacked as buttons all over the screen
contextmenurole=menuEscape

<main class="stage">
<div class="cm is-demo" id="cm">
<ul class="cm__tree" id="cm-tree" role="tree" aria-label="files" tabindex="0" aria-activedescendant="cm-1">
<li class="cm__item is-row1" id="cm-1" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="cm__name">hero.tsx</span></div>
</li>
<li class="cm__item" id="cm-2" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="cm__name">button.tsx</span></div>
</li>
<li class="cm__item" id="cm-3" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico cm__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="cm__name">site</span></div>
</li>
<li class="cm__item" id="cm-4" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span><span class="cm__name">bundle.zip</span></div>
</li>
</ul>
<span class="cm__dot" aria-hidden="true"></span>
<div class="cm__menu" id="cm-menu" role="menu" aria-label="hero.tsx menu" hidden>
<span class="cm__hl" aria-hidden="true"></span>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M184 80v128a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h128a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8"/></g></svg></span>Open</button>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>Rename</button>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><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>Copy</button>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><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>Delete</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-yellow;
@include stage($stage-orange);
.cm {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink; // 주황 무대 위 흰 글자는 3.32:1 — 잉크 5.50:1
}
.cm__tree { list-style: none; margin: 0; padding: 0; }
.cm__tree:focus-visible { outline: none; }
.cm__tree li[aria-selected="true"] > .cm__row { outline: 2px solid $subject-ink; outline-offset: -2px; }
.cm__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: context-menu;
user-select: none;
}
.cm__row:hover { background: rgba(255, 255, 255, .1); }
.cm__row:focus { outline: none; }
.cm__ico { display: inline-flex; flex: 0 0 auto; color: $subject-ink; }
.cm__ico--folder { color: $color; }
.cm__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
// 우클릭 지점 표시
.cm__dot {
position: absolute;
left: 96px; top: 10px;
width: 8px; height: 8px;
border-radius: 50%;
background: #fff;
opacity: 0;
}
// 컨텍스트 메뉴 — 절대 배치라 부모 높이에서 빠진다. 무대 안에 다 들어가게 열린다
.cm__menu {
position: absolute;
left: 88px; top: 26px;
box-sizing: border-box;
width: 148px;
padding: $sp-1 0;
border-radius: $r-sm;
background: #fff;
box-shadow: $shadow-raised;
transform-origin: top left; // 규칙8: 우클릭 지점에서 팝
z-index: 2;
}
.cm__menu:focus { outline: none; }
.cm__hl {
position: absolute;
left: $sp-1; right: $sp-1; top: $sp-1;
height: 28px;
border-radius: $r-xs;
background: rgba(255, 77, 31, .12);
transition: transform 120ms $ease-out;
}
.cm__mi {
position: relative;
display: flex; align-items: center; gap: $sp-2;
height: 28px;
padding: 0 $sp-2;
border: 0;
background: none;
border-radius: $r-xs;
font-family: inherit;
font-size: 11px; line-height: 28px; font-weight: 700;
color: $stage-ink;
cursor: pointer;
width: 100%;
text-align: left;
}
.cm__mi-ico { display: inline-flex; flex: 0 0 auto; color: $stage-orange; }
.cm__mi:hover { background: rgba(255, 77, 31, .12); }
.cm__mi:focus { outline: none; }
// ── 자동 루프: 점이 닿은 뒤 메뉴가 열리고 하이라이트가 내려간다 ──
.cm.is-demo .cm__dot {
animation-name: cm-dot;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes cm-dot {
0% { transform: scale(0); opacity: 1; }
8%, 78% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(0); opacity: 0; }
}
.cm.is-demo .cm__menu {
display: block;
animation-name: cm-menu;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cm-menu {
0%, 7.9% { opacity: 0; transform: scale(.7); }
14% { opacity: 1; transform: scale(1); }
72% { opacity: 1; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(.9); }
}
.cm.is-demo .cm__hl {
animation-name: cm-bar;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes cm-bar {
0%, 15% { transform: translateY(0); }
25%, 35% { transform: translateY(28px); }
45%, 55% { transform: translateY(56px); }
65%, 100% { transform: translateY(84px); }
}
.cm.is-demo .cm__item.is-row1 .cm__row {
animation-name: cm-tint;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cm-tint {
0%, 7.9% { background: transparent; }
8%, 72% { background: rgba(255, 255, 255, .1); }
72.01%, 100% { background: transparent; }
}
@media (max-width: 340px) {
.cm__row { height: 22px; font-size: 11px; }
.cm__mi { height: 24px; line-height: 24px; font-size: 10px; }
.cm__hl { height: 24px; }
.cm__menu { left: 72px; top: 22px; width: 132px; }
.cm__dot { left: 80px; top: 8px; }
@keyframes cm-bar {
0%, 15% { transform: translateY(0); }
25%, 35% { transform: translateY(24px); }
45%, 55% { transform: translateY(48px); }
65%, 100% { transform: translateY(72px); }
}
}
@media (prefers-reduced-motion: reduce) {
.cm.is-demo .cm__dot, .cm.is-demo .cm__menu, .cm.is-demo .cm__hl, .cm.is-demo .cm__item.is-row1 .cm__row { animation: none !important; }
.cm__hl { transition: none; }
.cm.is-demo .cm__menu { display: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cm {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cm__tree {
list-style: none;
margin: 0;
padding: 0;
}
.cm__tree:focus-visible {
outline: none;
}
.cm__tree li[aria-selected=true] > .cm__row {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.cm__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: context-menu;
user-select: none;
}
.cm__row:hover {
background: rgba(255, 255, 255, 0.1);
}
.cm__row:focus {
outline: none;
}
.cm__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.cm__ico--folder {
color: #ffd23f;
}
.cm__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm__dot {
position: absolute;
left: 96px;
top: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
opacity: 0;
}
.cm__menu {
position: absolute;
left: 88px;
top: 26px;
box-sizing: border-box;
width: 148px;
padding: 4px 0;
border-radius: 8px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top left;
z-index: 2;
}
.cm__menu:focus {
outline: none;
}
.cm__hl {
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: 28px;
border-radius: 4px;
background: rgba(255, 77, 31, 0.12);
transition: transform 120ms ease-out;
}
.cm__mi {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
border: 0;
background: none;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
line-height: 28px;
font-weight: 700;
color: #17141a;
cursor: pointer;
width: 100%;
text-align: left;
}
.cm__mi-ico {
display: inline-flex;
flex: 0 0 auto;
color: #ff4d1f;
}
.cm__mi:hover {
background: rgba(255, 77, 31, 0.12);
}
.cm__mi:focus {
outline: none;
}
.cm.is-demo .cm__dot {
animation-name: cm-dot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cm-dot {
0% {
transform: scale(0);
opacity: 1;
}
8%, 78% {
transform: scale(1);
opacity: 1;
}
90%, 100% {
transform: scale(0);
opacity: 0;
}
}
.cm.is-demo .cm__menu {
display: block;
animation-name: cm-menu;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cm-menu {
0%, 7.9% {
opacity: 0;
transform: scale(0.7);
}
14% {
opacity: 1;
transform: scale(1);
}
72% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0;
transform: scale(0.9);
}
}
.cm.is-demo .cm__hl {
animation-name: cm-bar;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes cm-bar {
0%, 15% {
transform: translateY(0);
}
25%, 35% {
transform: translateY(28px);
}
45%, 55% {
transform: translateY(56px);
}
65%, 100% {
transform: translateY(84px);
}
}
.cm.is-demo .cm__item.is-row1 .cm__row {
animation-name: cm-tint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cm-tint {
0%, 7.9% {
background: transparent;
}
8%, 72% {
background: rgba(255, 255, 255, 0.1);
}
72.01%, 100% {
background: transparent;
}
}
@media (max-width: 340px) {
.cm__row {
height: 22px;
font-size: 11px;
}
.cm__mi {
height: 24px;
line-height: 24px;
font-size: 10px;
}
.cm__hl {
height: 24px;
}
.cm__menu {
left: 72px;
top: 22px;
width: 132px;
}
.cm__dot {
left: 80px;
top: 8px;
}
@keyframes cm-bar {
0%, 15% {
transform: translateY(0);
}
25%, 35% {
transform: translateY(24px);
}
45%, 55% {
transform: translateY(48px);
}
65%, 100% {
transform: translateY(72px);
}
}
}
@media (prefers-reduced-motion: reduce) {
.cm.is-demo .cm__dot, .cm.is-demo .cm__menu, .cm.is-demo .cm__hl, .cm.is-demo .cm__item.is-row1 .cm__row {
animation: none !important;
}
.cm__hl {
transition: none;
}
.cm.is-demo .cm__menu {
display: none;
}
}// 실제 동작: 우클릭하면 그 자리에 메뉴가 열리고 화살표·Enter·Escape 가 듣는다.
var root = document.querySelector('.cm');
var tree = document.getElementById('cm-tree');
var menu = document.getElementById('cm-menu');
var mis = [].slice.call(menu.querySelectorAll('.cm__mi'));
var hl = menu.querySelector('.cm__hl');
var at = 0, row = null;
function open(x, y, li) {
root.classList.remove('is-demo');
menu.hidden = false;
row = li;
var r = root.getBoundingClientRect();
menu.style.left = Math.max(0, Math.min(x - r.left, root.offsetWidth - 152)) + 'px';
menu.style.top = Math.min(y - r.top, window.innerHeight - 128) + 'px';
menu.setAttribute('aria-label', li.querySelector('.cm__name').textContent + ' menu');
move(0);
menu.focus({ preventScroll: true });
}
function close() {
menu.hidden = true;
if (row) row.querySelector('.cm__row').focus({ preventScroll: true });
}
function move(i) {
at = Math.max(0, Math.min(i, mis.length - 1));
hl.style.transform = 'translateY(' + (at * 28) + 'px)';
mis.forEach(function (b, k) { b.setAttribute('aria-current', String(k === at)); });
mis[at].focus({ preventScroll: true });
}
tree.querySelectorAll('.cm__item').forEach(function (li) {
var r = li.querySelector('.cm__row');
r.setAttribute('tabindex', '-1');
li.setAttribute('aria-selected', 'false');
r.addEventListener('click', function () {
tree.querySelectorAll('.cm__item').forEach(function (l2) { l2.setAttribute('aria-selected', 'false'); });
li.setAttribute('aria-selected', 'true');
tree.setAttribute('aria-activedescendant', li.id);
r.focus({ preventScroll: true });
});
r.addEventListener('contextmenu', function (e) { e.preventDefault(); open(e.clientX, e.clientY, li); });
});
mis.forEach(function (b, i) {
b.addEventListener('mouseenter', function () { move(i); });
b.addEventListener('click', function () { close(); });
});
menu.setAttribute('tabindex', '0');
menu.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { move(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(at - 1); e.preventDefault(); }
else if (e.key === 'Enter') { close(); e.preventDefault(); }
else if (e.key === 'Escape') { close(); e.preventDefault(); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ContextMenuTree.module.scss";
export type TreeNode = { name: string; kind?: "file" | "folder" };
export type MenuEntry = { label: string; icon?: "open" | "rename" | "copy" | "trash" };
export type ContextMenuTreeProps = {
label?: string;
nodes?: TreeNode[];
items?: MenuEntry[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type MenuEvt = { clientX: number; clientY: number; preventDefault: () => void };
const DEFAULT_NODES: TreeNode[] = [
{ name: "hero.tsx" },
{ name: "button.tsx" },
{ name: "site", kind: "folder" },
{ name: "bundle.zip" },
];
const DEFAULT_ITEMS: MenuEntry[] = [
{ label: "열기", icon: "open" },
{ label: "이름 바꾸기", icon: "rename" },
{ label: "복사", icon: "copy" },
{ label: "삭제", icon: "trash" },
];
const ROW_H = 28;
const MENU_W = 152;
function fileIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
function folderIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function menuIcon(kind: string) {
if (kind === "rename") {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2" />
<path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z" />
</g>
</svg>
);
}
if (kind === "copy") {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<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>
);
}
if (kind === "trash") {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<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>
);
}
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M184 80v128a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h128a8 8 0 0 1 8 8" opacity=".2" />
<path d="M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8" />
</g>
</svg>
);
}
export default function ContextMenuTree({
label = "파일",
nodes = DEFAULT_NODES,
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: ContextMenuTreeProps) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const [atRow, setAtRow] = useState(0);
const [atItem, setAtItem] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 열릴 때: 첫 항목을 짚고 메뉴로 포커스를 옮긴다 (vanilla 의 move(0) + menu.focus 와 같은 순서)
useEffect(() => {
if (!pos) return;
const list = itemRefs.current;
if (list && list[0]) list[0].focus({ preventScroll: true });
menuRef.current?.focus({ preventScroll: true });
}, [pos]);
function openMenu(e: MenuEvt, i: number) {
e.preventDefault();
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, root.offsetWidth - MENU_W));
const y = Math.max(0, Math.min(e.clientY - r.top, window.innerHeight - 128));
setAtRow(i);
setAtItem(0);
setPos({ x, y });
}
function close() {
setPos(null);
const rows = rowRefs.current;
if (rows && rows[atRow]) rows[atRow]?.focus({ preventScroll: true });
}
function move(i: number) {
const k = Math.max(0, Math.min(i, items.length - 1));
setAtItem(k);
const list = itemRefs.current;
if (list && list[k]) list[k]?.focus({ preventScroll: true });
}
function onMenuKeyDown(e: KeyEvt) {
if (e.key === "ArrowDown") { move(atItem + 1); e.preventDefault(); }
else if (e.key === "ArrowUp") { move(atItem - 1); e.preventDefault(); }
else if (e.key === "Enter") { close(); e.preventDefault(); }
else if (e.key === "Escape") { close(); e.preventDefault(); }
}
const menuStyle: CSSProperties = pos ? { left: `${pos.x}px`, top: `${pos.y}px` } : {};
const target = nodes[atRow] ? nodes[atRow].name : label;
return (
<div className={styles.cm} style={vars} ref={rootRef}>
<ul
className={styles.tree}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={`cm-${atRow}`}
>
{nodes.map((n, i) => (
<li
key={n.name}
id={`cm-${i}`}
className={styles.item}
role="treeitem"
aria-level={1}
aria-selected={i === atRow}
>
<div
ref={(el: HTMLDivElement | null) => { if (rowRefs.current) rowRefs.current[i] = el; }}
className={`${styles.row} ${i === atRow ? styles.isSelected : ""}`}
tabIndex={-1}
onClick={() => setAtRow(i)}
onContextMenu={(e: MenuEvt) => openMenu(e, i)}
>
<span className={`${styles.ico} ${n.kind === "folder" ? styles.icoFolder : ""}`}>
{n.kind === "folder" ? folderIcon() : fileIcon()}
</span>
<span className={styles.name}>{n.name}</span>
</div>
</li>
))}
</ul>
<div
ref={menuRef}
className={styles.menu}
style={menuStyle}
role="menu"
aria-label={`${target} 메뉴`}
hidden={!pos}
tabIndex={0}
onKeyDown={onMenuKeyDown}
>
<span className={styles.hl} style={{ transform: `translateY(${atItem * ROW_H}px)` }} aria-hidden="true" />
{items.map((it, i) => (
<button
key={it.label}
ref={(el: HTMLButtonElement | null) => { if (itemRefs.current) itemRefs.current[i] = el; }}
className={styles.mi}
type="button"
role="menuitem"
aria-current={i === atItem ? "true" : "false"}
onMouseEnter={() => move(i)}
onClick={() => close()}
>
<span className={styles.miIco}>{menuIcon(it.icon || "open")}</span>
{it.label}
</button>
))}
</div>
</div>
);
}07 Right-click context menu — Measured area 21.474% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · 9 File Tree CSS Views — Folders That Really Open
The post this demo lives in
9 File Tree CSS Views — Folders That Really OpenA file tree css view is the nested folder list in a sidebar: folders that open, files with icons, a highlight on the row See all demos in this post


