ファイルツリー 作成 9選 — フォルダが実際に開くツリービュー
ファイルツリー 作成 とは、エクスプローラーの左側にあるような階層リストを HTML と CSS、それに少しの JavaScript で組み立てることです。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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 キャレットで開く基本ツリー
- 02 種類別アイコンとインデント線
- 03 選択ハイライトとパンくず
- 04 ドラッグでフォルダへ入れる
- 05 Shift・Ctrl 複数選択
- 06 検索で絞るツリー
- 07 右クリック コンテキストメニュー
- 08 その場で名前を変える
- 09 容量バー付きツリー
並び順は人気ではなく、人がツリーを実際に使う順です。まずフォルダの開閉(01)がすべての土台で、深くなっても階層が読める表示(02)、選んだ行を示す強調とパンくず(03)が続きます。ここから先はファイルを操作する順です。整理のためにドラッグでフォルダへ入れ(04)、まとめて扱うために複数選択を用意し(05)、数が増えたら名前で絞り込み(06)、操作は右クリックにまとめ(07)、名前はその場で直します(08)。最後の09だけは操作する側ではなく眺める側の番号で、行ごとの容量をバーで示します。ファイルツリーとは階層そのものなので、9つとも一覧に role="tree" と tabindex="0" が、行ごとに role="treeitem" と aria-level が付き、キー操作は項目ごとに違います。01・02・03 は矢印キーと Home・End、04 と 07 は矢印キーと Enter・Escape、05 は矢印キーと Space、06 は / と Escape、08 は F2 と Enter・Escape、09 は矢印キーと Enter です。ツリーが空のときに見せる画面はエンプティステート デザインに、ファイルではなく数字や文字が並ぶ場合はテーブル UI デザインにそれぞれ対応します。
| 番号 | 仕掛けの核 | 累積面積 | 動いたフレーム |
|---|---|---|---|
| 01 | grid-template-rows 0fr→1fr | 12.287% | 9/24 |
| 02 | インデント線 + animation-delay | 5.168% | 11/24 |
| 03 | translateY で動くバー1本 | 13.612% | 9/24 |
| 04 | setPointerCapture + elementFromPoint | 13.852% | 14/24 |
| 05 | shiftKey 範囲 + ctrlKey トグル | 16.023% | 8/24 |
| 06 | li.hidden + mark | 2.089% | 6/24 |
| 07 | contextmenu + translateY | 21.478% | 15/24 |
| 08 | input をその場に挿入 | 2.036% | 11/24 |
| 09 | scaleX(var(--v)) | 1.785% | 12/24 |
01キャレットで開く基本ツリー
「ファイルツリー 書き方」で何が見えるか
フォルダ行の三角を押すと子の行が実際にスライドして開き、もう一度押すと閉じます。畳む仕組みは、子孫を1本ずつ動かすのではなく、グリッドのトラックそのものを grid-template-rows で 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
同じ仕掛けで畳むリストは開閉リストのパターン集にまとめてあります。
// 開閉 — 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種類別アイコンとインデント線
画面で起きること
ファイルごとに種類別のアイコンが付き、階層が深くなるほど縦のインデント線が引かれて、どのまとまりに入っているのかが一目で分かります。線は深さごとの border-left: 1px solid で、第3階層は色を薄くして読み分けます。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
行ごとにインラインの --i へ 0 から 6 までの番号が振ってあり、animation-delay: calc(60ms + var(--i) * 80ms) の順に少しずつ遅れて現れます。
.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選択ハイライトとパンくず
どんな動きか
選んだ行だけに色の付いたバーが乗り、バーは行から行へ滑って移動します。行ごとに背景を塗り直す代わりに position: absolute のバー1本を translateY で動かし、移動量のもとになる行の高さは offsetHeight で実測します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
上のパンくずは3枚の文字を steps(1, end) で切り替えて繋ぎ、選んだ行を aria-activedescendant で指す作りはセレクトボックスの記事と同じ土台です。
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ドラッグでフォルダへ入れる
何が見えるか
ファイル行をドラッグしてフォルダに重ねると、フォルダ側が枠と背景で反応し、離した瞬間にその中へ実際に移動します。ポインタは setPointerCapture で1つの要素に固定し、指先の下にある要素を document.elementFromPoint() で調べ、移し先が決まったら appendChild で本物の DOM を動かします。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
フォルダは .dg__f:has(.dg__kids li) だけで開くので、開閉のための JavaScript は要りません。
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 複数選択
画面で起きること
1行を選んでから Shift で範囲を取り、Ctrl か Cmd で個別に拾うと、選んだ行に背景とチェックが重なり、上のバッジに個数が出ます。範囲選択も個別選択も直前の選択も、基準は anchor 変数1つで足ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
一覧には aria-multiselectable="true" が付き、個数は aria-live="polite" の領域が読み上げます。
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検索で絞るツリー
どんな動きか
検索欄に文字を打つと一致するファイルだけが残り、上位のフォルダは薄いまま道筋を残し、一致した文字にだけマーカーが付きます。一致しない行は li.hidden で表示からも読み上げからも外し、当てはまった文字は <mark> で包みます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
検索欄への出入りは / キーと Escape で、どちらも focus({ preventScroll: true }) を通して画面を飛ばさずに移します。
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 で閉じます。座標は Math.min(x - r.left, root.offsetWidth - 152) のように端で挟み込んで枠の外へ出さず、項目に mouseenter したときも矢印キーと同じ1本のハイライトが translateY(at * 28) で付いてきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
閉じるときは focus({ preventScroll: true }) でフォーカスを元の行へ戻します。
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その場で名前を変える
画面で起きること
行をダブルクリックするか F2 を押すと、名前の場所がそのまま入力欄に変わり、元の名前がすべて選択された状態で始まります。入力欄は高さ 22px・font-family: inherit で行の文字と同じ書体にそろえ、確定の印は 480ms だけ scale(.5→1) で弾みます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
Escape なら隠しておいた元の名前をそのまま戻し、入力欄も一緒に片付けます。
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容量バー付きツリー
どんな動きか
行ごとに容量バーが下に敷かれて左端から満ち、どれか1行を押すとその行を基準にすべてのバーが引き直されます。バーは width ではなく transform: scaleX(var(--v)) で、transform-origin: left center を起点に右へ伸びます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
入れ物には role="meter" と aria-valuenow が付き、基準を変えるたびに値が書き換わります。
.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';
}
どこで壊れるか — 落とし穴
まず折りたたみです。height: auto は transition の対象にならないため、子が何行あっても滑らかには畳めません。この記事で畳む3つ(01 と 02 の .ft__kids、04 の .dg__kids)はすべて、包む要素を display: grid; grid-template-rows: 0fr にして、開くとき 1fr へ遷移させます。このとき対になる .ft__kwrap と .dg__kwrap に overflow: hidden; min-height: 0 の1行がないと、トラックが 0fr でも中身がはみ出して、まったく畳めません。
次に、行の上を滑る部品のずれです。translateY(番号 * 行の高さ) で動くバーとハイライトは、幅 340px 以下では行の高さ自体が縮み、行と行の間にまたがってしまいます。03 は JavaScript 側では rowH() で offsetHeight を実測してこれを避け、CSS の自動ループ側は避けられないため @media (max-width: 340px) の中で @keyframes hl-bar を 24/48 から 22/44 へ書き直しています。07 も同じ理由で @keyframes cm-bar を 28/56/84 から 24/48/72 へ書き直しています。
3つ目は文字の重なりです。同じ場所に重ねた2枚の文字を opacity でクロスフェードさせると、途中のフレームで両方が半透明に重なってにじみます。この記事では状態の入れ替えをすべて steps(1, end) で切り替えます。01 のフォルダアイコン、03 のパンくず3枚、05 の個数、06 の入力文字、08 の名前と入力欄、09 の容量文字です。逆に位置が動くもの(バー・ドラッグのゴースト・メニュー)だけが spring と pop のイージングを使います。最後に自動再生の重ね層です。04 の .dg__after と 06 の .sf__hits は見せかけの結果を本物の一覧の上に置いた層で、触れた瞬間に classList.remove('is-demo') と display: none で完全に片付けます。opacity: 0 だけだとクリックを横取りし、スクリーンリーダーが見せかけの行を読み上げてしまいます。同じ重ね層が画像の上で使われる版は画像 拡大 表示 UIで読み込み帯として再登場します。直しまで含めた9つ分のソースは、解凍パスワード pbqranpk で開く zip にすべて入れてあります。
アクセシビリティ
9つとも prefers-reduced-motion: reduce では自動再生のループを animation: none !important で止め、状態の値はそのまま残します。01 は4つのループ(ft-kids・ft-caret・ft-icf・ft-ico)が止まって開閉の遷移もなくなりますが、クリックで開いた .is-open の結果はその場ですぐ反映されます。02 は行のアニメーションが止まっても行は opacity: 1 の既定値で見え、縦のガイド線と aria-level が残ります。03 は最初のパンくずを .ft__cs--1 { opacity: 1; } で別に書いて空白を防ぎ、04 は .dg:not(.is-demo) .dg__after { display: none; } を減速側でもう一度念押ししています。05 の選択の背景 rgba(47,109,246,.3) と aria-live の個数、06 の絞り込みとマーカー、07 のメニューの出現(ハイライトの位置だけ即時)、08 の名前変更、09 の最初から満ちたバーと aria-valuenow は、それぞれ動きを消しても働きます。07 は .cm.is-demo .cm__menu { display: none; } でメニューが開いたまま固まるのを防ぎ、08 は元の名前を戻して見せかけの入力欄を片付けます。
ツリーのロールと属性の定義は MDN の tree ロールの解説にまとまっています。フォーカスを戻すときに focus({ preventScroll: true }) を通すのは、これらの部品が一覧ページで iframe に埋め込まれており、素の focus() だと読んでいる人の画面が勝手にスクロールしてしまうためです。ツリーが置かれる場所そのものの作りはサイドバー・ドロワーの記事に続きます。
FAQ
フォルダの開閉を height ではなく grid-template-rows で作るのはなぜですか
height: auto はアニメーションできないため、子の行数が分からないと目標値も書けません。grid-template-rows: 0fr → 1fr は目標値が割合なので行数を知らなくても畳め、min-height: 0 と overflow: hidden の2行で完成します。01・02・04 の3つがこの作りです。コードを見せる窓が必要になったらコードブロック UIがツリーの隣列を担います。
ファイルツリー表示は9つともマウスなしで使えますか
使えます。9つとも一覧に tabindex="0" が付いていて Tab で届き、01・02・03・04・05・09 は上下の矢印キーで行を移れます。両端へ一気に飛ぶ Home と End が効くのは 01・02・03 の3つで、01 は左矢印で開いたフォルダを閉じ、05 は Space で選び外しができます。04 はファイルの行で Enter を押してから矢印キーで移動先のフォルダを選んで Escape で取り消し、06 は / キーで検索欄に入り、08 は F2 で名前を直せます。
zip はビルドなしで動きますか
動きます。vanilla の各フォルダに index.html・style.scss・コンパイル済みの style.css が入っており、index.html をブラウザで開くだけでその番号のデモが動きます。react/ には同じ9つの React 版が入っています。









