08Inline rename in place
Double-clicking turns the name into a field in place with the old name preselected; Enter commits and Escape cancels.
Best forWhen renaming should happen right where the eye already is, not in a dialog or a form below
F2inputEnter

<main class="stage">
<div class="rn is-demo" id="rn">
<div class="rn__head">
<span class="rn__title">Project files</span>
<button class="rn__add" type="button" id="rn-add" aria-label="Add new file"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg><span>Add file</span></button>
</div>
<ul class="rn__tree" id="rn-tree" role="tree" aria-label="Project files" tabindex="0" aria-activedescendant="rn-2">
<li class="rn__item" id="rn-1" role="treeitem" aria-level="1">
<div class="rn__row" data-hit><span class="rn__ico rn__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><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="rn__name">src</span></div>
</li>
<li class="rn__item is-edit" id="rn-2" role="treeitem" aria-level="1" aria-label="notes.md">
<div class="rn__row" data-hit>
<span class="rn__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><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 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152"/></g></svg></span>
<span class="rn__stack" aria-hidden="true">
<span class="rn__lab rn__lab--1">notes.md</span>
<span class="rn__lab rn__lab--2">release-notes.md</span>
<span class="rn__field"><span class="rn__fv rn__fv--1">notes.md</span><span class="rn__fv rn__fv--2">release-notes.md</span><span class="rn__caret"></span></span>
</span>
<span class="rn__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
</div>
</li>
<li class="rn__item" id="rn-3" role="treeitem" aria-level="1">
<div class="rn__row" data-hit><span class="rn__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><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="rn__name">app.js</span></div>
</li>
<li class="rn__item" id="rn-4" role="treeitem" aria-level="1">
<div class="rn__row" data-hit><span class="rn__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><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="rn__name">bundle.zip</span></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.rn {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.rn__head {
display: flex; align-items: center; justify-content: space-between;
margin: 0 0 $sp-2;
}
.rn__title {
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(23, 20, 26, .72);
}
.rn__add {
display: inline-flex; align-items: center; gap: $sp-1;
height: 20px;
padding: 0 $sp-2;
border: 1px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: #fff;
font-family: inherit;
font-size: 10px; line-height: 20px; font-weight: 800;
color: $stage-ink;
cursor: pointer;
}
.rn__add:hover { border-color: $color; color: $color; }
.rn__add svg { color: $color; }
.rn__tree { list-style: none; margin: 0; padding: 0; }
.rn__tree:focus-visible { outline: none; }
.rn__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: default;
user-select: none;
}
.rn__row:hover { background: rgba(23, 20, 26, .05); }
.rn__ico { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.rn__ico--folder { color: $color; }
.rn__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rn__ok {
margin-left: auto;
display: inline-flex; flex: 0 0 auto;
color: $subject-mint;
opacity: 0;
}
.rn__ok.is-flash { animation-name: rn-ok; animation-duration: 480ms; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
@keyframes rn-ok { 0% { opacity: 0; transform: scale(.5); } 40% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(1); } }
// 이름과 입력칸을 같은 자리에 겹쳐 둔다 — 교체는 컷
.rn__stack { position: relative; display: block; flex: 1; height: 26px; min-width: 0; }
.rn__lab {
position: absolute; left: 0; top: 0;
line-height: 26px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 100%;
opacity: 0;
}
.rn__field {
position: absolute; left: -4px; top: 1px; right: 0;
height: 24px;
display: none; align-items: center;
padding: 0 $sp-2;
border: 1px solid $color;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, .18);
opacity: 0;
}
.rn__fv { font-size: 12px; font-weight: 600; white-space: nowrap; }
.rn__fv--1 { background: rgba(47, 109, 246, .18); }
.rn__caret {
display: inline-block;
width: 1px; height: 14px;
margin-left: 0;
background: $color;
}
.rn__live { display: inline-flex; flex: 1; min-width: 0; }
.rn__edit {
box-sizing: border-box;
width: 100%; height: 22px;
padding: 0 $sp-2;
border: 1px solid $color;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, .18);
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $stage-ink;
outline: none;
}
// ── 자동 루프: 두 번 눌러 열고 → 치고 → Enter ──
.rn.is-demo .rn__lab--1 {
animation-name: rn-l1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l1 { 0% { opacity: 1; } 8% { opacity: 0; } 94% { opacity: 1; } 100% { opacity: 1; } }
.rn.is-demo .rn__lab--2 {
animation-name: rn-l2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l2 { 0% { opacity: 0; } 52% { opacity: 1; } 82% { opacity: 1; } 82.01%, 100% { opacity: 0; } }
.rn.is-demo .rn__field {
display: flex;
animation-name: rn-f;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-f { 0% { opacity: 0; } 8% { opacity: 1; } 52% { opacity: 0; } 100% { opacity: 0; } }
.rn.is-demo .rn__fv--1 {
animation-name: rn-v1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v1 { 0%, 7.9% { opacity: 0; } 8% { opacity: 1; } 36% { opacity: 0; } 100% { opacity: 0; } }
.rn.is-demo .rn__fv--2 {
animation-name: rn-v2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v2 { 0%, 35.9% { opacity: 0; } 36% { opacity: 1; } 52% { opacity: 0; } 100% { opacity: 0; } }
.rn.is-demo .rn__caret {
animation-name: rn-caret;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-caret {
0%, 7.9% { opacity: 0; }
8% { opacity: 1; } 16% { opacity: 0; } 24% { opacity: 1; } 32% { opacity: 0; } 40% { opacity: 1; }
48% { opacity: 0; } 52%, 100% { opacity: 0; }
}
.rn.is-demo .rn__ok {
animation-name: rn-oka;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes rn-oka {
0%, 51.9% { opacity: 0; transform: scale(.5); }
56% { opacity: 1; transform: scale(1); }
80% { opacity: 1; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.5); }
}
@media (max-width: 340px) {
.rn__row { height: 22px; font-size: 11px; }
.rn__lab { line-height: 22px; }
.rn__field { height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.rn.is-demo .rn__lab, .rn.is-demo .rn__field, .rn.is-demo .rn__fv, .rn.is-demo .rn__caret, .rn.is-demo .rn__ok { animation: none !important; }
.rn.is-demo .rn__lab--1 { opacity: 1; }
.rn.is-demo .rn__field { display: 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);
}
.rn {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.rn__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.rn__title {
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
}
.rn__add {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #fff;
font-family: inherit;
font-size: 10px;
line-height: 20px;
font-weight: 800;
color: #17141a;
cursor: pointer;
}
.rn__add:hover {
border-color: #2f6df6;
color: #2f6df6;
}
.rn__add svg {
color: #2f6df6;
}
.rn__tree {
list-style: none;
margin: 0;
padding: 0;
}
.rn__tree:focus-visible {
outline: none;
}
.rn__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: default;
user-select: none;
}
.rn__row:hover {
background: rgba(23, 20, 26, 0.05);
}
.rn__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.rn__ico--folder {
color: #2f6df6;
}
.rn__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rn__ok {
margin-left: auto;
display: inline-flex;
flex: 0 0 auto;
color: #4cd4a6;
opacity: 0;
}
.rn__ok.is-flash {
animation-name: rn-ok;
animation-duration: 480ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes rn-ok {
0% {
opacity: 0;
transform: scale(0.5);
}
40% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1);
}
}
.rn__stack {
position: relative;
display: block;
flex: 1;
height: 26px;
min-width: 0;
}
.rn__lab {
position: absolute;
left: 0;
top: 0;
line-height: 26px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0;
}
.rn__field {
position: absolute;
left: -4px;
top: 1px;
right: 0;
height: 24px;
display: none;
align-items: center;
padding: 0 8px;
border: 1px solid #2f6df6;
border-radius: 4px;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.18);
opacity: 0;
}
.rn__fv {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.rn__fv--1 {
background: rgba(47, 109, 246, 0.18);
}
.rn__caret {
display: inline-block;
width: 1px;
height: 14px;
margin-left: 0;
background: #2f6df6;
}
.rn__live {
display: inline-flex;
flex: 1;
min-width: 0;
}
.rn__edit {
box-sizing: border-box;
width: 100%;
height: 22px;
padding: 0 8px;
border: 1px solid #2f6df6;
border-radius: 4px;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.18);
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
outline: none;
}
.rn.is-demo .rn__lab--1 {
animation-name: rn-l1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l1 {
0% {
opacity: 1;
}
8% {
opacity: 0;
}
94% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.rn.is-demo .rn__lab--2 {
animation-name: rn-l2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l2 {
0% {
opacity: 0;
}
52% {
opacity: 1;
}
82% {
opacity: 1;
}
82.01%, 100% {
opacity: 0;
}
}
.rn.is-demo .rn__field {
display: flex;
animation-name: rn-f;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-f {
0% {
opacity: 0;
}
8% {
opacity: 1;
}
52% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rn.is-demo .rn__fv--1 {
animation-name: rn-v1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v1 {
0%, 7.9% {
opacity: 0;
}
8% {
opacity: 1;
}
36% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rn.is-demo .rn__fv--2 {
animation-name: rn-v2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v2 {
0%, 35.9% {
opacity: 0;
}
36% {
opacity: 1;
}
52% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rn.is-demo .rn__caret {
animation-name: rn-caret;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-caret {
0%, 7.9% {
opacity: 0;
}
8% {
opacity: 1;
}
16% {
opacity: 0;
}
24% {
opacity: 1;
}
32% {
opacity: 0;
}
40% {
opacity: 1;
}
48% {
opacity: 0;
}
52%, 100% {
opacity: 0;
}
}
.rn.is-demo .rn__ok {
animation-name: rn-oka;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes rn-oka {
0%, 51.9% {
opacity: 0;
transform: scale(0.5);
}
56% {
opacity: 1;
transform: scale(1);
}
80% {
opacity: 1;
transform: scale(1);
}
92%, 100% {
opacity: 0;
transform: scale(0.5);
}
}
@media (max-width: 340px) {
.rn__row {
height: 22px;
font-size: 11px;
}
.rn__lab {
line-height: 22px;
}
.rn__field {
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.rn.is-demo .rn__lab, .rn.is-demo .rn__field, .rn.is-demo .rn__fv, .rn.is-demo .rn__caret, .rn.is-demo .rn__ok {
animation: none !important;
}
.rn.is-demo .rn__lab--1 {
opacity: 1;
}
.rn.is-demo .rn__field {
display: none;
}
}// 실제 동작: 두 번 누르거나 F2 로 그 자리에서 고치고, + 로 새 줄을 넣는다.
var root = document.querySelector('.rn');
var tree = document.getElementById('rn-tree');
function edit(li, startName) {
root.classList.remove('is-demo');
li.querySelector('.rn__stack').style.display = 'none';
var name = li.querySelector('.rn__name') || li.querySelector('.rn__stack');
var holder = document.createElement('span');
holder.className = 'rn__live';
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'rn__edit';
inp.value = startName != null ? startName : (li.getAttribute('aria-label') || 'notes.md');
inp.setAttribute('aria-label', 'File name');
holder.appendChild(inp);
li.querySelector('.rn__row').insertBefore(holder, li.querySelector('.rn__ok'));
inp.focus({ preventScroll: true });
inp.select();
function done(save) {
var v = inp.value.trim();
if (save && v) {
var stack = li.querySelector('.rn__stack');
if (stack) {
stack.remove();
var n = document.createElement('span');
n.className = 'rn__name';
n.textContent = v;
li.querySelector('.rn__row').insertBefore(n, holder);
li.setAttribute('aria-label', v);
} else {
var nm = li.querySelector('.rn__name');
if (nm) { nm.textContent = v; nm.style.visibility = ''; }
}
var ok = li.querySelector('.rn__ok');
if (ok) { ok.classList.add('is-flash'); setTimeout(function () { ok.classList.remove('is-flash'); }, 480); }
} else {
var nm2 = li.querySelector('.rn__name');
if (nm2) nm2.style.visibility = '';
var st2 = li.querySelector('.rn__stack');
if (st2) st2.style.display = '';
}
holder.remove();
tree.focus({ preventScroll: true });
}
inp.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { done(true); e.preventDefault(); }
else if (e.key === 'Escape') { done(false); e.preventDefault(); }
});
}
tree.querySelectorAll('.rn__item').forEach(function (li) {
li.querySelector('.rn__row').addEventListener('click', function () {
tree.setAttribute('aria-activedescendant', li.id);
});
li.querySelector('.rn__row').addEventListener('dblclick', function () {
if (li.classList.contains('is-edit')) { edit(li); return; }
var nm = li.querySelector('.rn__name');
if (nm) { edit(li, nm.textContent); nm.style.visibility = 'hidden'; }
});
});
tree.addEventListener('keydown', function (e) {
var li = document.getElementById(tree.getAttribute('aria-activedescendant'));
if ((e.key === 'F2' || e.key === 'Enter') && li) {
var nm = li.querySelector('.rn__name');
if (li.classList.contains('is-edit')) edit(li);
else if (nm) { edit(li, nm.textContent); nm.style.visibility = 'hidden'; }
e.preventDefault();
}
});
document.getElementById('rn-add').addEventListener('click', function () {
root.classList.remove('is-demo');
var li = document.createElement('li');
li.className = 'rn__item';
li.id = 'rn-new-' + Date.now();
li.setAttribute('role', 'treeitem');
li.setAttribute('aria-level', '1');
li.innerHTML = '<div class="rn__row"><span class="rn__ico"><svg viewBox="0 0 256 256" width="13" height="13"><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 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152"/></g></svg></span><span class="rn__name">new-file.md</span><span class="rn__ok"></span></div>';
tree.appendChild(li);
tree.setAttribute('aria-activedescendant', li.id);
li.querySelector('.rn__row').addEventListener('click', function () { tree.setAttribute('aria-activedescendant', li.id); });
edit(li, 'new-file.md');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InlineRenameTree.module.scss";
export type RenameNode = { id: string; name: string; kind?: "file" | "folder" };
export type InlineRenameTreeProps = {
title?: string;
addLabel?: string;
newName?: string;
nodes?: RenameNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
const DEFAULT_NODES: RenameNode[] = [
{ id: "rn-1", name: "src", kind: "folder" },
{ id: "rn-2", name: "notes.md" },
{ id: "rn-3", name: "app.js" },
{ id: "rn-4", name: "bundle.zip" },
];
function folderIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" 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 fileIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" 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 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152" />
</g>
</svg>
);
}
function plusIcon() {
return (
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
function okIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
export default function InlineRenameTree({
title = "프로젝트 파일",
addLabel = "파일 추가",
newName = "new-file.md",
nodes = DEFAULT_NODES,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: InlineRenameTreeProps) {
const [list, setList] = useState<RenameNode[]>(nodes);
const [at, setAt] = useState(nodes.length > 1 ? nodes[1].id : nodes[0].id);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [flash, setFlash] = useState<string | null>(null);
const treeRef = useRef<HTMLUListElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const seq = useRef<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 입력칸이 뜨면 그 자리로 포커스 + 기존 이름 전체선택
useEffect(() => {
if (!editing) return;
const input = inputRef.current;
if (!input) return;
input.focus({ preventScroll: true });
input.select();
}, [editing]);
// 저장 뒤 체크 표시 한 번 (vanilla 의 is-flash 480ms 와 같은 길이)
useEffect(() => {
if (!flash) return;
const t = window.setTimeout(() => setFlash(null), 480);
return () => window.clearTimeout(t);
}, [flash]);
function edit(id: string, name: string) {
setAt(id);
setDraft(name);
setEditing(id);
}
function done(save: boolean) {
const id = editing;
if (!id) return;
const v = draft.trim();
if (save && v) {
setList((ns) => ns.map((n) => (n.id === id ? { id: n.id, name: v, kind: n.kind } : n)));
setFlash(id);
}
setEditing(null);
treeRef.current?.focus({ preventScroll: true });
}
function onInputKeyDown(e: KeyEvt) {
if (e.key === "Enter") { done(true); e.preventDefault(); }
else if (e.key === "Escape") { done(false); e.preventDefault(); }
}
function onTreeKeyDown(e: KeyEvt) {
if (editing) return;
if (e.key === "F2" || e.key === "Enter") {
const n = list.filter((x) => x.id === at)[0];
if (n) edit(n.id, n.name);
e.preventDefault();
}
}
function add() {
const k = (seq.current || 0) + 1;
seq.current = k;
const id = `rn-new-${k}`;
setList((ns) => ns.concat([{ id, name: newName }]));
edit(id, newName);
}
return (
<div className={styles.rn} style={vars}>
<div className={styles.head}>
<span className={styles.title} id="rn-title">{title}</span>
<button className={styles.add} type="button" aria-label={addLabel} onClick={add}>
{plusIcon()}
<span>{addLabel}</span>
</button>
</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-labelledby="rn-title"
tabIndex={0}
aria-activedescendant={at}
onKeyDown={onTreeKeyDown}
>
{list.map((n) => (
<li key={n.id} id={n.id} className={styles.item} role="treeitem" aria-level={1} aria-label={n.name}>
<div
className={`${styles.row} ${at === n.id ? styles.isAt : ""}`}
onClick={() => setAt(n.id)}
onDoubleClick={() => edit(n.id, n.name)}
>
<span className={`${styles.ico} ${n.kind === "folder" ? styles.icoFolder : ""}`}>
{n.kind === "folder" ? folderIcon() : fileIcon()}
</span>
{editing === n.id ? (
<span className={styles.live}>
<input
ref={inputRef}
className={styles.edit}
type="text"
aria-label="파일 이름"
value={draft}
onChange={(e: ChangeEvt) => setDraft(e.target.value)}
onKeyDown={onInputKeyDown}
/>
</span>
) : (
<span className={styles.name}>{n.name}</span>
)}
<span className={`${styles.ok} ${flash === n.id ? styles.isFlash : ""}`} aria-hidden="true">
{okIcon()}
</span>
</div>
</li>
))}
</ul>
</div>
);
}08 Inline rename in place — Measured area 2.036% · moving frames 10/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


