Skip to content

Commit 9386a1a

Browse files
michaelmcneesclaude
andcommitted
fix: address all 9 Copilot review comments on PR #6
Search component: - Split into DocsSearch (trigger only) + SearchModal (single shared modal) - SearchModal rendered once in Base.astro, no duplicate IDs - Global Cmd+K handler registered once with guard flag - Excerpt sanitized via DOMParser (only <mark> tags allowed) - Dialog a11y: role="dialog", aria-modal="true", aria-label Navigation: - isDocs derived from Astro.url.pathname.startsWith('/docs') - Nav/Sidebar overlay: use onclick assignment, no cloneNode (fixes detached ref) Content: - data-pagefind-ignore wraps entire landing page (Nav + Footer excluded too) - Removed unused .table-wrapper CSS, applied scroll directly to .prose table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 608cb82 commit 9386a1a

7 files changed

Lines changed: 207 additions & 169 deletions

File tree

Lines changed: 14 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,16 @@
11
---
2+
// This component renders only a trigger button.
3+
// The search modal is rendered once in Base.astro via SearchModal.
24
---
3-
<div class="relative" id="search-container">
4-
<button
5-
id="search-trigger"
6-
class="flex items-center gap-2 px-3 py-1.5 text-xs text-on-surface-variant border border-outline-variant bg-surface-container-low hover:border-outline transition-colors font-mono w-full lg:w-48"
7-
type="button"
8-
>
9-
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
11-
</svg>
12-
<span class="flex-1 text-left">Search docs...</span>
13-
<kbd class="hidden lg:inline text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1 rounded-sm">⌘K</kbd>
14-
</button>
15-
</div>
16-
17-
<!-- Search modal -->
18-
<div id="search-modal" class="fixed inset-0 z-[100] hidden">
19-
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" id="search-backdrop"></div>
20-
<div class="fixed top-[15%] left-1/2 -translate-x-1/2 w-full max-w-xl bg-surface-container border border-outline-variant shadow-2xl max-h-[60vh] flex flex-col">
21-
<div class="flex items-center gap-3 px-4 py-3 border-b border-outline-variant">
22-
<svg class="w-4 h-4 text-on-surface-variant shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
23-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
24-
</svg>
25-
<input
26-
id="search-input"
27-
type="text"
28-
placeholder="Search documentation..."
29-
class="flex-1 bg-transparent text-on-surface text-sm outline-none placeholder:text-on-surface-variant/50 font-body"
30-
autocomplete="off"
31-
/>
32-
<kbd class="text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1.5 py-0.5 rounded-sm">Esc</kbd>
33-
</div>
34-
<div id="search-results" class="overflow-y-auto p-2 text-sm">
35-
<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>
36-
</div>
37-
</div>
38-
</div>
39-
40-
<script is:inline>
41-
function initSearch() {
42-
const trigger = document.getElementById('search-trigger');
43-
const modal = document.getElementById('search-modal');
44-
const backdrop = document.getElementById('search-backdrop');
45-
const input = document.getElementById('search-input');
46-
const results = document.getElementById('search-results');
47-
if (!trigger || !modal || !input || !results) return;
48-
49-
let pagefind = null;
50-
51-
async function loadPagefind() {
52-
if (pagefind) return pagefind;
53-
try {
54-
pagefind = await import('/pagefind/pagefind.js');
55-
await pagefind.init();
56-
} catch (e) {
57-
console.warn('Pagefind not available (run build first):', e);
58-
pagefind = null;
59-
}
60-
return pagefind;
61-
}
62-
63-
function openSearch() {
64-
modal.classList.remove('hidden');
65-
input.value = '';
66-
input.focus();
67-
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>';
68-
}
69-
70-
function closeSearch() {
71-
modal.classList.add('hidden');
72-
}
73-
74-
trigger.onclick = openSearch;
75-
backdrop.onclick = closeSearch;
76-
77-
// Cmd+K / Ctrl+K shortcut
78-
document.addEventListener('keydown', (e) => {
79-
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
80-
e.preventDefault();
81-
if (modal.classList.contains('hidden')) openSearch();
82-
else closeSearch();
83-
}
84-
if (e.key === 'Escape') closeSearch();
85-
});
86-
87-
let debounceTimer;
88-
input.addEventListener('input', () => {
89-
clearTimeout(debounceTimer);
90-
debounceTimer = setTimeout(async () => {
91-
const query = input.value.trim();
92-
if (!query) {
93-
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>';
94-
return;
95-
}
96-
97-
const pf = await loadPagefind();
98-
if (!pf) {
99-
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Search unavailable (build required)</p>';
100-
return;
101-
}
102-
103-
const search = await pf.search(query);
104-
if (search.results.length === 0) {
105-
results.innerHTML = '<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">No results for &quot;' + query.replace(/[<>"&]/g, '') + '&quot;</p>';
106-
return;
107-
}
108-
109-
const items = await Promise.all(search.results.slice(0, 8).map(r => r.data()));
110-
const container = document.createElement('div');
111-
items.forEach(item => {
112-
const link = document.createElement('a');
113-
link.href = item.url;
114-
link.className = 'block px-3 py-2 hover:bg-surface-container-high transition-colors group';
115-
116-
const title = document.createElement('div');
117-
title.className = 'font-medium text-on-surface text-sm group-hover:text-primary transition-colors';
118-
title.textContent = item.meta?.title || item.url;
119-
link.appendChild(title);
120-
121-
const excerpt = document.createElement('div');
122-
excerpt.className = 'text-xs text-on-surface-variant mt-0.5 line-clamp-2';
123-
// Pagefind excerpt contains <mark> tags from our own build index — safe to render
124-
excerpt.innerHTML = item.excerpt;
125-
link.appendChild(excerpt);
126-
127-
link.addEventListener('click', closeSearch);
128-
container.appendChild(link);
129-
});
130-
131-
results.replaceChildren(...container.childNodes);
132-
}, 200);
133-
});
134-
}
135-
initSearch();
136-
document.addEventListener('astro:after-swap', initSearch);
137-
</script>
5+
<button
6+
data-search-trigger
7+
class="flex items-center gap-2 px-3 py-1.5 text-xs text-on-surface-variant border border-outline-variant bg-surface-container-low hover:border-outline transition-colors font-mono w-full lg:w-48"
8+
type="button"
9+
aria-label="Search documentation"
10+
>
11+
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
13+
</svg>
14+
<span class="flex-1 text-left">Search docs...</span>
15+
<kbd class="hidden lg:inline text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1 rounded-sm">⌘K</kbd>
16+
</button>

site/src/components/DocsSidebar.astro

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -187,21 +187,20 @@ function isParentActive(item: NavItem): boolean {
187187

188188
<script is:inline>
189189
function initSidebar() {
190-
const mobileSidebar = document.getElementById('docs-sidebar-mobile');
191-
const overlay = document.getElementById('sidebar-overlay');
192-
if (!mobileSidebar || !overlay) return;
190+
var mobileSidebar = document.getElementById('docs-sidebar-mobile');
191+
if (!mobileSidebar) return;
193192

194193
function closeSidebar() {
195194
mobileSidebar.classList.add('-translate-x-full');
196-
overlay.classList.add('hidden');
195+
var overlay = document.getElementById('sidebar-overlay');
196+
if (overlay) overlay.classList.add('hidden');
197197
}
198198

199-
const newOverlay = overlay.cloneNode(true);
200-
overlay.parentNode?.replaceChild(newOverlay, overlay);
201-
newOverlay.addEventListener('click', closeSidebar);
199+
var overlay = document.getElementById('sidebar-overlay');
200+
if (overlay) overlay.onclick = closeSidebar;
202201

203-
mobileSidebar.querySelectorAll('a').forEach((a) => {
204-
a.addEventListener('click', closeSidebar);
202+
mobileSidebar.querySelectorAll('a').forEach(function(a) {
203+
a.onclick = closeSidebar;
205204
});
206205
}
207206
initSidebar();

site/src/components/Nav.astro

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface Props {
66
}
77
const { isHome = false } = Astro.props;
88
const prefix = isHome ? '' : '/';
9-
const isDocs = !isHome;
9+
const isDocs = Astro.url.pathname.startsWith('/docs');
1010
---
1111
<nav class="fixed top-0 left-0 right-0 z-50 border-b border-outline-variant bg-surface/80 backdrop-blur-md">
1212
<div class={`nav-inner mx-auto px-6 flex items-center justify-between h-16 ${isHome ? 'max-w-[1200px]' : 'max-w-[1280px]'}`}>
@@ -85,25 +85,21 @@ const isDocs = !isHome;
8585

8686
<script is:inline>
8787
function initNav() {
88-
const btn = document.getElementById('bottom-nav-docs-btn');
89-
const docsSidebar = document.getElementById('docs-sidebar-mobile');
90-
const overlay = document.getElementById('sidebar-overlay');
91-
if (!btn || !docsSidebar || !overlay) return;
88+
var btn = document.getElementById('bottom-nav-docs-btn');
89+
var docsSidebar = document.getElementById('docs-sidebar-mobile');
90+
if (!btn || !docsSidebar) return;
9291

93-
// Remove old listeners by cloning
94-
const newBtn = btn.cloneNode(true);
95-
btn.parentNode?.replaceChild(newBtn, btn);
96-
97-
newBtn.addEventListener('click', () => {
98-
const isOpen = !docsSidebar.classList.contains('-translate-x-full');
92+
btn.onclick = function() {
93+
var overlay = document.getElementById('sidebar-overlay');
94+
var isOpen = !docsSidebar.classList.contains('-translate-x-full');
9995
if (isOpen) {
10096
docsSidebar.classList.add('-translate-x-full');
101-
overlay.classList.add('hidden');
97+
if (overlay) overlay.classList.add('hidden');
10298
} else {
10399
docsSidebar.classList.remove('-translate-x-full');
104-
overlay.classList.remove('hidden');
100+
if (overlay) overlay.classList.remove('hidden');
105101
}
106-
});
102+
};
107103
}
108104
initNav();
109105
document.addEventListener('astro:after-swap', initNav);
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
// Single search modal rendered once in Base.astro.
3+
// Multiple DocsSearch trigger buttons open this shared modal.
4+
---
5+
<div id="search-modal" class="fixed inset-0 z-[100] hidden" role="dialog" aria-modal="true" aria-label="Search documentation">
6+
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" data-search-backdrop></div>
7+
<div class="fixed top-[15%] left-1/2 -translate-x-1/2 w-[calc(100%-2rem)] max-w-xl bg-surface-container border border-outline-variant shadow-2xl max-h-[60vh] flex flex-col">
8+
<div class="flex items-center gap-3 px-4 py-3 border-b border-outline-variant">
9+
<svg class="w-4 h-4 text-on-surface-variant shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
11+
</svg>
12+
<input
13+
data-search-input
14+
type="text"
15+
placeholder="Search documentation..."
16+
class="flex-1 bg-transparent text-on-surface text-sm outline-none placeholder:text-on-surface-variant/50 font-body"
17+
autocomplete="off"
18+
/>
19+
<kbd class="text-[10px] text-on-surface-variant/60 border border-outline-variant/50 px-1.5 py-0.5 rounded-sm">Esc</kbd>
20+
</div>
21+
<div data-search-results class="overflow-y-auto p-2 text-sm">
22+
<p class="px-3 py-4 text-on-surface-variant/60 text-center text-xs">Type to search...</p>
23+
</div>
24+
</div>
25+
</div>
26+
27+
<script is:inline>
28+
(function() {
29+
if (window.__searchInitialized) return;
30+
window.__searchInitialized = true;
31+
32+
var pagefind = null;
33+
34+
function loadPagefind() {
35+
if (pagefind) return Promise.resolve(pagefind);
36+
return import('/pagefind/pagefind.js').then(function(pf) {
37+
pagefind = pf;
38+
return pf.init().then(function() { return pf; });
39+
}).catch(function() { pagefind = null; return null; });
40+
}
41+
42+
function getModal() { return document.getElementById('search-modal'); }
43+
function getInput() { return document.querySelector('[data-search-input]'); }
44+
function getResults() { return document.querySelector('[data-search-results]'); }
45+
46+
function setResultsText(msg) {
47+
var r = getResults();
48+
if (!r) return;
49+
while (r.firstChild) r.removeChild(r.firstChild);
50+
var p = document.createElement('p');
51+
p.className = 'px-3 py-4 text-on-surface-variant/60 text-center text-xs';
52+
p.textContent = msg;
53+
r.appendChild(p);
54+
}
55+
56+
function openSearch() {
57+
var modal = getModal();
58+
var input = getInput();
59+
if (!modal || !input) return;
60+
modal.classList.remove('hidden');
61+
input.value = '';
62+
input.focus();
63+
setResultsText('Type to search...');
64+
}
65+
66+
function closeSearch() {
67+
var modal = getModal();
68+
if (modal) modal.classList.add('hidden');
69+
}
70+
71+
// Sanitize Pagefind excerpt: strip all HTML except <mark> tags
72+
function sanitizeExcerpt(html) {
73+
var tmp = document.createElement('div');
74+
tmp.textContent = '';
75+
// Parse into a temporary element to strip tags
76+
var parser = new DOMParser();
77+
var doc = parser.parseFromString(html, 'text/html');
78+
var walker = doc.body.childNodes;
79+
for (var i = 0; i < walker.length; i++) {
80+
var node = walker[i];
81+
if (node.nodeType === 3) {
82+
tmp.appendChild(document.createTextNode(node.textContent));
83+
} else if (node.nodeType === 1 && node.tagName === 'MARK') {
84+
var mark = document.createElement('mark');
85+
mark.setAttribute('data-pagefind-highlight', '');
86+
mark.textContent = node.textContent;
87+
tmp.appendChild(mark);
88+
} else {
89+
tmp.appendChild(document.createTextNode(node.textContent || ''));
90+
}
91+
}
92+
return tmp;
93+
}
94+
95+
document.addEventListener('keydown', function(e) {
96+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
97+
e.preventDefault();
98+
var modal = getModal();
99+
if (!modal) return;
100+
if (modal.classList.contains('hidden')) openSearch();
101+
else closeSearch();
102+
}
103+
if (e.key === 'Escape') closeSearch();
104+
});
105+
106+
document.addEventListener('click', function(e) {
107+
if (e.target && e.target.hasAttribute('data-search-backdrop')) closeSearch();
108+
if (e.target && e.target.closest('[data-search-trigger]')) openSearch();
109+
});
110+
111+
var debounceTimer;
112+
document.addEventListener('input', function(e) {
113+
if (!e.target || !e.target.hasAttribute('data-search-input')) return;
114+
clearTimeout(debounceTimer);
115+
debounceTimer = setTimeout(function() {
116+
var input = e.target;
117+
var results = getResults();
118+
if (!results) return;
119+
120+
var query = input.value.trim();
121+
if (!query) {
122+
setResultsText('Type to search...');
123+
return;
124+
}
125+
126+
loadPagefind().then(function(pf) {
127+
if (!pf) {
128+
setResultsText('Search unavailable (build required)');
129+
return;
130+
}
131+
132+
pf.search(query).then(function(search) {
133+
if (search.results.length === 0) {
134+
setResultsText('No results for "' + query + '"');
135+
return;
136+
}
137+
138+
Promise.all(search.results.slice(0, 8).map(function(r) { return r.data(); }))
139+
.then(function(items) {
140+
while (results.firstChild) results.removeChild(results.firstChild);
141+
items.forEach(function(item) {
142+
var link = document.createElement('a');
143+
link.href = item.url;
144+
link.className = 'block px-3 py-2 hover:bg-surface-container-high transition-colors group';
145+
146+
var title = document.createElement('div');
147+
title.className = 'font-medium text-on-surface text-sm group-hover:text-primary transition-colors';
148+
title.textContent = (item.meta && item.meta.title) || item.url;
149+
link.appendChild(title);
150+
151+
if (item.excerpt) {
152+
var excerptEl = sanitizeExcerpt(item.excerpt);
153+
excerptEl.className = 'text-xs text-on-surface-variant mt-0.5 line-clamp-2';
154+
link.appendChild(excerptEl);
155+
}
156+
157+
link.addEventListener('click', closeSearch);
158+
results.appendChild(link);
159+
});
160+
});
161+
});
162+
});
163+
}, 200);
164+
});
165+
})();
166+
</script>

0 commit comments

Comments
 (0)