Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 76 additions & 43 deletions docs/src/components/Search.astro
Original file line number Diff line number Diff line change
Expand Up @@ -48,32 +48,17 @@ const base = import.meta.env.BASE_URL;
</div>

<script define:vars={{ base }}>
// Pagefind instance persists across View Transitions — no need to re-import
let pagefind = null;

// Detect OS and update shortcut badge
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
const modifierEl = document.getElementById('search-modifier');
if (modifierEl) {
modifierEl.textContent = isMac ? '⌘' : 'Ctrl+';
}

async function loadPagefind() {
if (pagefind) return pagefind;
pagefind = await import(`${base}pagefind/pagefind.js`);
await pagefind.init();
return pagefind;
}

const searchBtn = document.getElementById('search-btn');
const searchModal = document.getElementById('search-modal');
const searchBackdrop = document.getElementById('search-backdrop');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const searchEmpty = document.getElementById('search-empty');
const searchCloseKbd = document.getElementById('search-close-kbd');
const searchStatus = document.getElementById('search-status');

// Section badge color mapping
// Section badge color mapping (static, no DOM dependency)
const SECTION_COLORS = {
'Get Started': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
'Guide': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
Expand All @@ -91,6 +76,14 @@ const base = import.meta.env.BASE_URL;
return `<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${colors}">${section}</span>`;
}

// --- DOM-dependent state, refreshed on each navigation ---
let searchBtn, searchModal, searchBackdrop, searchInput,
searchResults, searchEmpty, searchCloseKbd, searchStatus;
let debounceTimer;

// Bound handler refs so we can removeEventListener cleanly
let _onBtnClick, _onBackdropClick, _onCloseKbdClick, _onInput;

function openSearch() {
searchModal?.classList.remove('hidden');
searchInput?.focus();
Expand All @@ -109,30 +102,6 @@ const base = import.meta.env.BASE_URL;
document.body.style.overflow = '';
}

searchBtn?.addEventListener('click', openSearch);
searchBackdrop?.addEventListener('click', closeSearch);
searchCloseKbd?.addEventListener('click', closeSearch);



document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (searchModal?.classList.contains('hidden')) {
openSearch();
} else {
closeSearch();
}
}
if (e.key === 'Escape') closeSearch();
});

let debounceTimer;
searchInput?.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => performSearch(), 200);
});

async function performSearch() {
const query = searchInput?.value?.trim();
if (!query) {
Expand Down Expand Up @@ -162,13 +131,11 @@ const base = import.meta.env.BASE_URL;
return;
}

// Update status bar
if (searchStatus) {
searchStatus.textContent = `${search.results.length} result${search.results.length === 1 ? '' : 's'}`;
searchStatus.classList.remove('hidden');
}

// Render results with section badges
results.forEach((result) => {
const section = result.meta?.section || '';
const a = document.createElement('a');
Expand All @@ -181,6 +148,7 @@ const base = import.meta.env.BASE_URL;
</div>
<div class="text-sm text-surface-500 dark:text-surface-400 mt-0.5 line-clamp-2">${result.excerpt || ''}</div>
`;
// Close modal on click — navigation proceeds via View Transitions
a.addEventListener('click', closeSearch);
searchResults.appendChild(a);
});
Expand All @@ -190,4 +158,69 @@ const base = import.meta.env.BASE_URL;
}
}
}

// Global keydown — register exactly once, never duplicated
let _globalKeydownRegistered = false;
function handleGlobalKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (searchModal?.classList.contains('hidden')) {
openSearch();
} else {
closeSearch();
}
}
if (e.key === 'Escape') closeSearch();
}

function initSearch() {
// Cancel any in-flight debounce from the previous page
clearTimeout(debounceTimer);

// Tear down old element listeners (old refs may be detached)
if (_onBtnClick && searchBtn) searchBtn.removeEventListener('click', _onBtnClick);
if (_onBackdropClick && searchBackdrop) searchBackdrop.removeEventListener('click', _onBackdropClick);
if (_onCloseKbdClick && searchCloseKbd) searchCloseKbd.removeEventListener('click', _onCloseKbdClick);
if (_onInput && searchInput) searchInput.removeEventListener('input', _onInput);

// Re-query all DOM elements (fresh references after View Transition swap)
searchBtn = document.getElementById('search-btn');
searchModal = document.getElementById('search-modal');
searchBackdrop = document.getElementById('search-backdrop');
searchInput = document.getElementById('search-input');
searchResults = document.getElementById('search-results');
searchEmpty = document.getElementById('search-empty');
searchCloseKbd = document.getElementById('search-close-kbd');
searchStatus = document.getElementById('search-status');

// Detect OS and update shortcut badge
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
const modifierEl = document.getElementById('search-modifier');
if (modifierEl) {
modifierEl.textContent = isMac ? '⌘' : 'Ctrl+';
}

// Create fresh bound handlers
_onBtnClick = () => openSearch();
_onBackdropClick = () => closeSearch();
_onCloseKbdClick = () => closeSearch();
_onInput = () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => performSearch(), 200);
};

searchBtn?.addEventListener('click', _onBtnClick);
searchBackdrop?.addEventListener('click', _onBackdropClick);
searchCloseKbd?.addEventListener('click', _onCloseKbdClick);
searchInput?.addEventListener('input', _onInput);

// Register global keydown exactly once
if (!_globalKeydownRegistered) {
document.addEventListener('keydown', handleGlobalKeydown);
_globalKeydownRegistered = true;
}
}

// astro:page-load fires on initial load AND after each View Transition
document.addEventListener('astro:page-load', initSearch);
Comment thread
diberry marked this conversation as resolved.
Resolved
</script>
69 changes: 69 additions & 0 deletions docs/tests/search.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,72 @@ test.describe('Search results', () => {
expect(text.trim()).not.toBe('Start typing to search…');
});
});

test.describe('Search after View Transition navigation', () => {

test('search modal works after navigating via search result', async ({ page }) => {
// Initial page load
await page.goto(BASE);

// Open search and perform a query
const searchBtn = page.locator('#search-btn');
await expect(searchBtn).toBeVisible();
await searchBtn.click();
const modal = page.locator('#search-modal');
await expect(modal).not.toHaveClass(/hidden/);

// Type a query and wait for results
const input = page.locator('#search-input');
await input.fill('agent');
const resultLinks = page.locator('#search-results a');
await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });

// Click first result to navigate (View Transition occurs)
const href = await resultLinks.first().getAttribute('href');
await resultLinks.first().click();
await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 });
Comment thread
diberry marked this conversation as resolved.
Resolved

// After navigation, search should still work
const searchBtnAfter = page.locator('#search-btn');
await expect(searchBtnAfter).toBeVisible();
await searchBtnAfter.click();

const modalAfter = page.locator('#search-modal');
await expect(modalAfter).not.toHaveClass(/hidden/);

// Verify input is focused and we can type a new query
const inputAfter = page.locator('#search-input');
await expect(inputAfter).toBeFocused();
await inputAfter.fill('test');

// Results should appear for the new query
const resultLinksAfter = page.locator('#search-results a');
await expect(resultLinksAfter.first()).toBeVisible({ timeout: 10_000 });
});

test('Ctrl+K opens search after View Transition navigation', async ({ page }) => {
// Initial page load
await page.goto(BASE);

// Perform a search and navigate via result
const searchBtn = page.locator('#search-btn');
await searchBtn.click();
const input = page.locator('#search-input');
await input.fill('agent');

const resultLinks = page.locator('#search-results a');
await expect(resultLinks.first()).toBeVisible({ timeout: 10_000 });

const href = await resultLinks.first().getAttribute('href');
await resultLinks.first().click();
await page.waitForURL(new RegExp(href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), { timeout: 10_000 });
Comment thread
diberry marked this conversation as resolved.
Resolved

// After View Transition navigation, Ctrl+K should open search
const modal = page.locator('#search-modal');
await expect(modal).toHaveClass(/hidden/);

await page.keyboard.press('Control+k');
await expect(modal).not.toHaveClass(/hidden/);
await expect(page.locator('#search-input')).toBeFocused();
});
});
Loading