GODRICH

ファイルツリー 作成 9選 — フォルダが実際に開くツリービュー

ファイルツリー 作成 とは、エクスプローラーの左側にあるような階層リストを HTML と CSS、それに少しの JavaScript で組み立てることです。9つとも、フォルダは画像でも飾りでもなく実際に開き、移動も選択もマウスと矢印キーの両方でできます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気ではなく、人がツリーを実際に使う順です。まずフォルダの開閉(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 です。

番号 仕掛けの核 累積面積 動いたフレーム
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 へ伸ばすやり方です。同じ仕掛けで畳むリストは開閉リストのパターン集にまとめてあります。

grid-template-rowsaria-expandedrole=tree
// 開閉 — 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階層は色を薄くして読み分けます。行ごとにインラインの --i へ 0 から 6 までの番号が振ってあり、animation-delay: calc(60ms + var(--i) * 80ms) の順に少しずつ遅れて現れます。

inline SVGanimation-delayaria-level
.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 で実測します。上のパンくずは3枚の文字を steps(1, end) で切り替えて繋ぎ、選んだ行を aria-activedescendant で指す作りはセレクトボックスの記事と同じ土台です。

translateYaria-activedescendantsteps(1,end)
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 を動かします。フォルダは .dg__f:has(.dg__kids li) だけで開くので、開閉のための JavaScript は要りません。

pointer eventselementFromPointEscape
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つで足ります。一覧には aria-multiselectable="true" が付き、個数は aria-live="polite" の領域が読み上げます。

shiftKeyctrlKeyaria-multiselectable
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> で包みます。検索欄への出入りは / キーと Escape で、どちらも focus({ preventScroll: true }) を通して画面を飛ばさずに移します。

inputmark::placeholder
function esc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;'); }
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) で付いてきます。閉じるときは focus({ preventScroll: true }) でフォーカスを元の行へ戻します。

contextmenurole=menuEscape
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) で弾みます。Escape なら隠しておいた元の名前をそのまま戻し、入力欄も一緒に片付けます。

F2inputEnter
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 を起点に右へ伸びます。入れ物には role="meter"aria-valuenow が付き、基準を変えるたびに値が書き換わります。

scaleXaria-valuenowtransform-origin
.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__kwrapoverflow: 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 だけだとクリックを横取りし、スクリーンリーダーが見せかけの行を読み上げてしまいます。直しまで含めた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: 0overflow: hidden の2行で完成します。01・02・04 の3つがこの作りです。

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 版が入っています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。