03選択ハイライトとパンくず
選んだ行だけに色バーが乗り、バーは行の間を滑って移動し、上のパス行に今選んだファイルの場所が繋がります。
使いどころツリーが長くなって、開いているファイルがどのフォルダの中か分からなくなりやすい編集画面や資料室のサイドバー
translateYaria-activedescendantsteps(1,end)

<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 に同梱 · ファイルツリー 作成 9選 — フォルダが実際に開くツリービュー
このデモが属する記事
ファイルツリー 作成 9選 — フォルダが実際に開くツリービューファイルツリー 作成 とは、エクスプローラーの左側にあるような階層リストを HTML と CSS、それに少しの JavaScript で組み立てることです。9つとも、フォルダは画像でも飾りでもなく実際に開き、移動も選択もマウスと矢印キーの両 この記事のデモをすべて見る


