파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바
파일 트리 만들기는 폴더와 문서가 겹겹이 들어간 목록을, 누르면 실제로 펼쳐지고 접히는 사이드바로 직접 짜는 일입니다. 여기 아홉 개는 모양만 비슷한 게 아니라 끌어 넣기, 이름 고치기, 검색으로 좁히기, 용량 보기까지 전부 실제로 되는 구성입니다.
01 filetree-expand-collapse
03 filetree-active-highlight
04 filetree-drag-into-folder
05 filetree-multi-select
06 filetree-search-filter
07 filetree-context-menu
08 filetree-inline-rename
09 filetree-size-bars
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 삼각형 화살표로 펼치는 기본 트리
- 02 형식별 파일 아이콘과 들여쓰기 줄
- 03 선택 하이라이트와 경로 표시
- 04 드래그로 폴더에 넣기
- 05 Shift·Ctrl 다중 선택
- 06 검색으로 남기는 트리
- 07 우클릭 컨텍스트 메뉴
- 08 제자리에서 이름 바꾸기
- 09 용량 막대가 붙은 트리
아홉 개의 순서는 화려함이 아니라 사람이 트리를 쓰게 되는 차례를 따릅니다. 먼저 뼈대가 열리고 닫히고(01), 열린 줄이 무엇인지 읽혀야 하고(02), 한 줄을 짚었을 때 어디 있는지 알려 줘야 합니다(03). 그다음은 파일을 옮기고(04), 여러 개를 한꺼번에 잡고(05), 많아지면 이름으로 좁히고(06), 파일에 할 일을 커서 가까이 두고(07), 이름을 그 자리에서 고칩니다(08). 마지막 09는 트리가 관리 도구가 되는 쪽으로, 줄마다 용량을 달아 정리 대상을 보여 줍니다. 아홉 개 전부 파일 트리 뜻 그대로 폴더와 파일의 층을 그리고, 외부 라이브러리·웹폰트·CDN 없이 두 파일로 서 있고, 목록마다 키보드 조작이 들어 있습니다 — 01·02·03은 화살표와 Home·End, 04·07은 화살표와 Enter·Escape, 05는 화살표와 Space, 06은 /와 Escape, 08은 F2, 09는 화살표와 Enter입니다. 트리가 비어 있을 때 보여 주는 화면은 빈 화면 디자인에, 파일이 아니라 숫자와 문자가 늘어설 때는 데이터 테이블 UI에 각각 대응합니다.
기법과 움직임의 크기는 자동 재생 화면을 24프레임으로 찍어 실제로 변한 픽셀 비율과 프레임 수를 직접 잰 값입니다.
| 번호 | 이름 | 핵심 기법 | 누적면적 | 움직인 프레임 |
|---|---|---|---|---|
| 01 | 삼각형 화살표로 펼치는 기본 트리 | 격자 트랙 0fr→1fr | 12.287% | 9/24 |
| 02 | 형식별 파일 아이콘과 들여쓰기 줄 | 줄 번호로 순차 등장 | 5.168% | 11/24 |
| 03 | 선택 하이라이트와 경로 표시 | 막대 하나를 translateY | 13.612% | 9/24 |
| 04 | 드래그로 폴더에 넣기 | 화면 좌표로 대상 판정 | 13.852% | 14/24 |
| 05 | Shift·Ctrl 다중 선택 | 기준점 변수 하나 | 16.023% | 8/24 |
| 06 | 검색으로 남기는 트리 | 줄을 실제로 빼고 형광 | 2.089% | 6/24 |
| 07 | 우클릭 컨텍스트 메뉴 | 좌표 클램프와 하이라이트 | 21.478% | 15/24 |
| 08 | 제자리에서 이름 바꾸기 | 이름 자리에 입력칸 삽입 | 2.036% | 11/24 |
| 09 | 용량 막대가 붙은 트리 | 가로 늘이기로 막대 키움 | 1.785% | 12/24 |
가장 크게 움직이는 07과 움직인 프레임이 가장 적은 06의 누적면적 차이는 열 배가 넘는데, 06은 가만히 있는 화면이 아니라 검색에 반응하는 쪽이 목적이라 그렇습니다.
01삼각형 화살표로 펼치는 기본 트리
폴더 트리 만들기에서 무엇이 보이나
폴더 줄의 삼각형을 누르면 자식 줄이 통째로 미끄러지듯 내려오며 실제로 열리고, 다시 누르면 접힙니다. 접는 방식은 자손의 높이를 매 프레임 재는 게 아니라 격자 트랙 자체를 0fr에서 1fr로 키우는 것이라 줄이 몇 개든 똑같이 부드럽습니다.

<main class="stage">
<div class="ft is-demo" id="ft">
<span class="ft__label" id="ft-label">my-app</span>
<ul class="ft__tree" id="ft-tree" role="tree" aria-labelledby="ft-label" tabindex="0" aria-activedescendant="ft-n1">
<li class="ft__item is-branch is-loop" id="ft-n1" role="treeitem" aria-level="1" aria-expanded="false">
<div class="ft__row" data-hit>
<span class="ft__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></span>
<span class="ft__ico" aria-hidden="true"><span class="ft__ic-f"><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="ft__ic-o"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2"/><path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/></g></svg></span></span>
<span class="ft__name">components</span>
</div>
<div class="ft__kids"><div class="ft__kwrap">
<ul role="group" aria-label="components">
<li class="ft__item is-leaf" id="ft-n2" role="treeitem" aria-level="2">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__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.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="ft__name">Button.tsx</span></div>
</li>
<li class="ft__item is-leaf" id="ft-n3" role="treeitem" aria-level="2">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__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.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="ft__name">Modal.tsx</span></div>
</li>
</ul>
</div></div>
</li>
<li class="ft__item is-branch" id="ft-n4" role="treeitem" aria-level="1" aria-expanded="false">
<div class="ft__row" data-hit>
<span class="ft__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></span>
<span class="ft__ico" aria-hidden="true"><span class="ft__ic-f"><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="ft__ic-o"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2"/><path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/></g></svg></span></span>
<span class="ft__name">assets</span>
</div>
<div class="ft__kids"><div class="ft__kwrap"><ul role="group" aria-label="assets"></ul></div></div>
</li>
<li class="ft__item is-leaf" id="ft-n5" role="treeitem" aria-level="1">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__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="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span><span class="ft__name">main.css</span></div>
</li>
<li class="ft__item is-leaf" id="ft-n6" role="treeitem" aria-level="1">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__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="ft__name">notes.md</span></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ft {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ft__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .6);
margin: 0 0 $sp-1;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0; padding: 0;
}
.ft__tree:focus-visible { outline: none; }
.ft__tree:focus-visible .ft__item.is-at > .ft__row { outline: 2px solid $color; outline-offset: -2px; }
.ft__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2 0 $sp-1;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover { background: rgba(255, 255, 255, .07); }
.ft__item.is-at > .ft__row { background: rgba(47, 109, 246, .22); }
.ft__caret {
display: inline-flex; width: 11px; flex: 0 0 auto;
color: rgba(255, 255, 255, .55);
transition: transform $duration $easing;
}
.ft__caret--blank { width: 11px; }
.ft__ico { display: inline-flex; flex: 0 0 auto; color: $color; }
.ft__ico > span { display: inline-flex; }
.ft__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
// 열림/닫힘 — 0fr→1fr 격자 트랙. 자손 막대기 애니메이션이 아니라 트랙 자체가 커진다
.ft__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.ft__kwrap { overflow: hidden; min-height: 0; }
.ft__kids ul { padding-left: $sp-6; }
.ft__item.is-open > .ft__kids { grid-template-rows: 1fr; }
.ft__item.is-open > .ft__row .ft__caret { transform: rotate(90deg); }
// 폴더 아이콘 교체는 컷 — 반투명로 겹치지 않는다
.ft__ic-o { display: none; }
.ft__item.is-open > .ft__row .ft__ic-f { display: none; }
.ft__item.is-open > .ft__row .ft__ic-o { display: inline-flex; }
// ── 자동 루프: 펼침 → 유지 → 접힘. 끝 프레임이 첫 프레임과 같다 ──
.ft.is-demo .ft__item.is-loop > .ft__kids {
animation-name: ft-kids;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ft-kids {
0%, 100% { grid-template-rows: 0fr; }
26%, 76% { grid-template-rows: 1fr; }
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret {
animation-name: ft-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ft-caret {
0%, 100% { transform: rotate(0deg); }
26%, 76% { transform: rotate(90deg); }
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f {
animation-name: ft-icf;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-icf { 0% { opacity: 1; } 26% { opacity: 0; } 76% { opacity: 1; } 100% { opacity: 1; } }
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o {
display: inline-flex;
animation-name: ft-ico;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-ico { 0% { opacity: 0; } 26% { opacity: 1; } 76% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.ft__row { height: 22px; font-size: 11px; }
.ft__kids ul { padding-left: $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__item.is-loop > .ft__kids,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o { animation: none !important; }
.ft__kids, .ft__caret { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ft {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ft__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 4px;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.ft__tree:focus-visible {
outline: none;
}
.ft__tree:focus-visible .ft__item.is-at > .ft__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ft__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 0 4px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover {
background: rgba(255, 255, 255, 0.07);
}
.ft__item.is-at > .ft__row {
background: rgba(47, 109, 246, 0.22);
}
.ft__caret {
display: inline-flex;
width: 11px;
flex: 0 0 auto;
color: rgba(255, 255, 255, 0.55);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ft__caret--blank {
width: 11px;
}
.ft__ico {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.ft__ico > span {
display: inline-flex;
}
.ft__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ft__kwrap {
overflow: hidden;
min-height: 0;
}
.ft__kids ul {
padding-left: 24px;
}
.ft__item.is-open > .ft__kids {
grid-template-rows: 1fr;
}
.ft__item.is-open > .ft__row .ft__caret {
transform: rotate(90deg);
}
.ft__ic-o {
display: none;
}
.ft__item.is-open > .ft__row .ft__ic-f {
display: none;
}
.ft__item.is-open > .ft__row .ft__ic-o {
display: inline-flex;
}
.ft.is-demo .ft__item.is-loop > .ft__kids {
animation-name: ft-kids;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes ft-kids {
0%, 100% {
grid-template-rows: 0fr;
}
26%, 76% {
grid-template-rows: 1fr;
}
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret {
animation-name: ft-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes ft-caret {
0%, 100% {
transform: rotate(0deg);
}
26%, 76% {
transform: rotate(90deg);
}
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f {
animation-name: ft-icf;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-icf {
0% {
opacity: 1;
}
26% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o {
display: inline-flex;
animation-name: ft-ico;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-ico {
0% {
opacity: 0;
}
26% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ft__row {
height: 22px;
font-size: 11px;
}
.ft__kids ul {
padding-left: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__item.is-loop > .ft__kids,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o {
animation: none !important;
}
.ft__kids, .ft__caret {
transition: none;
}
}// 실제 동작: 클릭·Enter 로 폴더를 열고 닫고, 화살표·Home·End 로 돌아다닌다.
var root = document.querySelector('.ft');
var tree = document.getElementById('ft-tree');
var items = [].slice.call(tree.querySelectorAll('li'));
var at = 0;
function visible(li) {
var p = li.parentElement;
while (p && p !== tree) {
if (p.getAttribute('role') === 'group') {
var host = p.closest('li');
if (!host.classList.contains('is-open') && !host.classList.contains('is-loop')) return false;
}
p = p.parentElement;
}
return true;
}
function list() { return items.filter(visible); }
function paint() {
items.forEach(function (li, i) {
li.classList.toggle('is-at', i === at);
if (i === at) tree.setAttribute('aria-activedescendant', li.id);
});
}
function toggle(li, on) {
root.classList.remove('is-demo');
li.classList.remove('is-loop');
li.classList.toggle('is-open', on);
li.setAttribute('aria-expanded', String(!!on && li.classList.contains('is-branch')));
}
items.forEach(function (li, i) {
var hit = li.querySelector('[data-hit]');
hit.addEventListener('click', function () { at = i; paint(); if (li.classList.contains('is-branch')) toggle(li, !li.classList.contains('is-open')); });
});
tree.addEventListener('keydown', function (e) {
var vis = list(), k = vis.indexOf(items[at]);
if (e.key === 'ArrowDown') { at = items.indexOf(vis[Math.min(k + 1, vis.length - 1)]); paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { at = items.indexOf(vis[Math.max(k - 1, 0)]); paint(); e.preventDefault(); }
else if (e.key === 'ArrowRight') {
if (items[at].classList.contains('is-branch') && !items[at].classList.contains('is-open')) toggle(items[at], true);
else if (k < vis.length - 1) { at = items.indexOf(vis[k + 1]); paint(); }
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
if (items[at].classList.contains('is-branch') && items[at].classList.contains('is-open')) toggle(items[at], false);
else { var host = items[at].closest('ul[role="group"]'); if (host) { at = items.indexOf(host.closest('li')); paint(); } }
e.preventDefault();
} else if (e.key === 'Home') { at = items.indexOf(vis[0]); paint(); e.preventDefault(); }
else if (e.key === 'End') { at = items.indexOf(vis[vis.length - 1]); paint(); e.preventDefault(); }
else if (e.key === 'Enter' || e.key === ' ') { if (items[at].classList.contains('is-branch')) toggle(items[at], !items[at].classList.contains('is-open')); e.preventDefault(); }
});
paint();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ExpandCollapseTree.module.scss";
export type TreeNode = { name: string; kids?: string[] };
export type ExpandCollapseTreeProps = {
label?: string;
nodes?: TreeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ClickEvt = { stopPropagation: () => void };
const DEFAULT_NODES: TreeNode[] = [
{ name: "components", kids: ["Button.tsx", "Modal.tsx"] },
{ name: "assets", kids: [] },
{ name: "main.css" },
{ name: "notes.md" },
];
type Row = { id: string; branch: boolean; owner: number; level: number };
// 아이콘은 vanilla 데모(01-filetree-expand-collapse/index.html)의 인라인 SVG 그대로다.
function Caret() {
return (
<svg viewBox="0 0 256 256" width="11" height="11">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
);
}
function FolderShut() {
return (
<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>
);
}
function FolderOpen() {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2" />
<path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z" />
</g>
</svg>
);
}
function FileTs() {
return (
<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.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 FileCss() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z" />
</g>
</svg>
);
}
function FileMd() {
return (
<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>
);
}
function fileIcon(name: string) {
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
if (ext === "css" || ext === "scss") return <FileCss />;
if (ext === "md") return <FileMd />;
return <FileTs />;
}
export default function ExpandCollapseTree({
label = "my-app",
nodes = DEFAULT_NODES,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ExpandCollapseTreeProps) {
const [open, setOpen] = useState<boolean[]>(() => nodes.map(() => false));
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const id = (i: number, j?: number) => (j === undefined ? `ect-n${i}` : `ect-n${i}-${j}`);
const isOpen = (i: number) => open[i] === true;
// 보이는 줄만 화살표로 돈다 — 닫힌 폴더의 자식은 건너뛴다
const rows: Row[] = [];
nodes.forEach((n, i) => {
rows.push({ id: id(i), branch: !!n.kids, owner: i, level: 1 });
const kids = n.kids;
if (kids && isOpen(i)) {
kids.forEach((_k, j) => rows.push({ id: id(i, j), branch: false, owner: i, level: 2 }));
}
});
const cur = at < rows.length ? at : rows.length - 1;
const active = rows[cur];
function setFold(i: number, on: boolean) {
setOpen((o) => o.map((v, k) => (k === i ? on : v)));
}
function hit(i: number, branch: boolean) {
const k = rows.findIndex((r) => r.id === id(i));
if (k >= 0) setAt(k);
if (branch) setFold(i, !isOpen(i));
if (treeRef.current) treeRef.current.focus({ preventScroll: true });
}
function hitKid(i: number, j: number) {
const k = rows.findIndex((r) => r.id === id(i, j));
if (k >= 0) setAt(k);
if (treeRef.current) treeRef.current.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
const r = active;
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, rows.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "ArrowRight") {
if (r && r.branch && !isOpen(r.owner)) setFold(r.owner, true);
else setAt(Math.min(cur + 1, rows.length - 1));
e.preventDefault();
} else if (e.key === "ArrowLeft") {
if (r && r.branch && isOpen(r.owner)) setFold(r.owner, false);
else if (r && r.level === 2) setAt(rows.findIndex((x) => x.id === id(r.owner)));
e.preventDefault();
} else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(rows.length - 1); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") {
if (r && r.branch) setFold(r.owner, !isOpen(r.owner));
e.preventDefault();
}
}
return (
<div className={styles.ft} style={vars}>
<span className={styles.label} id="ect-label">{label}</span>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-labelledby="ect-label"
tabIndex={0}
aria-activedescendant={active ? active.id : undefined}
onKeyDown={onKeyDown}
>
{nodes.map((n, i) => {
const kids = n.kids;
const opened = isOpen(i);
const cls = [
styles.item,
kids ? styles.branch : styles.leaf,
opened ? styles.isOpen : "",
active && active.id === id(i) ? styles.isAt : "",
].join(" ");
return (
<li key={n.name} id={id(i)} className={cls} role="treeitem" aria-level={1} aria-expanded={kids ? opened : undefined}>
<div className={styles.row} onClick={(e: ClickEvt) => { e.stopPropagation(); hit(i, !!kids); }}>
<span className={kids ? styles.caret : styles.caretBlank} aria-hidden="true">{kids ? <Caret /> : null}</span>
<span className={styles.ico} aria-hidden="true">{kids ? (opened ? <FolderOpen /> : <FolderShut />) : fileIcon(n.name)}</span>
<span className={styles.name}>{n.name}</span>
</div>
{kids ? (
<div className={styles.kids}>
<div className={styles.kwrap}>
<ul className={styles.group} role="group" aria-label={n.name}>
{kids.map((k, j) => (
<li
key={k}
id={id(i, j)}
className={`${styles.item} ${styles.leaf} ${active && active.id === id(i, j) ? styles.isAt : ""}`}
role="treeitem"
aria-level={2}
>
<div className={styles.row} onClick={(e: ClickEvt) => { e.stopPropagation(); hitKid(i, j); }}>
<span className={styles.caretBlank} aria-hidden="true" />
<span className={styles.ico} aria-hidden="true">{fileIcon(k)}</span>
<span className={styles.name}>{k}</span>
</div>
</li>
))}
</ul>
</div>
</div>
) : null}
</li>
);
})}
</ul>
</div>
);
}01 · 삼각형 화살표로 펼치는 기본 트리 — 측정 누적면적 12.287% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
문서·프로젝트·카테고리처럼 겹겹이 들어간 목록을 사이드바에 보여 줄 때 가장 먼저 깔리는 뼈대입니다.
// 열림/닫힘 — 0fr→1fr 격자 트랙. 자손 막대기 애니메이션이 아니라 트랙 자체가 커진다
.ft__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.ft__kwrap { overflow: hidden; min-height: 0; }
.ft__kids ul { padding-left: $sp-6; }
.ft__item.is-open > .ft__kids { grid-template-rows: 1fr; }
.ft__item.is-open > .ft__row .ft__caret { transform: rotate(90deg); }
// 폴더 아이콘 교체는 컷 — 반투명로 겹치지 않는다
.ft__ic-o { display: none; }
.ft__item.is-open > .ft__row .ft__ic-f { display: none; }
.ft__item.is-open > .ft__row .ft__ic-o { display: inline-flex; }
02형식별 파일 아이콘과 들여쓰기 줄
화면에서 일어나는 일
파일마다 종류에 맞는 아이콘이 붙고 층이 깊어질수록 세로 들여쓰기 줄이 그어져 어느 묶음인지 한눈에 보입니다. 줄이 처음 떠오를 때는 위쪽 줄부터 순서대로 나타나는데, HTML에서 줄마다 style="--i: 0"처럼 차례 번호를 박아 두면 이 규칙 하나로 전체가 차례를 지킵니다.
<main class="stage">
<div class="ft is-demo" id="ft">
<ul class="ft__tree" id="ft-tree" role="tree" aria-label="files" tabindex="0" aria-activedescendant="ft-n1">
<li class="ft__item is-branch" id="ft-n1" role="treeitem" aria-level="1" aria-expanded="true">
<div class="ft__row" style="--i: 0" data-hit>
<span class="ft__ico ft__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="ft__name">portfolio</span>
<span class="ft__kind" aria-hidden="true">폴더</span>
</div>
<div class="ft__kids"><div class="ft__kwrap">
<ul class="ft__lv2" role="group" aria-label="portfolio">
<li class="ft__item is-leaf" id="ft-n2" role="treeitem" aria-level="2">
<div class="ft__row" style="--i: 1" data-hit>
<span class="ft__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="M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="ft__name">resume.pdf</span>
<span class="ft__kind">PDF</span>
</div>
</li>
<li class="ft__item is-branch" id="ft-n3" role="treeitem" aria-level="2" aria-expanded="true">
<div class="ft__row" style="--i: 2" data-hit>
<span class="ft__ico ft__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="ft__name">site</span>
<span class="ft__kind" aria-hidden="true">폴더</span>
</div>
<div class="ft__kids"><div class="ft__kwrap">
<ul class="ft__lv3" role="group" aria-label="site">
<li class="ft__item is-leaf" id="ft-n4" role="treeitem" aria-level="3">
<div class="ft__row" style="--i: 3" data-hit>
<span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="ft__name">cover.jpg</span>
<span class="ft__kind">JPG</span>
</div>
</li>
</ul>
</div></div>
</li>
</ul>
</div></div>
</li>
<li class="ft__item is-leaf" id="ft-n5" role="treeitem" aria-level="1">
<div class="ft__row" style="--i: 4" data-hit>
<span class="ft__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="ft__name">main.js</span>
<span class="ft__kind">JS</span>
</div>
</li>
<li class="ft__item is-leaf" id="ft-n6" role="treeitem" aria-level="1">
<div class="ft__row" style="--i: 5" data-hit>
<span class="ft__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="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span>
<span class="ft__name">style.css</span>
<span class="ft__kind">CSS</span>
</div>
</li>
<li class="ft__item is-leaf" id="ft-n7" role="treeitem" aria-level="1">
<div class="ft__row" style="--i: 6" data-hit>
<span class="ft__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="ft__name">bundle.zip</span>
<span class="ft__kind">ZIP</span>
</div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-ink;
@include stage($stage-yellow);
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0; padding: 0;
}
.ft__tree:focus-visible { outline: none; }
.ft__tree:focus-visible .ft__item.is-at > .ft__row { outline: 2px solid $subject-blue; outline-offset: -2px; }
.ft__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover { background: rgba(23, 20, 26, .07); }
.ft__item.is-at > .ft__row { background: rgba(23, 20, 26, .12); }
.ft__ico { display: inline-flex; flex: 0 0 auto; color: $color; }
.ft__ico--folder { color: $subject-blue; }
.ft__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ft__kind {
margin-left: auto;
flex: 0 0 auto;
font-size: 9px; line-height: 12px; font-weight: 700;
letter-spacing: .06em;
color: rgba(23, 20, 26, .72);
}
// 들여쓰기 가이드라인 — 줄마다 실제 세로 줄이 그어진다
.ft__kids {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows $duration $easing;
}
.ft__kwrap { overflow: hidden; min-height: 0; }
.ft__lv2, .ft__lv3 {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid rgba(23, 20, 26, .22);
transform-origin: top center; // 규칙8: 가이드선 위에서 그어짐
}
.ft__lv3 { border-left-color: rgba(23, 20, 26, .14); }
.ft__item.is-branch:not(.is-open) > .ft__kids { grid-template-rows: 0fr; }
// ── 자동 루프: 가이드 줄이 그어지고 줄이 차례로 떠오른다 ──
.ft.is-demo .ft__lv2 {
animation-name: ft-guide;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes ft-guide {
0% { transform: scaleY(0); }
14%, 84% { transform: scaleY(1); }
96%, 100% { transform: scaleY(0); }
}
.ft.is-demo .ft__lv3 {
animation-name: ft-guide;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
}
.ft.is-demo .ft__row {
animation-name: ft-rise;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: calc(60ms + var(--i) * 80ms);
}
@keyframes ft-rise {
0% { opacity: 0; transform: translateX(-6px); }
9% { opacity: 1; transform: translateX(0); }
84% { opacity: 1; transform: translateX(0); }
95%, 100% { opacity: 0; transform: translateX(-6px); }
}
@media (max-width: 340px) {
.ft__row { height: 20px; font-size: 11px; }
.ft__kind { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__row,
.ft.is-demo .ft__lv2,
.ft.is-demo .ft__lv3 { animation: none !important; }
.ft__kids { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.ft__tree:focus-visible {
outline: none;
}
.ft__tree:focus-visible .ft__item.is-at > .ft__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ft__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover {
background: rgba(23, 20, 26, 0.07);
}
.ft__item.is-at > .ft__row {
background: rgba(23, 20, 26, 0.12);
}
.ft__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.ft__ico--folder {
color: #2f6df6;
}
.ft__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__kind {
margin-left: auto;
flex: 0 0 auto;
font-size: 9px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(23, 20, 26, 0.72);
}
.ft__kids {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ft__kwrap {
overflow: hidden;
min-height: 0;
}
.ft__lv2, .ft__lv3 {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid rgba(23, 20, 26, 0.22);
transform-origin: top center;
}
.ft__lv3 {
border-left-color: rgba(23, 20, 26, 0.14);
}
.ft__item.is-branch:not(.is-open) > .ft__kids {
grid-template-rows: 0fr;
}
.ft.is-demo .ft__lv2 {
animation-name: ft-guide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes ft-guide {
0% {
transform: scaleY(0);
}
14%, 84% {
transform: scaleY(1);
}
96%, 100% {
transform: scaleY(0);
}
}
.ft.is-demo .ft__lv3 {
animation-name: ft-guide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
}
.ft.is-demo .ft__row {
animation-name: ft-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: calc(60ms + var(--i) * 80ms);
}
@keyframes ft-rise {
0% {
opacity: 0;
transform: translateX(-6px);
}
9% {
opacity: 1;
transform: translateX(0);
}
84% {
opacity: 1;
transform: translateX(0);
}
95%, 100% {
opacity: 0;
transform: translateX(-6px);
}
}
@media (max-width: 340px) {
.ft__row {
height: 20px;
font-size: 11px;
}
.ft__kind {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__row,
.ft.is-demo .ft__lv2,
.ft.is-demo .ft__lv3 {
animation: none !important;
}
.ft__kids {
transition: none;
}
}// 실제 동작: 폴더를 누르면 접히고, 화살표로 줄을 돌아다닌다.
var root = document.querySelector('.ft');
var tree = document.getElementById('ft-tree');
var items = [].slice.call(tree.querySelectorAll('li'));
var at = 0;
function visible(li) {
var p = li.parentElement;
while (p && p !== tree) {
if (p.getAttribute('role') === 'group') {
if (!p.closest('li').classList.contains('is-open')) return false;
}
p = p.parentElement;
}
return true;
}
function paint() {
items.forEach(function (li, i) {
li.classList.toggle('is-at', i === at);
if (i === at) tree.setAttribute('aria-activedescendant', li.id);
});
}
items.forEach(function (li, i) {
li.querySelector('[data-hit]').addEventListener('click', function () {
at = i; paint();
if (!li.classList.contains('is-branch')) return;
root.classList.remove('is-demo');
var on = !li.classList.contains('is-open');
li.classList.toggle('is-open', on);
li.setAttribute('aria-expanded', String(on));
});
});
tree.addEventListener('keydown', function (e) {
var vis = items.filter(visible), k = vis.indexOf(items[at]);
if (e.key === 'ArrowDown' && k < vis.length - 1) { at = items.indexOf(vis[k + 1]); paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp' && k > 0) { at = items.indexOf(vis[k - 1]); paint(); e.preventDefault(); }
else if (e.key === 'Home') { at = items.indexOf(vis[0]); paint(); e.preventDefault(); }
else if (e.key === 'End') { at = items.indexOf(vis[vis.length - 1]); paint(); e.preventDefault(); }
});
paint();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TypeIconTree.module.scss";
export type TypeNode = { name: string; kind?: string; kids?: TypeNode[] };
export type TypeIconTreeProps = {
treeLabel?: string;
nodes?: TypeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ClickEvt = { stopPropagation: () => void };
const DEFAULT_NODES: TypeNode[] = [
{
name: "portfolio",
kind: "폴더",
kids: [
{ name: "resume.pdf", kind: "PDF" },
{ name: "site", kind: "폴더", kids: [{ name: "cover.jpg", kind: "JPG" }] },
],
},
{ name: "main.js", kind: "JS" },
{ name: "style.css", kind: "CSS" },
{ name: "bundle.zip", kind: "ZIP" },
];
// 아이콘은 vanilla 데모(02-filetree-icon-by-type/index.html)의 인라인 SVG 그대로다.
function IcoFolder() {
return (
<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>
);
}
function IcoPdf() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z" />
</g>
</svg>
);
}
function IcoImg() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
function IcoJs() {
return (
<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>
);
}
function IcoCss() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z" />
</g>
</svg>
);
}
function IcoZip() {
return (
<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>
);
}
function extOf(name: string) {
return name.slice(name.lastIndexOf(".") + 1).toLowerCase();
}
function iconFor(name: string) {
const ext = extOf(name);
if (ext === "pdf") return <IcoPdf />;
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "webp" || ext === "gif") return <IcoImg />;
if (ext === "css" || ext === "scss") return <IcoCss />;
if (ext === "zip" || ext === "tar" || ext === "gz") return <IcoZip />;
return <IcoJs />;
}
type Flat = { id: string; folder: boolean };
export default function TypeIconTree({
treeLabel = "files",
nodes = DEFAULT_NODES,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TypeIconTreeProps) {
// 기본은 펼침 — 닫은 줄만 shut 에 기록한다 (vanilla 의 aria-expanded="true" 시작과 같다)
const [shut, setShut] = useState<Record<string, boolean>>({});
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const opened = (id: string) => shut[id] !== true;
const flat: Flat[] = [];
function walk(list: TypeNode[], path: string) {
list.forEach((n, i) => {
const id = `${path}-${i}`;
const kids = n.kids;
flat.push({ id, folder: !!kids });
if (kids && opened(id)) walk(kids, id);
});
}
walk(nodes, "tit");
const cur = at < flat.length ? at : flat.length - 1;
const active = flat[cur];
function pick(id: string, folder: boolean) {
const k = flat.findIndex((f) => f.id === id);
if (k >= 0) setAt(k);
if (folder) setShut((s) => ({ ...s, [id]: opened(id) }));
if (treeRef.current) treeRef.current.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, flat.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(flat.length - 1); e.preventDefault(); }
}
// 반환형을 적어둔다 — 재귀 함수라 tsc 가 추론을 순환으로 본다(TS7023)
function branch(list: TypeNode[], path: string, level: number): any[] {
return list.map((n, i) => {
const id = `${path}-${i}`;
const kids = n.kids;
const on = opened(id);
const kind = n.kind || extOf(n.name).toUpperCase();
const cls = [
styles.item,
kids ? styles.isBranch : styles.isLeaf,
kids && !on ? styles.isShut : "",
active && active.id === id ? styles.isAt : "",
].join(" ");
return (
<li key={id} id={id} className={cls} role="treeitem" aria-level={level} aria-expanded={kids ? on : undefined}>
<div className={styles.row} onClick={(e: ClickEvt) => { e.stopPropagation(); pick(id, !!kids); }}>
<span className={kids ? `${styles.ico} ${styles.icoFolder}` : styles.ico} aria-hidden="true">
{kids ? <IcoFolder /> : iconFor(n.name)}
</span>
<span className={styles.name}>{n.name}</span>
<span className={styles.kind} aria-hidden={kids ? true : undefined}>{kind}</span>
</div>
{kids ? (
<div className={styles.kids}>
<div className={styles.kwrap}>
<ul className={level === 1 ? styles.lv2 : styles.lv3} role="group" aria-label={n.name}>
{branch(kids, id, level + 1)}
</ul>
</div>
</div>
) : null}
</li>
);
});
}
return (
<div className={styles.ft} style={vars}>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={treeLabel}
tabIndex={0}
aria-activedescendant={active ? active.id : undefined}
onKeyDown={onKeyDown}
>
{branch(nodes, "tit", 1)}
</ul>
</div>
);
}02 · 형식별 파일 아이콘과 들여쓰기 줄 — 측정 누적면적 5.169% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
같은 이름의 파일이 많거나 폴더가 세 겹 이상 겹쳐 계층이 안 읽힐 때 씁니다.
.ft__lv2, .ft__lv3 {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid rgba(23, 20, 26, .22);
transform-origin: top center;
}
.ft__lv3 { border-left-color: rgba(23, 20, 26, .14); }
.ft.is-demo .ft__row {
animation-name: ft-rise;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: calc(60ms + var(--i) * 80ms);
}
@keyframes ft-rise {
0% { opacity: 0; transform: translateX(-6px); }
9% { opacity: 1; transform: translateX(0); }
84% { opacity: 1; transform: translateX(0); }
95%, 100% { opacity: 0; transform: translateX(-6px); }
}
03선택 하이라이트와 경로 표시
어떤 움직임인가
고른 줄에만 색 막대가 얹히고, 막대는 줄 사이를 미끄러지듯 옮겨 가며, 위쪽 경로 줄에 지금 고른 파일의 위치가 이어 붙습니다. 막대를 줄마다 하나씩 새로 칠하는 대신 하나만 만들어 옮기기 때문에 칠해지는 면적이 아니라 위치만 바뀌고, 이동 거리는 화면에서 줄 높이를 직접 재어 계산합니다.

<main class="stage">
<div class="ft is-demo" id="ft">
<div class="ft__crumbs" aria-hidden="true">
<span class="ft__crumb" id="ft-real" hidden>portfolio</span>
<span class="ft__cstack">
<span class="ft__cs ft__cs--1">portfolio</span>
<span class="ft__cs ft__cs--2">portfolio / site</span>
<span class="ft__cs ft__cs--3">portfolio / site / <b>cover.jpg</b></span>
</span>
</div>
<ul class="ft__tree" id="ft-tree" role="tree" aria-label="portfolio" tabindex="0" aria-activedescendant="ft-n1" aria-describedby="ft-real">
<li class="ft__item" id="ft-n1" role="treeitem" aria-level="1" data-path="portfolio">
<div class="ft__row" data-hit><span class="ft__ico" 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="ft__name">portfolio</span></div>
</li>
<li class="ft__item" id="ft-n2" role="treeitem" aria-level="2" data-path="portfolio / site">
<div class="ft__row" data-hit><span class="ft__ico" 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="ft__name">site</span></div>
</li>
<li class="ft__item" id="ft-n3" role="treeitem" aria-level="3" data-path="portfolio / site / cover.jpg">
<div class="ft__row" data-hit><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="ft__name">cover.jpg</span></div>
</li>
<li class="ft__item" id="ft-n4" role="treeitem" aria-level="3" data-path="portfolio / site / main.js">
<div class="ft__row" data-hit><span class="ft__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="ft__name">main.js</span></div>
</li>
<li class="ft__item" id="ft-n5" role="treeitem" aria-level="3" data-path="portfolio / site / style.css">
<div class="ft__row" data-hit><span class="ft__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="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span><span class="ft__name">style.css</span></div>
</li>
<li class="ft__bar" aria-hidden="true"></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink; // 주황 무대 위 흰 글자는 3.32:1 — 잉크 5.50:1
}
.ft__crumbs {
position: relative;
box-sizing: border-box;
height: 22px;
margin: 0 0 $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .26);
display: flex; align-items: center;
overflow: hidden;
}
.ft__crumb, .ft__cs {
font-size: 11px; line-height: 22px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ft__cstack { position: relative; display: block; width: 100%; height: 22px; }
.ft__cs {
position: absolute; inset: 0;
opacity: 0;
}
.ft__cs b { color: $stage-yellow; font-weight: 800; }
.ft__tree {
position: relative;
list-style: none;
margin: 0; padding: 0;
}
.ft__tree:focus-visible { outline: none; }
.ft__tree:focus-visible .ft__item[aria-selected="true"] .ft__row { outline: 2px solid $subject-ink; outline-offset: -2px; }
.ft__row {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
position: relative;
z-index: 1;
}
.ft__ico { display: inline-flex; flex: 0 0 auto; color: $subject-ink; }
.ft__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
// 하이라이트 막대 — 줄마다 배경을 칠하는 대신 하나가 미끄러진다
.ft__bar {
position: absolute;
left: 0; right: 0; top: 0;
height: 24px;
border-left: 3px solid $color;
border-radius: $r-xs;
background: rgba(255, 255, 255, .2);
z-index: 0;
transition: transform $duration $easing;
}
// ── 자동 루프: 막대가 내려가고 경로가 컷으로 바뀐다 ──
.ft.is-demo .ft__bar {
animation-name: hl-bar;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hl-bar {
0%, 20% { transform: translateY(0); }
28%, 48% { transform: translateY(24px); }
56%, 84% { transform: translateY(48px); }
96%, 100% { transform: translateY(0); }
}
.ft.is-demo .ft__cs--1 {
animation-name: cs-1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-1 { 0% { opacity: 1; } 28% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
.ft.is-demo .ft__cs--2 {
animation-name: cs-2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-2 { 0% { opacity: 0; } 28% { opacity: 1; } 56% { opacity: 0; } 100% { opacity: 0; } }
.ft.is-demo .ft__cs--3 {
animation-name: cs-3;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-3 { 0% { opacity: 0; } 56% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.ft__row { height: 22px; font-size: 11px; }
.ft__bar { height: 22px; }
@keyframes hl-bar {
0%, 20% { transform: translateY(0); }
28%, 48% { transform: translateY(22px); }
56%, 84% { transform: translateY(44px); }
96%, 100% { transform: translateY(0); }
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__bar, .ft.is-demo .ft__cs { animation: none !important; }
.ft.is-demo .ft__cs--1 { opacity: 1; }
.ft__bar { transition: 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);
}
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ft__crumbs {
position: relative;
box-sizing: border-box;
height: 22px;
margin: 0 0 8px;
padding: 0 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.26);
display: flex;
align-items: center;
overflow: hidden;
}
.ft__crumb, .ft__cs {
font-size: 11px;
line-height: 22px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__cstack {
position: relative;
display: block;
width: 100%;
height: 22px;
}
.ft__cs {
position: absolute;
inset: 0;
opacity: 0;
}
.ft__cs b {
color: #ffd23f;
font-weight: 800;
}
.ft__tree {
position: relative;
list-style: none;
margin: 0;
padding: 0;
}
.ft__tree:focus-visible {
outline: none;
}
.ft__tree:focus-visible .ft__item[aria-selected=true] .ft__row {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.ft__row {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
position: relative;
z-index: 1;
}
.ft__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.ft__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__bar {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 24px;
border-left: 3px solid #2f6df6;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
z-index: 0;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ft.is-demo .ft__bar {
animation-name: hl-bar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hl-bar {
0%, 20% {
transform: translateY(0);
}
28%, 48% {
transform: translateY(24px);
}
56%, 84% {
transform: translateY(48px);
}
96%, 100% {
transform: translateY(0);
}
}
.ft.is-demo .ft__cs--1 {
animation-name: cs-1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-1 {
0% {
opacity: 1;
}
28% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ft.is-demo .ft__cs--2 {
animation-name: cs-2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-2 {
0% {
opacity: 0;
}
28% {
opacity: 1;
}
56% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ft.is-demo .ft__cs--3 {
animation-name: cs-3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-3 {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ft__row {
height: 22px;
font-size: 11px;
}
.ft__bar {
height: 22px;
}
@keyframes hl-bar {
0%, 20% {
transform: translateY(0);
}
28%, 48% {
transform: translateY(22px);
}
56%, 84% {
transform: translateY(44px);
}
96%, 100% {
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__bar, .ft.is-demo .ft__cs {
animation: none !important;
}
.ft.is-demo .ft__cs--1 {
opacity: 1;
}
.ft__bar {
transition: none;
}
}// 실제 동작: 줄을 고르면 막대가 그 행으로 미끄러지고 경로가 다시 짜인다.
var root = document.querySelector('.ft');
var tree = document.getElementById('ft-tree');
var bar = document.querySelector('.ft__bar');
var real = document.getElementById('ft-real');
var stack = document.querySelector('.ft__cstack');
var items = [].slice.call(tree.querySelectorAll('.ft__item'));
var at = 0;
function rowH() { return items[0].querySelector('.ft__row').offsetHeight || 24; }
function pick(i) {
root.classList.remove('is-demo');
stack.hidden = true;
real.hidden = false;
at = i;
bar.style.transform = 'translateY(' + (at * rowH()) + 'px)';
real.textContent = items[at].dataset.path;
items.forEach(function (li, k) { li.setAttribute('aria-selected', String(k === at)); });
tree.setAttribute('aria-activedescendant', items[at].id);
}
items.forEach(function (li, i) {
li.querySelector('[data-hit]').addEventListener('click', function () { pick(i); });
});
tree.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { pick(Math.min(at + 1, items.length - 1)); e.preventDefault(); }
else if (e.key === 'ArrowUp') { pick(Math.max(at - 1, 0)); e.preventDefault(); }
else if (e.key === 'Home') { pick(0); e.preventDefault(); }
else if (e.key === 'End') { pick(items.length - 1); e.preventDefault(); }
});
items[0].setAttribute('aria-selected', 'true');import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ActiveHighlightTree.module.scss";
export type TreeKind = "folder" | "image" | "js" | "css";
export type TreeNode = { name: string; path: string; level: number; kind: TreeKind };
export type ActiveHighlightTreeProps = {
label?: string;
nodes?: TreeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--at"?: string;
};
type KeyEvt = { key: string; preventDefault: () => void };
function Icon({ kind }: { kind: TreeKind }) {
if (kind === "folder") {
return (
<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>
);
}
if (kind === "image") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
if (kind === "js") {
return (
<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>
);
}
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z" />
</g>
</svg>
);
}
const DEFAULT_NODES: TreeNode[] = [
{ name: "portfolio", path: "portfolio", level: 1, kind: "folder" },
{ name: "site", path: "portfolio / site", level: 2, kind: "folder" },
{ name: "cover.jpg", path: "portfolio / site / cover.jpg", level: 3, kind: "image" },
{ name: "main.js", path: "portfolio / site / main.js", level: 3, kind: "js" },
{ name: "style.css", path: "portfolio / site / style.css", level: 3, kind: "css" },
];
export default function ActiveHighlightTree({
label = "portfolio",
nodes = DEFAULT_NODES,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ActiveHighlightTreeProps) {
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const last = nodes.length - 1;
const cur = Math.max(0, Math.min(at, last));
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--at": String(cur),
};
const segs = (nodes[cur] ? nodes[cur].path : "").split(" / ");
const head = segs.slice(0, -1).join(" / ");
const tail = segs[segs.length - 1];
function pick(i: number) {
setAt(Math.max(0, Math.min(i, last)));
treeRef.current?.focus({ preventScroll: true });
}
return (
<div className={styles.root} style={vars}>
<div className={styles.crumbs}>
<span className={styles.crumb} id="aht-crumb">
{head ? `${head} / ` : ""}
<b>{tail}</b>
</span>
</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={`aht-n${cur}`}
aria-describedby="aht-crumb"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { pick(cur + 1); e.preventDefault(); }
else if (e.key === "ArrowUp") { pick(cur - 1); e.preventDefault(); }
else if (e.key === "Home") { pick(0); e.preventDefault(); }
else if (e.key === "End") { pick(last); e.preventDefault(); }
}}
>
{nodes.map((n, i) => (
<li
key={n.path}
id={`aht-n${i}`}
className={styles.item}
role="treeitem"
aria-level={n.level}
aria-selected={i === cur}
onClick={() => pick(i)}
>
<div className={styles.row}>
<span className={styles.ico} aria-hidden="true"><Icon kind={n.kind} /></span>
<span className={styles.name}>{n.name}</span>
</div>
</li>
))}
<li className={styles.bar} aria-hidden="true" />
</ul>
</div>
);
}03 · 선택 하이라이트와 경로 표시 — 측정 누적면적 13.687% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
트리가 길어져 지금 연 파일이 어느 폴더 안인지 놓치기 쉬운 편집 화면이나 자료실 사이드바에 맞습니다.
function rowH() { return items[0].querySelector('.ft__row').offsetHeight || 24; }
function pick(i) {
root.classList.remove('is-demo');
stack.hidden = true;
real.hidden = false;
at = i;
bar.style.transform = 'translateY(' + (at * rowH()) + 'px)';
real.textContent = items[at].dataset.path;
items.forEach(function (li, k) { li.setAttribute('aria-selected', String(k === at)); });
tree.setAttribute('aria-activedescendant', items[at].id);
}
.ft__bar {
position: absolute;
left: 0; right: 0; top: 0;
height: 24px;
border-left: 3px solid $color;
border-radius: $r-xs;
background: rgba(255, 255, 255, .2);
z-index: 0;
transition: transform $duration $easing;
}
04드래그로 폴더에 넣기
무엇이 보이나
파일 줄을 끌어 폴더 위에 대면 폴더가 테두리와 배경으로 반응하고, 놓는 순간 파일이 그 폴더 안으로 실제로 이동합니다. 끌기는 손가락을 따라다니는 유령 조각으로 보여 주고 대상은 화면 좌표로 판정하며, 자식이 생기는 순간 폴더가 저절로 열리는 규칙은 .dg__f:has(.dg__kids li) 한 줄로 처리했습니다.

<main class="stage">
<div class="dg is-demo" id="dg">
<ul class="dg__list" id="dg-list" role="tree" aria-label="files" tabindex="0" aria-activedescendant="dg-f1">
<li class="dg__f" id="dg-f1" role="treeitem" aria-level="1" aria-expanded="false">
<div class="dg__row dg__row--f" data-hit>
<span class="dg__ico dg__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="dg__name">uploads</span>
<span class="dg__n" aria-label="0개">0</span>
</div>
<div class="dg__kids"><div class="dg__kwrap"><ul role="group" aria-label="uploads"></ul></div></div>
</li>
<li class="dg__f" id="dg-f2" role="treeitem" aria-level="1" aria-expanded="false">
<div class="dg__row dg__row--f" data-hit>
<span class="dg__ico dg__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="dg__name">archive</span>
<span class="dg__n" aria-label="0개">0</span>
</div>
<div class="dg__kids"><div class="dg__kwrap"><ul role="group" aria-label="archive"></ul></div></div>
</li>
<li class="dg__i" id="dg-i1" role="treeitem" aria-level="1" aria-label="photo-01.jpg 끌어 폴더로 이동">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">photo-01.jpg</span>
</div>
</li>
<li class="dg__i" id="dg-i2" role="treeitem" aria-level="1" aria-label="report.pdf 끌어 폴더로 이동">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__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="M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">report.pdf</span>
</div>
</li>
<li class="dg__i" id="dg-i3" role="treeitem" aria-level="1" aria-label="data.zip 끌어 폴더로 이동">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__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="dg__name">data.zip</span>
</div>
</li>
</ul>
<div class="dg__after" aria-hidden="true">
<ul>
<li><div class="dg__row dg__row--f dg__row--flash">
<span class="dg__ico dg__ico--folder"><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="dg__name">uploads</span>
<span class="dg__n dg__n--on">1</span>
</div>
<ul class="dg__inkids"><li><div class="dg__row dg__row--in">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">photo-01.jpg</span>
</div></li></ul></li>
<li><div class="dg__row dg__row--f">
<span class="dg__ico dg__ico--folder"><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="dg__name">archive</span>
<span class="dg__n">0</span>
</div></li>
<li><div class="dg__row">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">report.pdf</span>
</div></li>
<li><div class="dg__row">
<span class="dg__ico"><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="dg__name">data.zip</span>
</div></li>
</ul>
</div>
<div class="dg__ghost" aria-hidden="true">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
photo-01.jpg
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dg {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.dg__list, .dg__list ul, .dg__after ul {
list-style: none;
margin: 0; padding: 0;
}
.dg__list:focus-visible { outline: none; }
.dg__list li[aria-selected="true"] > .dg__row { outline: 2px solid $color; outline-offset: -2px; }
.dg__list:focus-visible .dg__row { outline: none; }
.dg__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border: 1px solid rgba(23, 20, 26, .1);
border-radius: $r-xs;
background: #fff;
font-size: 12px; font-weight: 600;
cursor: grab;
user-select: none;
transition: border-color $duration $easing, background $duration $easing, box-shadow $duration $easing;
}
.dg__row--f { cursor: pointer; }
.dg__row:hover { border-color: rgba(23, 20, 26, .24); }
.dg__ico { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.dg__ico--folder { color: $color; }
.dg__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dg__n {
margin-left: auto;
flex: 0 0 auto;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
font-size: 10px; line-height: 16px; font-weight: 800;
color: rgba(23, 20, 26, .72);
}
.dg__n--on { background: $color; color: #fff; }
// 폴더가 드롭 대상으로 반응하는 순간
.dg__row.is-hot {
border-color: $color;
background: rgba(47, 109, 246, .08);
box-shadow: 0 0 0 2px rgba(47, 109, 246, .25);
}
.dg__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.dg__kwrap { overflow: hidden; min-height: 0; }
.dg__f:has(.dg__kids li) > .dg__kids { grid-template-rows: 1fr; }
.dg__kids .dg__row { margin-left: $sp-4; }
.dg__row.is-in { animation-name: dg-in; animation-duration: 320ms; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
@keyframes dg-in { 0% { transform: scale(.94); background: rgba(47, 109, 246, .16); } 100% { transform: scale(1); background: #fff; } }
// 루프용 결과 상태 — 실제 목록 위에 겹쳐 놓는다
.dg:not(.is-demo) .dg__after, .dg:not(.is-demo) .dg__ghost { display: none; }
.dg__after {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.dg__inkids { padding-left: $sp-4; }
.dg__row--in { margin-bottom: $sp-1; }
.dg__row--flash { border-color: $color; background: rgba(47, 109, 246, .08); }
// 끌리는 파일 조각
.dg__ghost {
position: absolute;
left: 24px; top: 65px;
display: inline-flex; align-items: center; gap: $sp-1;
height: 24px;
padding: 0 $sp-2;
border-radius: $r-xs;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
box-shadow: $shadow-raised;
font-size: 11px; font-weight: 700;
color: $stage-ink;
opacity: 0;
pointer-events: none;
z-index: 2;
}
.dg__ghost.is-live { position: fixed; opacity: 1; }
// ── 자동 루프: 조각이 떠올라 폴더로 들어가고 목록이 컷으로 바뀐다 ──
.dg.is-demo .dg__list {
animation-name: dg-list;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-list {
0% { opacity: 0; }
4% { opacity: 1; }
39.9% { opacity: 1; }
40% { opacity: 0; }
100% { opacity: 0; }
}
.dg.is-demo .dg__after {
animation-name: dg-after;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-after {
0% { opacity: 0; }
39.9% { opacity: 0; }
40% { opacity: 1; }
82% { opacity: 1; }
94%, 100% { opacity: 0; }
}
.dg.is-demo .dg__ghost {
animation-name: dg-ghost;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-ghost {
0%, 4% { opacity: 0; transform: translate(0, 0); }
5% { opacity: 1; }
6% { transform: translate(0, 0) scale(.96); }
34% { transform: translate(10px, -52px) scale(.96); }
39.9% { opacity: 1; }
40%, 100% { opacity: 0; transform: translate(10px, -52px) scale(.96); }
}
.dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation-name: dg-hot;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dg-hot {
0%, 29.9% { border-color: rgba(23, 20, 26, .1); box-shadow: none; }
30%, 39.9% { border-color: $color; box-shadow: 0 0 0 2px rgba(47, 109, 246, .25); }
40%, 100% { border-color: rgba(23, 20, 26, .1); box-shadow: none; }
}
@media (max-width: 340px) {
.dg__row { height: 22px; font-size: 11px; }
@keyframes dg-ghost {
0%, 4% { opacity: 0; transform: translate(0, 0); }
5% { opacity: 1; }
6% { transform: translate(0, 0) scale(.96); }
34% { transform: translate(10px, -46px) scale(.96); }
39.9% { opacity: 1; }
40%, 100% { opacity: 0; transform: translate(10px, -46px) scale(.96); }
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__list, .dg.is-demo .dg__after, .dg.is-demo .dg__ghost, .dg.is-demo .dg__list .dg__f:first-child .dg__row { animation: none !important; }
.dg__row, .dg__kids { transition: none; }
.dg:not(.is-demo) .dg__after { 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);
}
.dg {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dg__list, .dg__list ul, .dg__after ul {
list-style: none;
margin: 0;
padding: 0;
}
.dg__list:focus-visible {
outline: none;
}
.dg__list li[aria-selected=true] > .dg__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.dg__list:focus-visible .dg__row {
outline: none;
}
.dg__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border: 1px solid rgba(23, 20, 26, 0.1);
border-radius: 4px;
background: #fff;
font-size: 12px;
font-weight: 600;
cursor: grab;
user-select: none;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__row--f {
cursor: pointer;
}
.dg__row:hover {
border-color: rgba(23, 20, 26, 0.24);
}
.dg__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.dg__ico--folder {
color: #2f6df6;
}
.dg__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dg__n {
margin-left: auto;
flex: 0 0 auto;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
font-size: 10px;
line-height: 16px;
font-weight: 800;
color: rgba(23, 20, 26, 0.72);
}
.dg__n--on {
background: #2f6df6;
color: #fff;
}
.dg__row.is-hot {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.25);
}
.dg__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__kwrap {
overflow: hidden;
min-height: 0;
}
.dg__f:has(.dg__kids li) > .dg__kids {
grid-template-rows: 1fr;
}
.dg__kids .dg__row {
margin-left: 16px;
}
.dg__row.is-in {
animation-name: dg-in;
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes dg-in {
0% {
transform: scale(0.94);
background: rgba(47, 109, 246, 0.16);
}
100% {
transform: scale(1);
background: #fff;
}
}
.dg:not(.is-demo) .dg__after, .dg:not(.is-demo) .dg__ghost {
display: none;
}
.dg__after {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.dg__inkids {
padding-left: 16px;
}
.dg__row--in {
margin-bottom: 4px;
}
.dg__row--flash {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
}
.dg__ghost {
position: absolute;
left: 24px;
top: 65px;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-size: 11px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
z-index: 2;
}
.dg__ghost.is-live {
position: fixed;
opacity: 1;
}
.dg.is-demo .dg__list {
animation-name: dg-list;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-list {
0% {
opacity: 0;
}
4% {
opacity: 1;
}
39.9% {
opacity: 1;
}
40% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dg.is-demo .dg__after {
animation-name: dg-after;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-after {
0% {
opacity: 0;
}
39.9% {
opacity: 0;
}
40% {
opacity: 1;
}
82% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
.dg.is-demo .dg__ghost {
animation-name: dg-ghost;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-ghost {
0%, 4% {
opacity: 0;
transform: translate(0, 0);
}
5% {
opacity: 1;
}
6% {
transform: translate(0, 0) scale(0.96);
}
34% {
transform: translate(10px, -52px) scale(0.96);
}
39.9% {
opacity: 1;
}
40%, 100% {
opacity: 0;
transform: translate(10px, -52px) scale(0.96);
}
}
.dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation-name: dg-hot;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dg-hot {
0%, 29.9% {
border-color: rgba(23, 20, 26, 0.1);
box-shadow: none;
}
30%, 39.9% {
border-color: #2f6df6;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.25);
}
40%, 100% {
border-color: rgba(23, 20, 26, 0.1);
box-shadow: none;
}
}
@media (max-width: 340px) {
.dg__row {
height: 22px;
font-size: 11px;
}
@keyframes dg-ghost {
0%, 4% {
opacity: 0;
transform: translate(0, 0);
}
5% {
opacity: 1;
}
6% {
transform: translate(0, 0) scale(0.96);
}
34% {
transform: translate(10px, -46px) scale(0.96);
}
39.9% {
opacity: 1;
}
40%, 100% {
opacity: 0;
transform: translate(10px, -46px) scale(0.96);
}
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__list, .dg.is-demo .dg__after, .dg.is-demo .dg__ghost, .dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation: none !important;
}
.dg__row, .dg__kids {
transition: none;
}
.dg:not(.is-demo) .dg__after {
display: none;
}
}// 실제 동작: 포인터로 끌어 폴더에 놓는다(마우스·터치). 키보드는 Enter 로 이동 모드.
var root = document.querySelector('.dg');
var list = document.getElementById('dg-list');
var files = [].slice.call(list.querySelectorAll('.dg__i .dg__row'));
var folders = [].slice.call(list.querySelectorAll('.dg__f'));
function clearHot() { folders.forEach(function (f) { f.querySelector('.dg__row').classList.remove('is-hot'); }); }
function bump(f) {
var n = f.querySelector('.dg__n'), k = f.querySelectorAll('.dg__kids li').length;
n.textContent = k; n.classList.toggle('dg__n--on', k > 0);
f.setAttribute('aria-expanded', String(k > 0));
}
function drop(file, folder) {
folder.querySelector('.dg__kids ul').appendChild(file.closest('li'));
file.classList.add('is-in');
setTimeout(function () { file.classList.remove('is-in'); }, 320);
bump(folder);
}
files.forEach(function (row) {
row.addEventListener('pointerdown', function (e) {
if (e.button && e.button !== 0) return;
root.classList.remove('is-demo');
var gh = document.querySelector('.dg__ghost').cloneNode(true);
gh.classList.add('is-live'); gh.removeAttribute('aria-hidden');
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
document.body.appendChild(gh);
row.setPointerCapture(e.pointerId);
function mv(ev) {
gh.style.transform = 'translate(' + (ev.clientX - e.clientX) + 'px,' + (ev.clientY - e.clientY) + 'px) scale(.96)';
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
clearHot();
var f = hit && hit.closest && hit.closest('.dg__f');
if (f) f.querySelector('.dg__row').classList.add('is-hot');
}
function up(ev) {
row.removeEventListener('pointermove', mv);
row.removeEventListener('pointerup', up);
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
var f = hit && hit.closest && hit.closest('.dg__f');
clearHot();
gh.remove();
if (f) drop(row, f);
}
row.addEventListener('pointermove', mv);
row.addEventListener('pointerup', up);
e.preventDefault();
});
});
var moving = null, moveRow = null;
var rows = [].slice.call(list.querySelectorAll('li[role="treeitem"]'));
rows.forEach(function (li, i) {
li.querySelector('.dg__row').addEventListener('click', function () { list.setAttribute('aria-activedescendant', li.id); });
});
list.addEventListener('keydown', function (e) {
if (!moving && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
var cur = rows.indexOf(document.getElementById(list.getAttribute('aria-activedescendant')));
var nxt = Math.max(0, Math.min(cur + (e.key === 'ArrowDown' ? 1 : -1), rows.length - 1));
list.setAttribute('aria-activedescendant', rows[nxt].id);
e.preventDefault();
return;
}
if (moving) {
var k = folders.indexOf(moving);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
clearHot();
moving = folders[(k + (e.key === 'ArrowDown' ? 1 : folders.length - 1)) % folders.length];
moving.querySelector('.dg__row').classList.add('is-hot');
list.setAttribute('aria-activedescendant', moving.id);
e.preventDefault();
} else if (e.key === 'Enter') {
var f = moving, row = moveRow;
moving = null; moveRow = null; clearHot();
if (row) drop(row, f);
e.preventDefault();
} else if (e.key === 'Escape') {
moving = null; moveRow = null; clearHot();
e.preventDefault();
}
return;
}
var at = list.getAttribute('aria-activedescendant');
var li = at && document.getElementById(at);
if (e.key === 'Enter' && li && li.classList.contains('dg__i')) {
root.classList.remove('is-demo');
moving = folders[0]; moveRow = li.querySelector('.dg__row');
clearHot();
moving.querySelector('.dg__row').classList.add('is-hot');
e.preventDefault();
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragIntoFolderTree.module.scss";
export type FileKind = "image" | "pdf" | "zip";
export type FolderItem = { id: string; name: string };
export type FileItem = { id: string; name: string; kind: FileKind };
export type DragIntoFolderTreeProps = {
label?: string;
folders?: FolderItem[];
files?: FileItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type PointerEvt = {
button: number;
clientX: number;
clientY: number;
pointerId: number;
currentTarget: { setPointerCapture: (id: number) => void };
preventDefault: () => void;
};
type PtrMove = { clientX: number; clientY: number };
type KeyDown = { key: string };
function FolderIcon() {
return (
<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>
);
}
function FileIcon({ kind }: { kind: FileKind }) {
if (kind === "image") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
if (kind === "pdf") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z" />
</g>
</svg>
);
}
return (
<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>
);
}
const DEFAULT_FOLDERS: FolderItem[] = [
{ id: "uploads", name: "uploads" },
{ id: "archive", name: "archive" },
];
const DEFAULT_FILES: FileItem[] = [
{ id: "photo", name: "photo-01.jpg", kind: "image" },
{ id: "report", name: "report.pdf", kind: "pdf" },
{ id: "data", name: "data.zip", kind: "zip" },
];
export default function DragIntoFolderTree({
label = "files",
folders = DEFAULT_FOLDERS,
files = DEFAULT_FILES,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: DragIntoFolderTreeProps) {
const [where, setWhere] = useState<{ [k: string]: string | null }>({});
const [drag, setDrag] = useState<{ id: string; x: number; y: number } | null>(null);
const [hot, setHot] = useState<string | null>(null);
const [justIn, setJustIn] = useState<string | null>(null);
const [moving, setMoving] = useState<{ fileId: string; folder: number } | null>(null);
const [at, setAt] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const kidsOf = (fid: string) => files.filter((f) => where[f.id] === fid);
const loose = files.filter((f) => !where[f.id]);
const rows: { key: string; file: FileItem | null }[] = [];
folders.forEach((fo) => {
rows.push({ key: fo.id, file: null });
kidsOf(fo.id).forEach((f) => rows.push({ key: f.id, file: f }));
});
loose.forEach((f) => rows.push({ key: f.id, file: f }));
const cur = Math.max(0, Math.min(at, rows.length - 1));
const active = rows[cur];
function drop(fileId: string, folderId: string) {
setWhere((w) => {
const next: { [k: string]: string | null } = {};
for (const k in w) next[k] = w[k];
next[fileId] = folderId;
return next;
});
setJustIn(fileId);
window.setTimeout(() => setJustIn(null), 320);
}
// 포인터로 끌어 폴더에 놓는다 (마우스·터치). Escape 로 취소.
useEffect(() => {
if (!drag) return;
const id = drag.id;
function folderAt(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y);
const f = el ? el.closest("[data-folder]") : null;
return f ? f.getAttribute("data-folder") : null;
}
function onMove(ev: PtrMove) {
const x = ev.clientX;
const y = ev.clientY;
setDrag((d) => (d ? { id: d.id, x: x, y: y } : d));
setHot(folderAt(x, y));
}
function onUp(ev: PtrMove) {
const fid = folderAt(ev.clientX, ev.clientY);
setDrag(null);
setHot(null);
if (fid) drop(id, fid);
}
function onKey(ev: KeyDown) {
if (ev.key === "Escape") {
setDrag(null);
setHot(null);
}
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
document.removeEventListener("keydown", onKey);
};
}, [drag ? drag.id : null]);
function grab(e: PointerEvt, fileId: string) {
if (e.button && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag({ id: fileId, x: e.clientX, y: e.clientY });
setMoving(null);
listRef.current?.focus({ preventScroll: true });
e.preventDefault();
}
function onKeyDown(e: KeyEvt) {
if (moving) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
const step = e.key === "ArrowDown" ? 1 : folders.length - 1;
const next = (moving.folder + step) % folders.length;
setMoving({ fileId: moving.fileId, folder: next });
setHot(folders[next].id);
e.preventDefault();
} else if (e.key === "Enter" || e.key === " ") {
drop(moving.fileId, folders[moving.folder].id);
setMoving(null);
setHot(null);
e.preventDefault();
} else if (e.key === "Escape") {
setMoving(null);
setHot(null);
e.preventDefault();
}
return;
}
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, rows.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(rows.length - 1); e.preventDefault(); }
else if ((e.key === "Enter" || e.key === " ") && active && active.file) {
setMoving({ fileId: active.file.id, folder: 0 });
setHot(folders[0].id);
e.preventDefault();
}
}
function fileRow(f: FileItem, inside: boolean) {
const rowCls = [styles.row, inside ? styles.rowIn : "", justIn === f.id ? styles.isIn : ""]
.filter(Boolean)
.join(" ");
return (
<li
key={f.id}
id={`dg-${f.id}`}
className={styles.item}
role="treeitem"
aria-level={inside ? 2 : 1}
aria-selected={active ? active.key === f.id : false}
aria-grabbed={drag ? drag.id === f.id : moving ? moving.fileId === f.id : false}
>
<div
className={rowCls}
onPointerDown={(e: PointerEvt) => grab(e, f.id)}
onClick={() => setAt(rows.findIndex((r) => r.key === f.id))}
>
<span className={styles.ico} aria-hidden="true"><FileIcon kind={f.kind} /></span>
<span className={styles.name}>{f.name}</span>
</div>
</li>
);
}
const ghostFile = drag ? files.filter((f) => f.id === drag.id)[0] : null;
return (
<div className={styles.root} style={vars}>
<ul
ref={listRef}
className={styles.list}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={active ? `dg-${active.key}` : undefined}
onKeyDown={onKeyDown}
>
{folders.map((fo) => {
const kids = kidsOf(fo.id);
return (
<li
key={fo.id}
id={`dg-${fo.id}`}
className={styles.item}
role="treeitem"
aria-level={1}
aria-expanded={kids.length > 0}
aria-selected={active ? active.key === fo.id : false}
data-folder={fo.id}
>
<div
className={`${styles.row} ${styles.rowFolder} ${hot === fo.id ? styles.isHot : ""}`}
onClick={() => setAt(rows.findIndex((r) => r.key === fo.id))}
>
<span className={`${styles.ico} ${styles.icoFolder}`} aria-hidden="true"><FolderIcon /></span>
<span className={styles.name}>{fo.name}</span>
<span className={`${styles.count} ${kids.length > 0 ? styles.countOn : ""}`}>{kids.length}</span>
</div>
<div className={`${styles.kids} ${kids.length > 0 ? styles.kidsOpen : ""}`}>
<div className={styles.kwrap}>
<ul className={styles.group} role="group" aria-label={fo.name}>
{kids.map((f) => fileRow(f, true))}
</ul>
</div>
</div>
</li>
);
})}
{loose.map((f) => fileRow(f, false))}
</ul>
{drag && ghostFile ? (
<div
className={styles.ghost}
style={{ left: `${drag.x}px`, top: `${drag.y}px` }}
aria-hidden="true"
>
<span className={styles.ico}><FileIcon kind={ghostFile.kind} /></span>
{ghostFile.name}
</div>
) : null}
</div>
);
}04 · 드래그로 폴더에 넣기 — 측정 누적면적 13.852% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
정리 안 된 파일을 마우스로 끌어 분류하는 자료실·업로드함·이미지 갤러리 정리 화면에 씁니다.
function drop(file, folder) {
folder.querySelector('.dg__kids ul').appendChild(file.closest('li'));
file.classList.add('is-in');
setTimeout(function () { file.classList.remove('is-in'); }, 320);
bump(folder);
}
function mv(ev) {
gh.style.transform = 'translate(' + (ev.clientX - e.clientX) + 'px,' + (ev.clientY - e.clientY) + 'px) scale(.96)';
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
clearHot();
var f = hit && hit.closest && hit.closest('.dg__f');
if (f) f.querySelector('.dg__row').classList.add('is-hot');
}
function up(ev) {
row.removeEventListener('pointermove', mv);
row.removeEventListener('pointerup', up);
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
var f = hit && hit.closest && hit.closest('.dg__f');
clearHot();
gh.remove();
if (f) drop(row, f);
}
05Shift·Ctrl 다중 선택
화면에서 일어나는 일
한 줄을 고른 뒤 Shift로 범위를 잡고 Ctrl로 하나씩 담으면 해당 줄에 배경이 차례로 쌓이고 개수가 위에 집계됩니다. 범위의 시작점을 기억하는 변수 하나로 세 가지 클릭 방식을 같은 규칙으로 묶었고, 목록 자체에 여러 개를 고를 수 있다는 표시를 붙였습니다.

<main class="stage">
<div class="ms is-demo" id="ms">
<div class="ms__head">
<span class="ms__title" id="ms-title">자료 6개</span>
<span class="ms__badge" aria-live="polite">
<span class="ms__b ms__b--0">0개 선택</span>
<span class="ms__b ms__b--1">1개 선택</span>
<span class="ms__b ms__b--4">4개 선택</span>
<span class="ms__breal" id="ms-badge" hidden>0개 선택</span>
</span>
</div>
<ul class="ms__list" id="ms-list" role="tree" aria-label="자료" aria-multiselectable="true" tabindex="0" aria-activedescendant="ms-1">
<li class="ms__item" id="ms-1" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="ms__name">hero.jpg</span><span class="ms__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="ms__item is-a" id="ms-2" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__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="M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span><span class="ms__name">resume.pdf</span><span class="ms__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="ms__item is-b" style="--i: 0" id="ms-3" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__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="ms__name">bundle.zip</span><span class="ms__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="ms__item is-b" style="--i: 1" id="ms-4" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__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="ms__name">main.js</span><span class="ms__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="ms__item is-b" style="--i: 2" id="ms-5" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__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="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span><span class="ms__name">style.css</span><span class="ms__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="ms__item" id="ms-6" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__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="ms__name">notes.md</span><span class="ms__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>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ms {
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ms__head {
display: flex; align-items: center; justify-content: space-between;
margin: 0 0 $sp-2;
}
.ms__title {
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .6);
}
.ms__badge {
position: relative;
box-sizing: border-box;
height: 20px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
display: inline-flex; align-items: center; justify-content: flex-end;
overflow: hidden;
}
.ms__b, .ms__breal {
position: absolute; right: $sp-2;
font-size: 10px; line-height: 20px; font-weight: 800;
white-space: nowrap;
color: rgba(255, 255, 255, .75);
}
.ms__b--1, .ms__b--4 { color: $stage-yellow; }
.ms__b { opacity: 0; }
.ms__b--0 { opacity: 1; }
.ms__list { list-style: none; margin: 0; padding: 0; }
.ms__list:focus-visible { outline: none; }
.ms__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
background: rgba(255, 255, 255, .05);
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
transition: background $duration $easing;
}
.ms__row:hover { background: rgba(255, 255, 255, .1); }
.ms__item.is-sel .ms__row { background: rgba(47, 109, 246, .3); }
.ms__ico { display: inline-flex; flex: 0 0 auto; color: rgba(255, 255, 255, .8); }
.ms__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms__ok {
margin-left: auto;
display: inline-flex; flex: 0 0 auto;
color: $stage-yellow;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $ease-pop;
}
.ms__item.is-sel .ms__ok { opacity: 1; transform: scale(1); }
// ── 자동 루프: 한 줄 고르고 → Shift 범위로 네 줄 → 풀린다 ──
.ms.is-demo .ms__item.is-a .ms__row {
animation-name: ms-sela;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-sela {
0%, 9.9% { background: rgba(255, 255, 255, .05); }
10%, 82% { background: rgba(47, 109, 246, .3); }
82.01%, 100% { background: rgba(255, 255, 255, .05); }
}
.ms.is-demo .ms__item.is-a .ms__ok {
animation-name: ms-oka;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-oka { 0%, 9.9% { opacity: 0; } 10%, 82% { opacity: 1; } 82.01%, 100% { opacity: 0; } }
.ms.is-demo .ms__item.is-b .ms__row {
animation-name: ms-selb;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-selb {
0%, 21.9% { background: rgba(255, 255, 255, .05); }
22%, 82% { background: rgba(47, 109, 246, .3); }
82.01%, 100% { background: rgba(255, 255, 255, .05); }
}
.ms.is-demo .ms__item.is-b .ms__ok {
animation-name: ms-okb;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-okb { 0%, 21.9% { opacity: 0; } 22%, 82% { opacity: 1; } 82.01%, 100% { opacity: 0; } }
.ms.is-demo .ms__b--0 {
animation-name: ms-b0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b0 { 0% { opacity: 1; } 13% { opacity: 0; } 89% { opacity: 1; } 100% { opacity: 1; } }
.ms.is-demo .ms__b--1 {
animation-name: ms-b1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b1 { 0% { opacity: 0; } 13% { opacity: 1; } 37% { opacity: 0; } 100% { opacity: 0; } }
.ms.is-demo .ms__b--4 {
animation-name: ms-b4;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b4 { 0% { opacity: 0; } 37% { opacity: 1; } 89% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.ms__row { height: 20px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ms.is-demo .ms__row, .ms.is-demo .ms__ok, .ms.is-demo .ms__b { animation: none !important; }
.ms__row, .ms__ok { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ms {
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ms__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.ms__title {
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.6);
}
.ms__badge {
position: relative;
box-sizing: border-box;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
display: inline-flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
.ms__b, .ms__breal {
position: absolute;
right: 8px;
font-size: 10px;
line-height: 20px;
font-weight: 800;
white-space: nowrap;
color: rgba(255, 255, 255, 0.75);
}
.ms__b--1, .ms__b--4 {
color: #ffd23f;
}
.ms__b {
opacity: 0;
}
.ms__b--0 {
opacity: 1;
}
.ms__list {
list-style: none;
margin: 0;
padding: 0;
}
.ms__list:focus-visible {
outline: none;
}
.ms__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__row:hover {
background: rgba(255, 255, 255, 0.1);
}
.ms__item.is-sel .ms__row {
background: rgba(47, 109, 246, 0.3);
}
.ms__ico {
display: inline-flex;
flex: 0 0 auto;
color: rgba(255, 255, 255, 0.8);
}
.ms__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ms__ok {
margin-left: auto;
display: inline-flex;
flex: 0 0 auto;
color: #ffd23f;
opacity: 0;
transform: scale(0.6);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ms__item.is-sel .ms__ok {
opacity: 1;
transform: scale(1);
}
.ms.is-demo .ms__item.is-a .ms__row {
animation-name: ms-sela;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-sela {
0%, 9.9% {
background: rgba(255, 255, 255, 0.05);
}
10%, 82% {
background: rgba(47, 109, 246, 0.3);
}
82.01%, 100% {
background: rgba(255, 255, 255, 0.05);
}
}
.ms.is-demo .ms__item.is-a .ms__ok {
animation-name: ms-oka;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-oka {
0%, 9.9% {
opacity: 0;
}
10%, 82% {
opacity: 1;
}
82.01%, 100% {
opacity: 0;
}
}
.ms.is-demo .ms__item.is-b .ms__row {
animation-name: ms-selb;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-selb {
0%, 21.9% {
background: rgba(255, 255, 255, 0.05);
}
22%, 82% {
background: rgba(47, 109, 246, 0.3);
}
82.01%, 100% {
background: rgba(255, 255, 255, 0.05);
}
}
.ms.is-demo .ms__item.is-b .ms__ok {
animation-name: ms-okb;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-okb {
0%, 21.9% {
opacity: 0;
}
22%, 82% {
opacity: 1;
}
82.01%, 100% {
opacity: 0;
}
}
.ms.is-demo .ms__b--0 {
animation-name: ms-b0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b0 {
0% {
opacity: 1;
}
13% {
opacity: 0;
}
89% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ms.is-demo .ms__b--1 {
animation-name: ms-b1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b1 {
0% {
opacity: 0;
}
13% {
opacity: 1;
}
37% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ms.is-demo .ms__b--4 {
animation-name: ms-b4;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b4 {
0% {
opacity: 0;
}
37% {
opacity: 1;
}
89% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ms__row {
height: 20px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ms.is-demo .ms__row, .ms.is-demo .ms__ok, .ms.is-demo .ms__b {
animation: none !important;
}
.ms__row, .ms__ok {
transition: none;
}
}// 실제 동작: 클릭 한 개, Ctrl+클릭 토글, Shift+클릭 범위.
var root = document.querySelector('.ms');
var list = document.getElementById('ms-list');
var badge = document.getElementById('ms-badge');
var items = [].slice.call(list.querySelectorAll('.ms__item'));
var anchor = 0;
function sync() {
var n = 0;
items.forEach(function (li, i) {
var on = li.classList.contains('is-sel');
li.setAttribute('aria-selected', String(on));
if (on) { n++; list.setAttribute('aria-activedescendant', li.id); }
});
badge.textContent = n + '개 선택';
}
items.forEach(function (li, i) {
li.querySelector('[data-hit]').addEventListener('click', function (e) {
root.classList.remove('is-demo');
document.querySelector('.ms__badge').querySelectorAll('.ms__b').forEach(function (b) { b.hidden = true; });
badge.hidden = false;
if (e.shiftKey) {
var a = Math.min(anchor, i), z = Math.max(anchor, i);
items.forEach(function (l, k) { l.classList.toggle('is-sel', k >= a && k <= z); });
} else if (e.ctrlKey || e.metaKey) {
li.classList.toggle('is-sel');
anchor = i;
} else {
items.forEach(function (l) { l.classList.remove('is-sel'); });
li.classList.add('is-sel');
anchor = i;
}
sync();
});
});
list.addEventListener('keydown', function (e) {
var at = items.indexOf(document.getElementById(list.getAttribute('aria-activedescendant')));
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
var li = items[Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), items.length - 1))];
list.setAttribute('aria-activedescendant', li.id);
e.preventDefault();
} else if (e.key === ' ' && at >= 0) {
root.classList.remove('is-demo');
items[at].classList.toggle('is-sel');
anchor = at;
sync();
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiSelectTree.module.scss";
export type FileKind = "img" | "pdf" | "zip" | "js" | "css" | "md";
export type FileItem = { name: string; kind: FileKind };
export type MultiSelectTreeProps = {
label?: string;
items?: FileItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ClickEvt = { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean };
type Glyph = { dim: string; solid: string };
const PAGE = "M208 88h-56V32Z";
const GLYPH: Record<FileKind, Glyph> = {
img: {
dim: "m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z",
solid: "M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z",
},
pdf: {
dim: PAGE,
solid: "M52 144H36a8 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 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z",
},
zip: {
dim: PAGE,
solid: "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",
},
js: {
dim: PAGE,
solid: "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",
},
css: {
dim: PAGE,
solid: "M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z",
},
md: {
dim: PAGE,
solid: "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",
},
};
const CHECK: Glyph = {
dim: "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",
solid: "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",
};
function Icon({ glyph, size }: { glyph: Glyph; size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size}>
<g fill="currentColor">
<path d={glyph.dim} opacity=".2" />
<path d={glyph.solid} />
</g>
</svg>
);
}
const DEFAULT_ITEMS: FileItem[] = [
{ name: "hero.jpg", kind: "img" },
{ name: "resume.pdf", kind: "pdf" },
{ name: "bundle.zip", kind: "zip" },
{ name: "main.js", kind: "js" },
{ name: "style.css", kind: "css" },
{ name: "notes.md", kind: "md" },
];
export default function MultiSelectTree({
label = "자료",
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: MultiSelectTreeProps) {
const [sel, setSel] = useState<boolean[]>(items.map(() => false));
const [anchor, setAnchor] = useState(0);
const [active, setActive] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const count = sel.filter(Boolean).length;
const last = items.length - 1;
function hit(i: number, e: ClickEvt) {
if (e.shiftKey) {
const a = Math.min(anchor, i);
const z = Math.max(anchor, i);
setSel(items.map((_, k) => k >= a && k <= z));
} else if (e.ctrlKey || e.metaKey) {
setSel((prev) => prev.map((v, k) => (k === i ? !v : v)));
setAnchor(i);
} else {
setSel(items.map((_, k) => k === i));
setAnchor(i);
}
setActive(i);
listRef.current?.focus({ preventScroll: true });
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowDown") {
setActive((a) => Math.min(a + 1, last));
e.preventDefault();
} else if (e.key === "ArrowUp") {
setActive((a) => Math.max(a - 1, 0));
e.preventDefault();
} else if (e.key === " ") {
setSel((prev) => prev.map((v, k) => (k === active ? !v : v)));
setAnchor(active);
e.preventDefault();
}
}
return (
<div className={styles.ms} style={vars}>
<div className={styles.head}>
<span className={styles.title} id="ms-title">{`${label} ${items.length}개`}</span>
<span className={`${styles.badge} ${count > 0 ? styles.badgeOn : ""}`} aria-live="polite">
{`${count}개 선택`}
</span>
</div>
<ul
ref={listRef}
className={styles.list}
role="tree"
aria-labelledby="ms-title"
aria-multiselectable="true"
tabIndex={0}
aria-activedescendant={`ms-o${active}`}
onKeyDown={onKey}
>
{items.map((it, i) => (
<li
key={it.name}
id={`ms-o${i}`}
className={`${styles.item} ${sel[i] ? styles.isSel : ""} ${i === active ? styles.isActive : ""}`}
role="treeitem"
aria-level={1}
aria-selected={sel[i]}
>
<div className={styles.row} onClick={(e: ClickEvt) => hit(i, e)}>
<span className={styles.ico} aria-hidden="true">
<Icon glyph={GLYPH[it.kind]} size={13} />
</span>
<span className={styles.name}>{it.name}</span>
<span className={styles.ok} aria-hidden="true">
<Icon glyph={CHECK} size={12} />
</span>
</div>
</li>
))}
</ul>
</div>
);
}05 · Shift·Ctrl 다중 선택 — 측정 누적면적 16.023% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
여러 파일을 한 번에 내보내거나 옮기거나 지워야 하는 자료 목록의 선택 방식입니다.
function sync() {
var n = 0;
items.forEach(function (li, i) {
var on = li.classList.contains('is-sel');
li.setAttribute('aria-selected', String(on));
if (on) { n++; list.setAttribute('aria-activedescendant', li.id); }
});
badge.textContent = n + '개 선택';
}
if (e.shiftKey) {
var a = Math.min(anchor, i), z = Math.max(anchor, i);
items.forEach(function (l, k) { l.classList.toggle('is-sel', k >= a && k <= z); });
} else if (e.ctrlKey || e.metaKey) {
li.classList.toggle('is-sel');
anchor = i;
} else {
items.forEach(function (l) { l.classList.remove('is-sel'); });
li.classList.add('is-sel');
anchor = i;
}
06검색으로 남기는 트리
어떤 움직임인가
검색창에 글자를 치면 일치하는 파일만 남고 그 조상 폴더는 흐리게 길을 남기며, 일치한 글자에만 형광이 입혀집니다. 해당 없는 줄은 흐리게 두는 대신 화면에서 실제로 빼고, 초점을 옮길 때는 화면이 딸려가지 않게 막아 두었습니다.

<main class="stage">
<div class="sf is-demo" id="sf">
<div class="sf__bar">
<span class="sf__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="sf__stack" aria-hidden="true">
<span class="sf__t sf__t--0"></span>
<span class="sf__t sf__t--1">he</span>
<span class="sf__t sf__t--2">hero</span>
</span>
<input class="sf__input" id="sf-input" type="text" placeholder="파일 검색" autocomplete="off" aria-label="파일 검색">
</div>
<div class="sf__count" aria-live="polite">
<span class="sf__c sf__c--0">6건</span>
<span class="sf__c sf__c--2">2건</span>
<span class="sf__creal" id="sf-count" hidden>6건</span>
</div>
<ul class="sf__tree" id="sf-tree" role="tree" aria-label="파일" tabindex="0" aria-activedescendant="sf-1">
<li class="sf__f" id="sf-1" role="treeitem" aria-level="1" data-folder="site">
<div class="sf__row" data-hit><span class="sf__fi" 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="sf__name">site</span></div>
</li>
<li class="sf__i" id="sf-2" role="treeitem" aria-level="2" data-anc="site" data-name="hero.tsx">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" 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="sf__name">hero.tsx</span></div>
</li>
<li class="sf__i" id="sf-3" role="treeitem" aria-level="2" data-anc="site" data-name="button.tsx">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" 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="sf__name">button.tsx</span></div>
</li>
<li class="sf__f" id="sf-4" role="treeitem" aria-level="1" data-folder="assets">
<div class="sf__row" data-hit><span class="sf__fi" 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="sf__name">assets</span></div>
</li>
<li class="sf__i" id="sf-5" role="treeitem" aria-level="2" data-anc="assets" data-name="hero.png">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="sf__name">hero.png</span></div>
</li>
<li class="sf__i" id="sf-6" role="treeitem" aria-level="1" data-name="bundle.zip">
<div class="sf__row" data-hit><span class="sf__fi" 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="sf__name">bundle.zip</span></div>
</li>
</ul>
<div class="sf__hits" aria-hidden="true">
<div class="sf__hrow"><span class="sf__fi"><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="sf__name sf__name--path">site</span></div>
<div class="sf__hrow sf__hrow--deep"><span class="sf__fi"><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="sf__name"><mark class="sf__m">hero</mark>.tsx</span></div>
<div class="sf__hrow"><span class="sf__fi"><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="sf__name sf__name--path">assets</span></div>
<div class="sf__hrow sf__hrow--deep"><span class="sf__fi"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="sf__name"><mark class="sf__m">hero</mark>.png</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.sf {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.sf__bar {
position: relative;
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-10 0 $sp-2;
margin-bottom: $sp-2;
border-radius: $r-sm;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
}
.sf__ico { display: inline-flex; flex: 0 0 auto; color: rgba(23, 20, 26, .45); }
.sf__stack { position: relative; display: block; flex: 1; height: 28px; }
.sf__t {
position: absolute; left: 0; top: 0;
line-height: 28px;
font-size: 12px; font-weight: 600;
opacity: 0;
}
.sf__t--0 { opacity: 1; }
.sf__input {
position: absolute; inset: 0;
border: 0; padding: 0;
background: transparent;
font-family: inherit;
font-size: 12px; line-height: 28px; font-weight: 600;
color: $stage-ink;
outline: none;
}
.sf.is-demo .sf__input::placeholder { color: transparent; }
.sf:not(.is-demo) .sf__stack { display: none; }
.sf__count {
position: absolute;
top: 6px; right: 0;
height: 20px;
padding: 0 $sp-2;
display: inline-flex; align-items: center;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
}
.sf__c, .sf__creal {
position: absolute; right: $sp-2;
font-size: 10px; line-height: 20px; font-weight: 800;
white-space: nowrap;
}
.sf__c { opacity: 0; }
.sf__c--0 { opacity: 1; }
.sf__c--2 { color: $color; }
.sf__tree { list-style: none; margin: 0; padding: 0; }
.sf__tree:focus-visible { outline: none; }
.sf__tree li[aria-selected="true"] > .sf__row { outline: 2px solid $color; outline-offset: -2px; }
.sf__row {
box-sizing: border-box;
height: 22px;
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: pointer;
user-select: none;
}
.sf__row:hover { background: rgba(23, 20, 26, .07); }
.sf__row--deep { margin-left: $sp-4; }
.sf__tree li:last-child .sf__row { margin-bottom: 0; }
.sf__fi { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.sf__f .sf__fi { color: $color; }
.sf__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sf__name--path { color: rgba(23, 20, 26, .45); }
.sf__m {
background: $stage-yellow;
color: $stage-ink;
font-weight: 800;
}
// 검색 결과 상태 — 실제 트리 위에 겹친다
.sf:not(.is-demo) .sf__hits { display: none; }
.sf__hits {
position: absolute;
top: 36px; left: 0; right: 0;
opacity: 0;
pointer-events: none;
}
// ── 자동 루프: he → hero 로 치면 흐린 경로와 일치 행만 남는다 ──
.sf.is-demo .sf__tree {
animation-name: sf-full;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-full {
0%, 6% { opacity: 1; }
16.9% { opacity: 1; }
17% { opacity: 0; }
92% { opacity: 0; }
100% { opacity: 1; }
}
.sf.is-demo .sf__hits {
animation-name: sf-hit;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-hit {
0%, 16.9% { opacity: 0; }
17% { opacity: 1; }
80% { opacity: 1; }
92%, 100% { opacity: 0; }
}
.sf.is-demo .sf__t--0 {
animation-name: sf-t0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t0 { 0% { opacity: 1; } 13% { opacity: 0; } 92% { opacity: 1; } 100% { opacity: 1; } }
.sf.is-demo .sf__t--1 {
animation-name: sf-t1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t1 { 0% { opacity: 0; } 13% { opacity: 1; } 17% { opacity: 0; } 100% { opacity: 0; } }
.sf.is-demo .sf__t--2 {
animation-name: sf-t2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t2 { 0% { opacity: 0; } 17% { opacity: 1; } 92% { opacity: 0; } 100% { opacity: 0; } }
.sf.is-demo .sf__c--0 {
animation-name: sf-c0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c0 { 0% { opacity: 1; } 17% { opacity: 0; } 92% { opacity: 1; } 100% { opacity: 1; } }
.sf.is-demo .sf__c--2 {
animation-name: sf-c2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c2 { 0% { opacity: 0; } 17% { opacity: 1; } 92% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.sf__bar { height: 24px; }
.sf__stack { height: 24px; }
.sf__t { line-height: 24px; }
.sf__input { line-height: 24px; }
.sf__hits { top: 32px; }
.sf__row { height: 20px; font-size: 11px; }
.sf__row--deep { margin-left: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__tree, .sf.is-demo .sf__hits, .sf.is-demo .sf__t, .sf.is-demo .sf__c { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.sf__bar {
position: relative;
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 40px 0 8px;
margin-bottom: 8px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
}
.sf__ico {
display: inline-flex;
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.45);
}
.sf__stack {
position: relative;
display: block;
flex: 1;
height: 28px;
}
.sf__t {
position: absolute;
left: 0;
top: 0;
line-height: 28px;
font-size: 12px;
font-weight: 600;
opacity: 0;
}
.sf__t--0 {
opacity: 1;
}
.sf__input {
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
line-height: 28px;
font-weight: 600;
color: #17141a;
outline: none;
}
.sf.is-demo .sf__input::placeholder {
color: transparent;
}
.sf:not(.is-demo) .sf__stack {
display: none;
}
.sf__count {
position: absolute;
top: 6px;
right: 0;
height: 20px;
padding: 0 8px;
display: inline-flex;
align-items: center;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.sf__c, .sf__creal {
position: absolute;
right: 8px;
font-size: 10px;
line-height: 20px;
font-weight: 800;
white-space: nowrap;
}
.sf__c {
opacity: 0;
}
.sf__c--0 {
opacity: 1;
}
.sf__c--2 {
color: #2f6df6;
}
.sf__tree {
list-style: none;
margin: 0;
padding: 0;
}
.sf__tree:focus-visible {
outline: none;
}
.sf__tree li[aria-selected=true] > .sf__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sf__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.sf__row:hover {
background: rgba(23, 20, 26, 0.07);
}
.sf__row--deep {
margin-left: 16px;
}
.sf__tree li:last-child .sf__row {
margin-bottom: 0;
}
.sf__fi {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.sf__f .sf__fi {
color: #2f6df6;
}
.sf__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sf__name--path {
color: rgba(23, 20, 26, 0.45);
}
.sf__m {
background: #ffd23f;
color: #17141a;
font-weight: 800;
}
.sf:not(.is-demo) .sf__hits {
display: none;
}
.sf__hits {
position: absolute;
top: 36px;
left: 0;
right: 0;
opacity: 0;
pointer-events: none;
}
.sf.is-demo .sf__tree {
animation-name: sf-full;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-full {
0%, 6% {
opacity: 1;
}
16.9% {
opacity: 1;
}
17% {
opacity: 0;
}
92% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__hits {
animation-name: sf-hit;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-hit {
0%, 16.9% {
opacity: 0;
}
17% {
opacity: 1;
}
80% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
.sf.is-demo .sf__t--0 {
animation-name: sf-t0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t0 {
0% {
opacity: 1;
}
13% {
opacity: 0;
}
92% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__t--1 {
animation-name: sf-t1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t1 {
0% {
opacity: 0;
}
13% {
opacity: 1;
}
17% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sf.is-demo .sf__t--2 {
animation-name: sf-t2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t2 {
0% {
opacity: 0;
}
17% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sf.is-demo .sf__c--0 {
animation-name: sf-c0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c0 {
0% {
opacity: 1;
}
17% {
opacity: 0;
}
92% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__c--2 {
animation-name: sf-c2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c2 {
0% {
opacity: 0;
}
17% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sf__bar {
height: 24px;
}
.sf__stack {
height: 24px;
}
.sf__t {
line-height: 24px;
}
.sf__input {
line-height: 24px;
}
.sf__hits {
top: 32px;
}
.sf__row {
height: 20px;
font-size: 11px;
}
.sf__row--deep {
margin-left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__tree, .sf.is-demo .sf__hits, .sf.is-demo .sf__t, .sf.is-demo .sf__c {
animation: none !important;
}
}// 실제 동작: 칠 때마다 목록이 줄고 조상 폴더는 흐린 길로 남는다.
var root = document.querySelector('.sf');
var input = document.getElementById('sf-input');
var count = document.getElementById('sf-count');
var tree = document.getElementById('sf-tree');
var files = [].slice.call(tree.querySelectorAll('.sf__i'));
var folders = [].slice.call(tree.querySelectorAll('.sf__f'));
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<'); }
function render(q) {
root.classList.remove('is-demo');
document.querySelectorAll('.sf__t, .sf__c').forEach(function (t) { t.hidden = true; });
count.hidden = false;
var hits = 0;
files.forEach(function (li) {
var name = li.dataset.name, k = q ? name.toLowerCase().indexOf(q) : -1;
var hit = !q || k >= 0;
li.hidden = !hit;
if (hit && q) {
hits++;
li.querySelector('.sf__name').innerHTML = esc(name.slice(0, k)) + '<mark class="sf__m">' + esc(name.slice(k, k + q.length)) + '</mark>' + esc(name.slice(k + q.length));
} else {
li.querySelector('.sf__name').textContent = name;
}
});
folders.forEach(function (f) {
var alive = files.some(function (li) { return !li.hidden && li.dataset.anc === f.dataset.folder; });
f.hidden = !alive;
f.classList.toggle('is-path', alive && !!q);
});
count.textContent = (q ? hits : files.length) + '건';
}
input.addEventListener('input', function () { render(input.value.trim().toLowerCase()); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { input.value = ''; render(''); tree.focus({ preventScroll: true }); }
});
tree.addEventListener('keydown', function (e) {
if (e.key === '/' && document.activeElement !== input) { input.focus({ preventScroll: true }); e.preventDefault(); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SearchFilterTree.module.scss";
export type NodeKind = "folder" | "ts" | "js" | "img" | "zip";
export type TreeNode = { name: string; kind: NodeKind; level: 1 | 2; parent?: string };
export type SearchFilterTreeProps = {
placeholder?: string;
nodes?: TreeNode[];
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 } };
type Glyph = { dim: string; solid: string };
const PAGE = "M208 88h-56V32Z";
const GLYPH: Record<NodeKind, Glyph> = {
folder: {
dim: "M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z",
solid: "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",
},
ts: {
dim: PAGE,
solid: "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",
},
js: {
dim: PAGE,
solid: "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",
},
img: {
dim: "m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z",
solid: "M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z",
},
zip: {
dim: PAGE,
solid: "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",
},
};
const SEARCH: Glyph = {
dim: "M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80",
solid: "m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72",
};
function Icon({ glyph, size }: { glyph: Glyph; size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size}>
<g fill="currentColor">
<path d={glyph.dim} opacity=".2" />
<path d={glyph.solid} />
</g>
</svg>
);
}
function Name({ name, q }: { name: string; q: string }) {
const k = q ? name.toLowerCase().indexOf(q) : -1;
if (k < 0) return <span className={styles.name}>{name}</span>;
return (
<span className={styles.name}>
{name.slice(0, k)}
<mark className={styles.mark}>{name.slice(k, k + q.length)}</mark>
{name.slice(k + q.length)}
</span>
);
}
const DEFAULT_NODES: TreeNode[] = [
{ name: "site", kind: "folder", level: 1 },
{ name: "hero.tsx", kind: "ts", level: 2, parent: "site" },
{ name: "button.tsx", kind: "js", level: 2, parent: "site" },
{ name: "assets", kind: "folder", level: 1 },
{ name: "hero.png", kind: "img", level: 2, parent: "assets" },
{ name: "bundle.zip", kind: "zip", level: 1 },
];
export default function SearchFilterTree({
placeholder = "파일 검색",
nodes = DEFAULT_NODES,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SearchFilterTreeProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const q = query.trim().toLowerCase();
function fileHit(n: TreeNode): boolean {
return n.kind !== "folder" && (!q || n.name.toLowerCase().indexOf(q) >= 0);
}
function folderAlive(n: TreeNode): boolean {
return nodes.some((c) => c.parent === n.name && fileHit(c));
}
const shown = nodes.map((n) => (n.kind === "folder" ? folderAlive(n) : fileHit(n)));
const files = nodes.filter((n) => n.kind !== "folder");
const hits = q ? files.filter(fileHit).length : files.length;
const first = shown.indexOf(true);
const activeId = `sf-n${first < 0 ? 0 : first}`;
function onInputKey(e: KeyEvt) {
if (e.key === "Escape") {
setQuery("");
treeRef.current?.focus({ preventScroll: true });
}
}
function onTreeKey(e: KeyEvt) {
if (e.key === "/") {
inputRef.current?.focus({ preventScroll: true });
e.preventDefault();
}
}
return (
<div className={styles.sf} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<Icon glyph={SEARCH} size={13} />
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
value={query}
placeholder={placeholder}
autoComplete="off"
aria-label={placeholder}
onChange={(e: ChangeEvt) => setQuery(e.target.value)}
onKeyDown={onInputKey}
/>
</div>
<div className={styles.count} aria-live="polite">{`${hits}건`}</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={placeholder}
tabIndex={0}
aria-activedescendant={activeId}
onKeyDown={onTreeKey}
>
{nodes.map((n, i) => {
const folder = n.kind === "folder";
const path = folder && !!q;
return (
<li
key={n.name}
id={`sf-n${i}`}
className={`${styles.item} ${folder ? styles.isFolder : ""} ${path ? styles.isPath : ""}`}
role="treeitem"
aria-level={n.level}
hidden={!shown[i]}
>
<div className={`${styles.row} ${n.level === 2 ? styles.rowDeep : ""}`}>
<span className={styles.fi} aria-hidden="true">
<Icon glyph={GLYPH[n.kind]} size={folder ? 13 : 12} />
</span>
{folder ? <span className={styles.name}>{n.name}</span> : <Name name={n.name} q={q} />}
</div>
</li>
);
})}
</ul>
</div>
);
}06 · 검색으로 남기는 트리 — 측정 누적면적 2.089% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
파일이 수백 개라 폴더를 하나하나 열어 찾는 게 느릴 때 이름으로 바로 좁히고 싶은 큰 트리에 씁니다.
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<'); }
function render(q) {
root.classList.remove('is-demo');
document.querySelectorAll('.sf__t, .sf__c').forEach(function (t) { t.hidden = true; });
count.hidden = false;
var hits = 0;
files.forEach(function (li) {
var name = li.dataset.name, k = q ? name.toLowerCase().indexOf(q) : -1;
var hit = !q || k >= 0;
li.hidden = !hit;
if (hit && q) {
hits++;
li.querySelector('.sf__name').innerHTML = esc(name.slice(0, k)) + '<mark class="sf__m">' + esc(name.slice(k, k + q.length)) + '</mark>' + esc(name.slice(k + q.length));
} else {
li.querySelector('.sf__name').textContent = name;
}
});
folders.forEach(function (f) {
var alive = files.some(function (li) { return !li.hidden && li.dataset.anc === f.dataset.folder; });
f.hidden = !alive;
f.classList.toggle('is-path', alive && !!q);
});
count.textContent = (q ? hits : files.length) + '건';
}
07우클릭 컨텍스트 메뉴
무엇이 보이나
줄에서 우클릭하면 누른 자리에 메뉴가 튀어나오고, 항목 사이를 하이라이트가 오르내리며, Escape로 닫힙니다. 메뉴가 화면 밖으로 삐져나가지 않도록 좌표를 가장자리 안쪽으로 밀어 넣고, 닫히면 초점이 눌렀던 줄로 되돌아옵니다.

<main class="stage">
<div class="cm is-demo" id="cm">
<ul class="cm__tree" id="cm-tree" role="tree" aria-label="파일" 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 메뉴" 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>열기</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>이름 바꾸기</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>복사</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>삭제</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 + ' 메뉴');
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 · 우클릭 컨텍스트 메뉴 — 측정 누적면적 21.474% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
여는·고치는·지우는 같은 파일 동작을 화면 여기저기 단추로 늘어놓지 않고 커서 가까이 두고 싶을 때 씁니다.
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 + ' 메뉴');
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 });
}
08제자리에서 이름 바꾸기
화면에서 일어나는 일
줄을 두 번 누르면 이름 자리가 그대로 입력칸으로 바뀌어 기존 이름이 통째로 선택되고, Enter로 확정하고 Escape로 취소합니다. 입력칸은 줄 글자와 같은 글꼴에 높이 22px로 만들어 자리가 튀지 않으며, 확정 표시는 잠깐 한 번 커지는 것으로 끝냅니다.

<main class="stage">
<div class="rn is-demo" id="rn">
<div class="rn__head">
<span class="rn__title">프로젝트 파일</span>
<button class="rn__add" type="button" id="rn-add" aria-label="새 파일 추가"><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>파일 추가</span></button>
</div>
<ul class="rn__tree" id="rn-tree" role="tree" aria-label="프로젝트 파일" 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', '파일 이름');
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 · 제자리에서 이름 바꾸기 — 측정 누적면적 2.036% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
이름을 바꾸려고 새 창이나 아랫줄 폼을 띄우는 대신 보던 자리에서 바로 고치게 하고 싶을 때 씁니다.
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', '파일 이름');
holder.appendChild(inp);
li.querySelector('.rn__row').insertBefore(holder, li.querySelector('.rn__ok'));
inp.focus({ preventScroll: true });
inp.select();
} 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 });
09용량 막대가 붙은 트리
어떤 움직임인가
줄마다 아래에 용량 막대가 자리 잡고 차오르며, 어느 줄이든 누르면 그 줄을 기준으로 비율을 다시 잽니다. 막대는 너비를 직접 늘리는 대신 가로로 늘이는 변형으로 키워 다시 계산하는 부담을 줄였고, 용량을 숫자뿐 아니라 막대 길이로도 읽게 했습니다.

<main class="stage">
<div class="zb is-demo" id="zb">
<div class="zb__head">
<span class="zb__title">저장소 6.8 GB</span>
<span class="zb__hint">폴더를 누르면 그 기준으로 다시 잽니다</span>
</div>
<ul class="zb__tree" id="zb-tree" role="tree" aria-label="저장소" tabindex="0" aria-activedescendant="zb-1">
<li class="zb__item" id="zb-1" role="treeitem" aria-level="1" data-v="4.2">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico zb__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="zb__name">media</span>
<span class="zb__size" aria-hidden="true">4.2 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="82" aria-label="media 용량" style="--v: .82"><i class="zb__fill"></i></div>
</li>
<li class="zb__item" id="zb-2" role="treeitem" aria-level="1" data-v="1.1">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico zb__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="zb__name">docs</span>
<span class="zb__size" aria-hidden="true">1.1 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="21" aria-label="docs 용량" style="--v: .21"><i class="zb__fill"></i></div>
</li>
<li class="zb__item" id="zb-3" role="treeitem" aria-level="1" data-v="0.6">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico zb__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="zb__name">src</span>
<span class="zb__size" aria-hidden="true">0.6 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="12" aria-label="src 용량" style="--v: .12"><i class="zb__fill"></i></div>
</li>
<li class="zb__item" id="zb-4" role="treeitem" aria-level="1" data-v="0.9">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__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="zb__name">backup.zip</span>
<span class="zb__size" aria-hidden="true">0.9 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="17" aria-label="backup.zip 용량" style="--v: .17"><i class="zb__fill"></i></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.zb {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.zb__head {
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
margin: 0 0 $sp-2;
}
.zb__title {
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: rgba(255, 255, 255, .75);
}
.zb__hint {
font-size: 9px; line-height: 12px;
color: rgba(255, 255, 255, .52);
}
.zb__tree { list-style: none; margin: 0; padding: 0; }
.zb__tree:focus-visible { outline: none; }
.zb__item { margin-bottom: $sp-1; }
.zb__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.zb__row:hover { background: rgba(255, 255, 255, .07); }
.zb__item[aria-selected="true"] .zb__row { background: rgba(47, 109, 246, .22); }
.zb__ico { display: inline-flex; flex: 0 0 auto; color: #fff; }
.zb__ico--folder { color: $color; }
.zb__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zb__size {
margin-left: auto;
flex: 0 0 auto;
font-size: 10px; line-height: 12px; font-weight: 800;
color: rgba(255, 255, 255, .55);
}
.zb__bar {
display: block;
height: 6px;
margin: 0 $sp-2 $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
overflow: hidden;
}
.zb__fill {
display: block;
height: 100%;
border-radius: $r-pill;
background: $color;
transform-origin: left center; // 규칙8: 크기 막대 왼쪽 채움
transform: scaleX(var(--v));
transition: transform $duration $easing;
}
// ── 자동 루프: 막대가 차오르고 크기가 컷으로 붙는다. 끝에는 다시 빠진다 ──
.zb.is-demo .zb__fill {
animation-name: zb-grow;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d, 0ms);
}
.zb__item:nth-child(1) .zb__fill { --d: 0ms; }
.zb__item:nth-child(2) .zb__fill { --d: 90ms; }
.zb__item:nth-child(3) .zb__fill { --d: 180ms; }
.zb__item:nth-child(4) .zb__fill { --d: 270ms; }
@keyframes zb-grow {
0% { transform: scaleX(0); }
16% { transform: scaleX(var(--v)); }
80% { transform: scaleX(var(--v)); }
96%, 100% { transform: scaleX(0); }
}
.zb.is-demo .zb__size {
animation-name: zb-size;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.zb__item:nth-child(1) .zb__size { animation-delay: 0ms; }
.zb__item:nth-child(2) .zb__size { animation-delay: 90ms; }
.zb__item:nth-child(3) .zb__size { animation-delay: 180ms; }
.zb__item:nth-child(4) .zb__size { animation-delay: 270ms; }
@keyframes zb-size {
0%, 15.9% { opacity: 0; }
16%, 80% { opacity: 1; }
80.01%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.zb__row { height: 20px; font-size: 11px; }
.zb__hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.zb.is-demo .zb__fill, .zb.is-demo .zb__size { animation: none !important; }
.zb__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.zb {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.zb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
}
.zb__title {
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.75);
}
.zb__hint {
font-size: 9px;
line-height: 12px;
color: rgba(255, 255, 255, 0.52);
}
.zb__tree {
list-style: none;
margin: 0;
padding: 0;
}
.zb__tree:focus-visible {
outline: none;
}
.zb__item {
margin-bottom: 4px;
}
.zb__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.zb__row:hover {
background: rgba(255, 255, 255, 0.07);
}
.zb__item[aria-selected=true] .zb__row {
background: rgba(47, 109, 246, 0.22);
}
.zb__ico {
display: inline-flex;
flex: 0 0 auto;
color: #fff;
}
.zb__ico--folder {
color: #2f6df6;
}
.zb__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.zb__size {
margin-left: auto;
flex: 0 0 auto;
font-size: 10px;
line-height: 12px;
font-weight: 800;
color: rgba(255, 255, 255, 0.55);
}
.zb__bar {
display: block;
height: 6px;
margin: 0 8px 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.zb__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(var(--v));
transition: transform 300ms ease-out;
}
.zb.is-demo .zb__fill {
animation-name: zb-grow;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: var(--d, 0ms);
}
.zb__item:nth-child(1) .zb__fill {
--d: 0ms;
}
.zb__item:nth-child(2) .zb__fill {
--d: 90ms;
}
.zb__item:nth-child(3) .zb__fill {
--d: 180ms;
}
.zb__item:nth-child(4) .zb__fill {
--d: 270ms;
}
@keyframes zb-grow {
0% {
transform: scaleX(0);
}
16% {
transform: scaleX(var(--v));
}
80% {
transform: scaleX(var(--v));
}
96%, 100% {
transform: scaleX(0);
}
}
.zb.is-demo .zb__size {
animation-name: zb-size;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.zb__item:nth-child(1) .zb__size {
animation-delay: 0ms;
}
.zb__item:nth-child(2) .zb__size {
animation-delay: 90ms;
}
.zb__item:nth-child(3) .zb__size {
animation-delay: 180ms;
}
.zb__item:nth-child(4) .zb__size {
animation-delay: 270ms;
}
@keyframes zb-size {
0%, 15.9% {
opacity: 0;
}
16%, 80% {
opacity: 1;
}
80.01%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.zb__row {
height: 20px;
font-size: 11px;
}
.zb__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.zb.is-demo .zb__fill, .zb.is-demo .zb__size {
animation: none !important;
}
.zb__fill {
transition: none;
}
}// 실제 동작: 폴더를 누르면 그 폴더를 100% 기준으로 막대가 다시 잡힌다.
var root = document.querySelector('.zb');
var tree = document.getElementById('zb-tree');
var title = document.querySelector('.zb__title');
var items = [].slice.call(tree.querySelectorAll('.zb__item'));
var base = null;
function paint() {
var baseV = base ? parseFloat(base.dataset.v) : 4.2;
items.forEach(function (li) {
var v = parseFloat(li.dataset.v);
var share = base ? Math.min(v / baseV, 1) : (v / 4.2) * .82;
li.querySelector('.zb__fill').style.transform = 'scaleX(' + share + ')';
li.querySelector('.zb__bar').setAttribute('aria-valuenow', String(Math.round(share * 100)));
li.setAttribute('aria-selected', String(base != null && li === base));
});
title.textContent = base ? ('기준: ' + base.querySelector('.zb__name').textContent + ' ' + base.dataset.v + ' GB') : '저장소 6.8 GB';
}
items.forEach(function (li, i) {
li.querySelector('.zb__row').addEventListener('click', function () {
root.classList.remove('is-demo');
tree.setAttribute('aria-activedescendant', li.id);
base = (base === li) ? null : li;
paint();
});
});
tree.addEventListener('keydown', function (e) {
var at = items.indexOf(document.getElementById(tree.getAttribute('aria-activedescendant')));
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
var li = items[Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), items.length - 1))];
tree.setAttribute('aria-activedescendant', li.id);
e.preventDefault();
} else if (e.key === 'Enter' && at >= 0) {
root.classList.remove('is-demo');
base = (base === items[at]) ? null : items[at];
paint();
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SizeBarTree.module.scss";
export type SizeNode = { name: string; gb: number; folder?: boolean };
export type SizeBarTreeProps = {
label?: string;
hint?: string;
nodes?: SizeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--v"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_NODES: SizeNode[] = [
{ name: "media", gb: 4.2, folder: true },
{ name: "docs", gb: 1.1, folder: true },
{ name: "src", gb: 0.6, folder: true },
{ name: "backup.zip", gb: 0.9 },
];
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="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>
);
}
export default function SizeBarTree({
label = "저장소",
hint = "폴더를 누르면 그 기준으로 다시 잽니다",
nodes = DEFAULT_NODES,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: SizeBarTreeProps) {
const [base, setBase] = useState(-1);
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const total = nodes.reduce((s, n) => s + n.gb, 0);
const top = nodes.reduce((m, n) => Math.max(m, n.gb), 0) || 1;
const baseV = base >= 0 && nodes[base] ? nodes[base].gb : top;
// 기준이 없으면 최대 폴더를 82% 로, 기준이 있으면 그 폴더를 100% 로 다시 잰다.
function share(n: SizeNode) {
return base >= 0 ? Math.min(n.gb / baseV, 1) : (n.gb / top) * 0.82;
}
const title = base >= 0 && nodes[base]
? `기준: ${nodes[base].name} ${nodes[base].gb} GB`
: `${label} ${total.toFixed(1)} GB`;
function pick(i: number) {
setAt(i);
setBase((b) => (b === i ? -1 : i));
treeRef.current?.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
if (e.key === "ArrowDown") { setAt((a) => Math.min(a + 1, nodes.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt((a) => Math.max(a - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(nodes.length - 1); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") { setBase((b) => (b === at ? -1 : at)); e.preventDefault(); }
}
return (
<div className={styles.zb} style={vars}>
<div className={styles.head}>
<span className={styles.title}>{title}</span>
<span className={styles.hint}>{hint}</span>
</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={`zb-${at}`}
onKeyDown={onKeyDown}
>
{nodes.map((n, i) => {
const s = share(n);
const bar: BarVars = { "--v": String(s) };
return (
<li
key={n.name}
id={`zb-${i}`}
className={`${styles.item} ${base === i ? styles.isBase : ""} ${at === i ? styles.isAt : ""}`}
role="treeitem"
aria-level={1}
aria-selected={base === i}
>
<div className={styles.row} onClick={() => pick(i)} tabIndex={-1}>
<span className={`${styles.ico} ${n.folder ? styles.icoFolder : ""}`} aria-hidden="true">
{n.folder ? folderIcon() : fileIcon()}
</span>
<span className={styles.name}>{n.name}</span>
<span className={styles.size} aria-hidden="true">{n.gb} GB</span>
</div>
<div
className={styles.bar}
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(s * 100)}
aria-label={`${n.name} 용량`}
style={bar}
>
<i className={styles.fill} />
</div>
</li>
);
})}
</ul>
</div>
);
}09 · 용량 막대가 붙은 트리 — 측정 누적면적 1.824% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
저장소·프로젝트별 용량을 트리 안에서 곧바로 보여 정리 대상을 찾게 하는 관리 화면에 씁니다.
.zb__fill {
display: block;
height: 100%;
border-radius: $r-pill;
background: $color;
transform-origin: left center;
transform: scaleX(var(--v));
transition: transform $duration $easing;
}
function paint() {
var baseV = base ? parseFloat(base.dataset.v) : 4.2;
items.forEach(function (li) {
var v = parseFloat(li.dataset.v);
var share = base ? Math.min(v / baseV, 1) : (v / 4.2) * .82;
li.querySelector('.zb__fill').style.transform = 'scaleX(' + share + ')';
li.querySelector('.zb__bar').setAttribute('aria-valuenow', String(Math.round(share * 100)));
li.setAttribute('aria-selected', String(base != null && li === base));
});
title.textContent = base ? ('기준: ' + base.querySelector('.zb__name').textContent + ' ' + base.dataset.v + ' GB') : '저장소 6.8 GB';
}
어디서 깨지는가 — 함정
폴더를 접는 애니메이션에서 가장 먼저 부딪히는 벽은 높이입니다. 자식이 몇 줄인지 모르는 목록의 높이는 미리 정해 둘 수 없는 값이라 그 자체로는 부드럽게 변하지 않는데, 이 편의 접는 트리 셋(01·02·04)은 감싼 요소를 격자로 두고 트랙을 0fr에서 1fr로 전환해 해결했습니다. 여기서 안쪽 요소에 overflow: hidden과 min-height: 0 두 줄이 없으면 트랙이 0이어도 내용이 삐져나와 전혀 접히지 않습니다. 세 파일 모두 이 두 줄을 넣어 뒀습니다.
둘째는 줄을 따라 움직이는 막대의 이동 거리입니다. 막대는 줄 번호에 줄 높이를 곱한 만큼 내려가는데, 좁은 화면에서는 줄 높이 자체가 줄어듭니다. 03은 화면에서 줄 높이를 직접 재어 돌려주는 함수로 피했고, 자동 재생 루프처럼 피할 수 없는 부분은 340px 이하 규칙 안에서 키프레임을 24/48에서 22/44로 통째로 다시 썼습니다. 07도 같은 이유로 28/56/84를 24/48/72로 고쳤습니다. 높이만 줄이고 키프레임을 그대로 두면 막대가 두 줄에 걸쳐 앉습니다.
셋째는 같은 자리에서 바뀌는 두 글자·두 아이콘입니다. 겹쳐 둔 두 벌을 서서히 크로스페이드하면 중간 프레임에서 둘 다 반투명으로 포개져 글자가 뭉개집니다. 그래서 상태가 바뀌는 교체는 전부 계단 함수로 딱 끊었습니다. 01의 폴더 아이콘, 03의 경로 세 벌, 05의 개수 배지, 06의 입력 글자, 08의 이름과 입력칸, 09의 용량 글자가 그 대상이고, 반대로 위치가 움직이는 막대·유령 조각·메뉴에만 스프링·팝 곡선을 썼습니다.
넷째는 자동 재생용 겹침 층입니다. 04와 06은 재생 결과 화면을 실제 목록 위에 겹쳐 놓은 가짜 층을 두는데, 사용자가 한 번 만지는 순간 이 층은 완전히 치워야 합니다. 두 데모 다 겹침 층을 투명도로만 숨기지 않고 아예 화면에서 빼는데, 어렴풋이 남겨 두면 클릭을 가로채거나 스크린 리더가 유령 줄을 읽기 때문입니다. 같은 겹침 층이 그림 위에서 쓰이는 판은 이미지 확대 보기 UI에서 로딩 띠로 다시 나타납니다. 이 네 가지를 모두 고친 뒤의 소스는 압축 비밀번호 pbqranpk를 넣어야 열리는 zip에 아홉 벌 그대로 담겨 있습니다.
접근성
아홉 개 전부 시스템 설정이 움직임 줄이기로 되어 있으면 자동 재생 루프를 끄지만, 무엇을 끄고 무엇을 남기는지는 제각각입니다. 01은 반복 네 개와 전이 두 개를 끄는 대신 실제로 눌러 여닫는 상태인 트랙 1fr·화살표 90도·열린 폴더 아이콘을 즉시 적용하고, 02는 줄 등장 순서만 꺼지고 세로 들여쓰기 줄과 형식 라벨은 그대로 남습니다. 03은 막대 루프와 경로 교체가 꺼져도 첫 경로를 남겨 경로 줄이 빈칸이 되지 않게 했고, 04는 드롭 대상 테두리와 파일 이동 결과를 그대로 둔 채 유령 조각만 지웁니다. 05는 고른 배경·체크 표시·개수 배지가 즉시 바뀌고, 06은 끌 전이가 원래 없어 루프만 꺼도 실제 필터가 그대로 돕니다. 07은 애니메이션이 꺼진 채 메뉴가 열린 상태로 굳는 것을 막는 규칙을 따로 넣었고, 08은 원래 이름을 되살리고 가짜 입력칸을 치워 이름 칸이 빈 상태로 멈추지 않게 했으며, 09는 0에서 차오르는 연출만 꺼지고 막대는 처음부터 채워진 채로 보입니다. 트리의 역할과 속성 정의는 MDN의 tree 역할 문서에 정리돼 있습니다.
트리가 얹히는 사이드바 자체는 사이드바 서랍에서, 접고 펴는 목록의 다른 모음은 체크리스트 펼치기에서 다룹니다. 목록에서 고르는 방식은 셀렉트박스 9종에, 쳐서 좁히는 검색은 커맨드 팔레트에 모아 두었습니다.
FAQ
키보드만으로 이 폴더 트리 시각화를 다 쓸 수 있나요?
네. 아홉 개 전부 목록에 tabindex="0"을 달아 Tab으로 닿고, 그중 01·02·03·04·05·09는 위아래 화살표로 줄을 옮기며 01·02·03은 Home과 End로 양 끝까지 갑니다. 01은 왼쪽 화살표가 열린 폴더를 닫고 닫힌 폴더에서는 부모 줄로 올라가며, 04는 파일 줄에서 Enter로 이동 모드에 들어가 화살표로 대상 폴더를 고르고 Escape로 취소합니다. 06은 트리에서 /로 검색창에 가고 Escape로 비우며, 08은 두 번 누르기 대신 F2로도 편집이 열립니다. 코드를 보여 주는 창이 필요해지면 코드 블록 UI가 트리 옆칸을 맡습니다.
React 프로젝트에도 바로 붙일 수 있나요?
붙일 수 있습니다. zip 안에는 순수 버전 아홉 벌과 함께 React 판 아홉 벌이 들어 있고, 순수 버전은 각자 index.html과 style.scss 두 파일로 외부 라이브러리·웹폰트·CDN을 하나도 안 씁니다. 스타일 맨 위에 시간·곡선·색 변수가 모여 있어 값을 한 곳에서 바꾸면 되고, 07의 120ms 하이라이트·04의 320ms 안착·08의 480ms 확인 표시처럼 한 번만 쓰는 값 몇 개만 그 밖에 따로 적혀 있습니다.
폴더가 접힐 때 뚝뚝 끊기는데 어디를 볼까요?
접히는 대상을 높이값으로 만들었는지 먼저 보세요. 이 편의 접는 트리는 감싼 요소를 격자로 두고 트랙을 0fr에서 1fr로 전환하는데, 안쪽 요소에 overflow: hidden과 min-height: 0이 빠지면 트랙이 0이어도 내용이 삐져나와 전혀 접히지 않습니다. 01·02·04의 소스에 이 두 줄이 있는지 확인하면 대부분 해결됩니다.









