06검색으로 남기는 트리
검색창에 글자를 치면 맞는 파일만 남고 그 조상 폴더는 흐리게 길을 남기며, 맞은 글자에만 형광이 드립니다.
쓰임파일이 수백 개라 폴더를 하나하나 열어 찾는 게 느릴 때, 이름으로 바로 좁히고 싶은 큰 트리
inputmark::placeholder

<main class="stage">
<div class="sf is-demo" id="sf">
<div class="sf__bar">
<span class="sf__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="sf__stack" aria-hidden="true">
<span class="sf__t sf__t--0"></span>
<span class="sf__t sf__t--1">he</span>
<span class="sf__t sf__t--2">hero</span>
</span>
<input class="sf__input" id="sf-input" type="text" placeholder="파일 검색" autocomplete="off" aria-label="파일 검색">
</div>
<div class="sf__count" aria-live="polite">
<span class="sf__c sf__c--0">6건</span>
<span class="sf__c sf__c--2">2건</span>
<span class="sf__creal" id="sf-count" hidden>6건</span>
</div>
<ul class="sf__tree" id="sf-tree" role="tree" aria-label="파일" tabindex="0" aria-activedescendant="sf-1">
<li class="sf__f" id="sf-1" role="treeitem" aria-level="1" data-folder="site">
<div class="sf__row" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name">site</span></div>
</li>
<li class="sf__i" id="sf-2" role="treeitem" aria-level="2" data-anc="site" data-name="hero.tsx">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="sf__name">hero.tsx</span></div>
</li>
<li class="sf__i" id="sf-3" role="treeitem" aria-level="2" data-anc="site" data-name="button.tsx">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="sf__name">button.tsx</span></div>
</li>
<li class="sf__f" id="sf-4" role="treeitem" aria-level="1" data-folder="assets">
<div class="sf__row" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name">assets</span></div>
</li>
<li class="sf__i" id="sf-5" role="treeitem" aria-level="2" data-anc="assets" data-name="hero.png">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="sf__name">hero.png</span></div>
</li>
<li class="sf__i" id="sf-6" role="treeitem" aria-level="1" data-name="bundle.zip">
<div class="sf__row" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span><span class="sf__name">bundle.zip</span></div>
</li>
</ul>
<div class="sf__hits" aria-hidden="true">
<div class="sf__hrow"><span class="sf__fi"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name sf__name--path">site</span></div>
<div class="sf__hrow sf__hrow--deep"><span class="sf__fi"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="sf__name"><mark class="sf__m">hero</mark>.tsx</span></div>
<div class="sf__hrow"><span class="sf__fi"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name sf__name--path">assets</span></div>
<div class="sf__hrow sf__hrow--deep"><span class="sf__fi"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="sf__name"><mark class="sf__m">hero</mark>.png</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.sf {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.sf__bar {
position: relative;
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-10 0 $sp-2;
margin-bottom: $sp-2;
border-radius: $r-sm;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
}
.sf__ico { display: inline-flex; flex: 0 0 auto; color: rgba(23, 20, 26, .45); }
.sf__stack { position: relative; display: block; flex: 1; height: 28px; }
.sf__t {
position: absolute; left: 0; top: 0;
line-height: 28px;
font-size: 12px; font-weight: 600;
opacity: 0;
}
.sf__t--0 { opacity: 1; }
.sf__input {
position: absolute; inset: 0;
border: 0; padding: 0;
background: transparent;
font-family: inherit;
font-size: 12px; line-height: 28px; font-weight: 600;
color: $stage-ink;
outline: none;
}
.sf.is-demo .sf__input::placeholder { color: transparent; }
.sf:not(.is-demo) .sf__stack { display: none; }
.sf__count {
position: absolute;
top: 6px; right: 0;
height: 20px;
padding: 0 $sp-2;
display: inline-flex; align-items: center;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
}
.sf__c, .sf__creal {
position: absolute; right: $sp-2;
font-size: 10px; line-height: 20px; font-weight: 800;
white-space: nowrap;
}
.sf__c { opacity: 0; }
.sf__c--0 { opacity: 1; }
.sf__c--2 { color: $color; }
.sf__tree { list-style: none; margin: 0; padding: 0; }
.sf__tree:focus-visible { outline: none; }
.sf__tree li[aria-selected="true"] > .sf__row { outline: 2px solid $color; outline-offset: -2px; }
.sf__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.sf__row:hover { background: rgba(23, 20, 26, .07); }
.sf__row--deep { margin-left: $sp-4; }
.sf__tree li:last-child .sf__row { margin-bottom: 0; }
.sf__fi { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.sf__f .sf__fi { color: $color; }
.sf__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sf__name--path { color: rgba(23, 20, 26, .45); }
.sf__m {
background: $stage-yellow;
color: $stage-ink;
font-weight: 800;
}
// 검색 결과 상태 — 실제 트리 위에 겹친다
.sf:not(.is-demo) .sf__hits { display: none; }
.sf__hits {
position: absolute;
top: 36px; left: 0; right: 0;
opacity: 0;
pointer-events: none;
}
// ── 자동 루프: he → hero 로 치면 흐린 경로와 일치 행만 남는다 ──
.sf.is-demo .sf__tree {
animation-name: sf-full;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-full {
0%, 6% { opacity: 1; }
16.9% { opacity: 1; }
17% { opacity: 0; }
92% { opacity: 0; }
100% { opacity: 1; }
}
.sf.is-demo .sf__hits {
animation-name: sf-hit;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-hit {
0%, 16.9% { opacity: 0; }
17% { opacity: 1; }
80% { opacity: 1; }
92%, 100% { opacity: 0; }
}
.sf.is-demo .sf__t--0 {
animation-name: sf-t0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t0 { 0% { opacity: 1; } 13% { opacity: 0; } 92% { opacity: 1; } 100% { opacity: 1; } }
.sf.is-demo .sf__t--1 {
animation-name: sf-t1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t1 { 0% { opacity: 0; } 13% { opacity: 1; } 17% { opacity: 0; } 100% { opacity: 0; } }
.sf.is-demo .sf__t--2 {
animation-name: sf-t2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t2 { 0% { opacity: 0; } 17% { opacity: 1; } 92% { opacity: 0; } 100% { opacity: 0; } }
.sf.is-demo .sf__c--0 {
animation-name: sf-c0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c0 { 0% { opacity: 1; } 17% { opacity: 0; } 92% { opacity: 1; } 100% { opacity: 1; } }
.sf.is-demo .sf__c--2 {
animation-name: sf-c2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c2 { 0% { opacity: 0; } 17% { opacity: 1; } 92% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.sf__bar { height: 24px; }
.sf__stack { height: 24px; }
.sf__t { line-height: 24px; }
.sf__input { line-height: 24px; }
.sf__hits { top: 32px; }
.sf__row { height: 20px; font-size: 11px; }
.sf__row--deep { margin-left: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__tree, .sf.is-demo .sf__hits, .sf.is-demo .sf__t, .sf.is-demo .sf__c { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.sf__bar {
position: relative;
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 40px 0 8px;
margin-bottom: 8px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
}
.sf__ico {
display: inline-flex;
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.45);
}
.sf__stack {
position: relative;
display: block;
flex: 1;
height: 28px;
}
.sf__t {
position: absolute;
left: 0;
top: 0;
line-height: 28px;
font-size: 12px;
font-weight: 600;
opacity: 0;
}
.sf__t--0 {
opacity: 1;
}
.sf__input {
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
line-height: 28px;
font-weight: 600;
color: #17141a;
outline: none;
}
.sf.is-demo .sf__input::placeholder {
color: transparent;
}
.sf:not(.is-demo) .sf__stack {
display: none;
}
.sf__count {
position: absolute;
top: 6px;
right: 0;
height: 20px;
padding: 0 8px;
display: inline-flex;
align-items: center;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.sf__c, .sf__creal {
position: absolute;
right: 8px;
font-size: 10px;
line-height: 20px;
font-weight: 800;
white-space: nowrap;
}
.sf__c {
opacity: 0;
}
.sf__c--0 {
opacity: 1;
}
.sf__c--2 {
color: #2f6df6;
}
.sf__tree {
list-style: none;
margin: 0;
padding: 0;
}
.sf__tree:focus-visible {
outline: none;
}
.sf__tree li[aria-selected=true] > .sf__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sf__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.sf__row:hover {
background: rgba(23, 20, 26, 0.07);
}
.sf__row--deep {
margin-left: 16px;
}
.sf__tree li:last-child .sf__row {
margin-bottom: 0;
}
.sf__fi {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.sf__f .sf__fi {
color: #2f6df6;
}
.sf__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sf__name--path {
color: rgba(23, 20, 26, 0.45);
}
.sf__m {
background: #ffd23f;
color: #17141a;
font-weight: 800;
}
.sf:not(.is-demo) .sf__hits {
display: none;
}
.sf__hits {
position: absolute;
top: 36px;
left: 0;
right: 0;
opacity: 0;
pointer-events: none;
}
.sf.is-demo .sf__tree {
animation-name: sf-full;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-full {
0%, 6% {
opacity: 1;
}
16.9% {
opacity: 1;
}
17% {
opacity: 0;
}
92% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__hits {
animation-name: sf-hit;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-hit {
0%, 16.9% {
opacity: 0;
}
17% {
opacity: 1;
}
80% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
.sf.is-demo .sf__t--0 {
animation-name: sf-t0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t0 {
0% {
opacity: 1;
}
13% {
opacity: 0;
}
92% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__t--1 {
animation-name: sf-t1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t1 {
0% {
opacity: 0;
}
13% {
opacity: 1;
}
17% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sf.is-demo .sf__t--2 {
animation-name: sf-t2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t2 {
0% {
opacity: 0;
}
17% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sf.is-demo .sf__c--0 {
animation-name: sf-c0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c0 {
0% {
opacity: 1;
}
17% {
opacity: 0;
}
92% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__c--2 {
animation-name: sf-c2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c2 {
0% {
opacity: 0;
}
17% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sf__bar {
height: 24px;
}
.sf__stack {
height: 24px;
}
.sf__t {
line-height: 24px;
}
.sf__input {
line-height: 24px;
}
.sf__hits {
top: 32px;
}
.sf__row {
height: 20px;
font-size: 11px;
}
.sf__row--deep {
margin-left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__tree, .sf.is-demo .sf__hits, .sf.is-demo .sf__t, .sf.is-demo .sf__c {
animation: none !important;
}
}// 실제 동작: 칠 때마다 목록이 줄고 조상 폴더는 흐린 길로 남는다.
var root = document.querySelector('.sf');
var input = document.getElementById('sf-input');
var count = document.getElementById('sf-count');
var tree = document.getElementById('sf-tree');
var files = [].slice.call(tree.querySelectorAll('.sf__i'));
var folders = [].slice.call(tree.querySelectorAll('.sf__f'));
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<'); }
function render(q) {
root.classList.remove('is-demo');
document.querySelectorAll('.sf__t, .sf__c').forEach(function (t) { t.hidden = true; });
count.hidden = false;
var hits = 0;
files.forEach(function (li) {
var name = li.dataset.name, k = q ? name.toLowerCase().indexOf(q) : -1;
var hit = !q || k >= 0;
li.hidden = !hit;
if (hit && q) {
hits++;
li.querySelector('.sf__name').innerHTML = esc(name.slice(0, k)) + '<mark class="sf__m">' + esc(name.slice(k, k + q.length)) + '</mark>' + esc(name.slice(k + q.length));
} else {
li.querySelector('.sf__name').textContent = name;
}
});
folders.forEach(function (f) {
var alive = files.some(function (li) { return !li.hidden && li.dataset.anc === f.dataset.folder; });
f.hidden = !alive;
f.classList.toggle('is-path', alive && !!q);
});
count.textContent = (q ? hits : files.length) + '건';
}
input.addEventListener('input', function () { render(input.value.trim().toLowerCase()); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { input.value = ''; render(''); tree.focus({ preventScroll: true }); }
});
tree.addEventListener('keydown', function (e) {
if (e.key === '/' && document.activeElement !== input) { input.focus({ preventScroll: true }); e.preventDefault(); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SearchFilterTree.module.scss";
export type NodeKind = "folder" | "ts" | "js" | "img" | "zip";
export type TreeNode = { name: string; kind: NodeKind; level: 1 | 2; parent?: string };
export type SearchFilterTreeProps = {
placeholder?: string;
nodes?: TreeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type Glyph = { dim: string; solid: string };
const PAGE = "M208 88h-56V32Z";
const GLYPH: Record<NodeKind, Glyph> = {
folder: {
dim: "M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z",
solid: "M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z",
},
ts: {
dim: PAGE,
solid: "m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8",
},
js: {
dim: PAGE,
solid: "m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0",
},
img: {
dim: "m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z",
solid: "M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z",
},
zip: {
dim: PAGE,
solid: "M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z",
},
};
const SEARCH: Glyph = {
dim: "M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80",
solid: "m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72",
};
function Icon({ glyph, size }: { glyph: Glyph; size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size}>
<g fill="currentColor">
<path d={glyph.dim} opacity=".2" />
<path d={glyph.solid} />
</g>
</svg>
);
}
function Name({ name, q }: { name: string; q: string }) {
const k = q ? name.toLowerCase().indexOf(q) : -1;
if (k < 0) return <span className={styles.name}>{name}</span>;
return (
<span className={styles.name}>
{name.slice(0, k)}
<mark className={styles.mark}>{name.slice(k, k + q.length)}</mark>
{name.slice(k + q.length)}
</span>
);
}
const DEFAULT_NODES: TreeNode[] = [
{ name: "site", kind: "folder", level: 1 },
{ name: "hero.tsx", kind: "ts", level: 2, parent: "site" },
{ name: "button.tsx", kind: "js", level: 2, parent: "site" },
{ name: "assets", kind: "folder", level: 1 },
{ name: "hero.png", kind: "img", level: 2, parent: "assets" },
{ name: "bundle.zip", kind: "zip", level: 1 },
];
export default function SearchFilterTree({
placeholder = "파일 검색",
nodes = DEFAULT_NODES,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SearchFilterTreeProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const q = query.trim().toLowerCase();
function fileHit(n: TreeNode): boolean {
return n.kind !== "folder" && (!q || n.name.toLowerCase().indexOf(q) >= 0);
}
function folderAlive(n: TreeNode): boolean {
return nodes.some((c) => c.parent === n.name && fileHit(c));
}
const shown = nodes.map((n) => (n.kind === "folder" ? folderAlive(n) : fileHit(n)));
const files = nodes.filter((n) => n.kind !== "folder");
const hits = q ? files.filter(fileHit).length : files.length;
const first = shown.indexOf(true);
const activeId = `sf-n${first < 0 ? 0 : first}`;
function onInputKey(e: KeyEvt) {
if (e.key === "Escape") {
setQuery("");
treeRef.current?.focus({ preventScroll: true });
}
}
function onTreeKey(e: KeyEvt) {
if (e.key === "/") {
inputRef.current?.focus({ preventScroll: true });
e.preventDefault();
}
}
return (
<div className={styles.sf} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<Icon glyph={SEARCH} size={13} />
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
value={query}
placeholder={placeholder}
autoComplete="off"
aria-label={placeholder}
onChange={(e: ChangeEvt) => setQuery(e.target.value)}
onKeyDown={onInputKey}
/>
</div>
<div className={styles.count} aria-live="polite">{`${hits}건`}</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={placeholder}
tabIndex={0}
aria-activedescendant={activeId}
onKeyDown={onTreeKey}
>
{nodes.map((n, i) => {
const folder = n.kind === "folder";
const path = folder && !!q;
return (
<li
key={n.name}
id={`sf-n${i}`}
className={`${styles.item} ${folder ? styles.isFolder : ""} ${path ? styles.isPath : ""}`}
role="treeitem"
aria-level={n.level}
hidden={!shown[i]}
>
<div className={`${styles.row} ${n.level === 2 ? styles.rowDeep : ""}`}>
<span className={styles.fi} aria-hidden="true">
<Icon glyph={GLYPH[n.kind]} size={folder ? 13 : 12} />
</span>
{folder ? <span className={styles.name}>{n.name}</span> : <Name name={n.name} q={q} />}
</div>
</li>
);
})}
</ul>
</div>
);
}06 검색으로 남기는 트리 — 측정 누적면적 2.089% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바
이 데모가 속한 글
파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바파일 트리 만들기는 폴더와 문서가 겹겹이 들어간 목록을, 누르면 실제로 펼쳐지고 접히는 사이드바로 직접 짜는 일입니다. 여기 아홉 개는 모양만 비슷한 게 아니라 끌어 넣기, 이름 고치기, 검색으로 좁히기, 용량 보기 이 글의 데모 전부 보기


