Skip to content

Commit 85da5fa

Browse files
feat(web): add in-title section search filter to expandable TOC (#92) (#94)
New TitleFilter.svelte component: - Debounced text filter (150ms) searches section titles - Auto-expands chapters with matches, hides empty chapters - Shows "N of M sections match" count - Clear button to reset filter - Only renders for titles with 20+ sections (skips tiny titles) - Accessible: labeled input, aria-label on clear button Integrated into /browse/title-{n}/ above the expand/collapse buttons. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f83b679 commit 85da5fa

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<script lang="ts">
2+
/**
3+
* In-title search filter for the expandable TOC.
4+
* Filters sections by keyword, auto-expanding matching chapters
5+
* and hiding non-matching sections. Zero-overhead when empty.
6+
*/
7+
8+
interface Props {
9+
/** CSS selector for the TOC container */
10+
tocSelector?: string;
11+
/** Placeholder text */
12+
placeholder?: string;
13+
}
14+
15+
let { tocSelector = '#title-toc', placeholder = 'Filter sections...' }: Props = $props();
16+
let query = $state('');
17+
let matchCount = $state(0);
18+
let totalCount = $state(0);
19+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
20+
21+
function applyFilter(q: string): void {
22+
const toc = document.querySelector(tocSelector);
23+
if (!toc) return;
24+
25+
const details = toc.querySelectorAll('details');
26+
const normalizedQuery = q.toLowerCase().trim();
27+
let matches = 0;
28+
let total = 0;
29+
30+
for (const detail of details) {
31+
const items = detail.querySelectorAll('li');
32+
let chapterHasMatch = false;
33+
34+
for (const item of items) {
35+
total++;
36+
const text = item.textContent?.toLowerCase() ?? '';
37+
if (!normalizedQuery || text.includes(normalizedQuery)) {
38+
(item as HTMLElement).style.display = '';
39+
matches++;
40+
chapterHasMatch = true;
41+
} else {
42+
(item as HTMLElement).style.display = 'none';
43+
}
44+
}
45+
46+
// Auto-expand chapters with matches, collapse empty ones
47+
if (normalizedQuery) {
48+
detail.open = chapterHasMatch;
49+
(detail as HTMLElement).style.display = chapterHasMatch ? '' : 'none';
50+
} else {
51+
// Reset: show all, restore default open state
52+
(detail as HTMLElement).style.display = '';
53+
}
54+
}
55+
56+
matchCount = matches;
57+
totalCount = total;
58+
}
59+
60+
$effect(() => {
61+
const q = query;
62+
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
63+
debounceTimer = setTimeout(() => applyFilter(q), 150);
64+
return () => {
65+
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
66+
};
67+
});
68+
</script>
69+
70+
<div class="relative font-sans">
71+
<div class="relative">
72+
<svg class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
73+
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
74+
</svg>
75+
<input
76+
type="text"
77+
bind:value={query}
78+
placeholder={placeholder}
79+
class="w-full rounded border border-gray-300 bg-white py-1.5 pl-8 pr-8 text-xs text-gray-900 placeholder-gray-400 focus:border-teal focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-600 sm:w-72"
80+
aria-label="Filter sections within this title"
81+
/>
82+
{#if query}
83+
<button
84+
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
85+
onclick={() => { query = ''; }}
86+
aria-label="Clear filter"
87+
>&times;</button>
88+
{/if}
89+
</div>
90+
{#if query}
91+
<p class="mt-1 text-[11px] text-gray-400">
92+
{matchCount} of {totalCount} sections match
93+
</p>
94+
{/if}
95+
</div>

apps/web/src/pages/browse/[title].astro

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { getCollection } from 'astro:content';
33
import BaseLayout from '../../layouts/BaseLayout.astro';
44
import Breadcrumbs from '../../components/Breadcrumbs.astro';
5+
import TitleFilter from '../../components/TitleFilter.svelte';
56
import { TITLE_NAMES } from '../../data/title-names';
67
78
export async function getStaticPaths() {
@@ -73,9 +74,16 @@ const autoExpand = sortedChapters.length <= 10;
7374
Click a chapter to expand its sections, or click a section to view its full text and change history.
7475
</p>
7576

77+
<!-- In-title search filter -->
78+
{entries.length > 20 && (
79+
<div class="not-prose mt-4">
80+
<TitleFilter client:idle placeholder={`Filter ${entries.length} sections...`} />
81+
</div>
82+
)}
83+
7684
<!-- Expand/Collapse all toggle -->
7785
{sortedChapters.length > 5 && (
78-
<div class="not-prose mt-4 mb-2 flex gap-2 font-sans text-xs">
86+
<div class="not-prose mt-3 mb-2 flex gap-2 font-sans text-xs">
7987
<button
8088
id="expand-all"
8189
class="rounded border border-gray-300 px-2 py-1 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"

0 commit comments

Comments
 (0)