From bda399729bf7feacf2ca6b305e73b8499a53fe2c Mon Sep 17 00:00:00 2001 From: omermorad Date: Fri, 6 Nov 2026 01:35:45 +0200 Subject: [PATCH] Polish documentation search and add GA4 tracking --- src/components/islands/DocSearch.tsx | 102 +++++-- src/lib/searchAnalytics.js | 77 ++++++ src/styles/components.css | 379 +++++++++++++-------------- tests/search-analytics.test.mjs | 75 ++++++ 4 files changed, 413 insertions(+), 220 deletions(-) create mode 100644 src/lib/searchAnalytics.js create mode 100644 tests/search-analytics.test.mjs diff --git a/src/components/islands/DocSearch.tsx b/src/components/islands/DocSearch.tsx index 1216eb8..1e6a037 100644 --- a/src/components/islands/DocSearch.tsx +++ b/src/components/islands/DocSearch.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, type ComponentProps } from 'react'; import { DocSearch } from '@docsearch/react'; import '@docsearch/css'; import { @@ -6,16 +6,61 @@ import { ALGOLIA_API_KEY, ALGOLIA_INDEX_NAME, } from '@/lib/algolia'; +import { createSearchAnalytics } from '@/lib/searchAnalytics'; +import { trackEvent } from '@/utils/analytics'; + +type SearchProps = ComponentProps; /* * DocSearch owns shortcuts, modal focus trapping, results and persistence. * Its current close handler does not restore focus to the search button. */ export default function DocSearchIsland() { + const analytics = useMemo(() => createSearchAnalytics(trackEvent), []); + const transformSearchClient = useMemo(() => client => ({ + ...client, + search: async (...args) => { + analytics.open(); + const requests = Array.isArray(args[0]) ? args[0] : args[0].requests; + const first = requests[0]; + const params = first && 'params' in first ? first.params : undefined; + const query = typeof params === 'string' + ? new URLSearchParams(params).get('query') ?? '' + : params?.query ?? (first && 'query' in first ? first.query : '') ?? ''; + const requestVersion = analytics.change(query); + const response = await client.search(...args); + const result = response.results[0]; + if (result && 'nbHits' in result && typeof result.nbHits === 'number') { + analytics.results(requestVersion, result.nbHits); + } + return response; + }, + }), [analytics]); + const hitComponent = useMemo(() => ({ hit, children }) => ( + analytics.select(hit.url)}>{children} + ), [analytics]); + const navigator = useMemo(() => ({ + navigate({ itemUrl }) { + analytics.select(itemUrl); + window.location.assign(itemUrl); + }, + navigateNewTab({ itemUrl }) { + analytics.select(itemUrl); + window.open(itemUrl, '_blank', 'noopener,noreferrer'); + }, + navigateNewWindow({ itemUrl }) { + analytics.select(itemUrl); + window.open(itemUrl, '_blank', 'noopener,noreferrer'); + }, + }), [analytics]); + useEffect(() => { let wasOpen = Boolean(document.querySelector('.DocSearch-Modal')); + if (wasOpen) analytics.open(); const observer = new MutationObserver(() => { const isOpen = Boolean(document.querySelector('.DocSearch-Modal')); + if (!wasOpen && isOpen) analytics.open(); + if (wasOpen && !isOpen) analytics.close(); if (wasOpen && !isOpen && document.activeElement === document.body) { document.querySelector('[data-docsearch-root] .DocSearch-Button') ?.focus({ preventScroll: true }); @@ -24,8 +69,25 @@ export default function DocSearchIsland() { }); // The modal is a direct portal child of body. Result updates stay nested. observer.observe(document.body, { childList: true }); - return () => observer.disconnect(); - }, []); + const onInput = (event: Event) => { + if (event.target instanceof HTMLInputElement && event.target.matches('.DocSearch-Input')) { + analytics.change(event.target.value); + } + }; + const onReset = (event: Event) => { + if (event.target instanceof HTMLFormElement && event.target.matches('.DocSearch-Form')) { + analytics.change(''); + } + }; + document.addEventListener('input', onInput, true); + document.addEventListener('reset', onReset, true); + return () => { + observer.disconnect(); + document.removeEventListener('input', onInput, true); + document.removeEventListener('reset', onReset, true); + analytics.dispose(); + }; + }, [analytics]); return (
@@ -35,16 +97,14 @@ export default function DocSearchIsland() { indexName={ALGOLIA_INDEX_NAME} placeholder="Search documentation" insights={true} + transformSearchClient={transformSearchClient} + hitComponent={hitComponent} + navigator={navigator} getMissingResultsUrl={({ query }) => `https://github.com/suites-dev/suites/issues/new?title=${encodeURIComponent( `Docs search: no results for "${query}"`, )}&labels=docs` } - // Cast: a few translation keys (clearButtonTitle, recentConversationsTitle, - // backToSearchText) exist in newer @docsearch/react runtimes but are - // not yet present in the installed type definitions. Algolia accepts - // them at runtime; the cast lets us ship the strings without dropping - // them on the floor or pinning to stale types. translations={{ button: { buttonText: 'Search documentation', @@ -52,26 +112,19 @@ export default function DocSearchIsland() { }, modal: { searchBox: { - clearButtonTitle: 'Clear', - clearButtonAriaLabel: 'Clear the query', - closeButtonText: 'Close', - closeButtonAriaLabel: 'Close', - placeholderText: 'Search documentation', - placeholderTextAskAi: 'Ask AI a question', - placeholderTextAskAiStreaming: 'Answering', - searchInputLabel: 'Search', - backToKeywordSearchButtonText: 'Back to keyword search', - backToKeywordSearchButtonAriaLabel: 'Back to keyword search', + resetButtonTitle: 'Clear the query', + resetButtonAriaLabel: 'Clear the query', + cancelButtonText: 'Close', + cancelButtonAriaLabel: 'Close search', + searchInputLabel: 'Search Suites documentation', }, startScreen: { recentSearchesTitle: 'Recent', - noRecentSearchesText: 'No recent searches', + noRecentSearchesText: 'Find a guide, framework, or API.', saveRecentSearchButtonTitle: 'Save this search', removeRecentSearchButtonTitle: 'Remove this search from history', favoriteSearchesTitle: 'Favorites', removeFavoriteSearchButtonTitle: 'Remove this search from favorites', - recentConversationsTitle: 'Recent conversations', - removeRecentConversationButtonTitle: 'Remove conversation from history', }, errorScreen: { titleText: 'Unable to fetch results', @@ -85,16 +138,15 @@ export default function DocSearchIsland() { navigateDownKeyAriaLabel: 'Arrow down', closeText: 'to close', closeKeyAriaLabel: 'Escape key', - backToSearchText: 'Back to search', }, noResultsScreen: { noResultsText: 'No results for', suggestedQueryText: 'Try searching for', - reportMissingResultsText: 'Believe this query should return results?', - reportMissingResultsLinkText: 'Let us know.', + reportMissingResultsText: 'Try a different term, or', + reportMissingResultsLinkText: 'suggest missing documentation.', }, }, - } as any} + }} />
); diff --git a/src/lib/searchAnalytics.js b/src/lib/searchAnalytics.js new file mode 100644 index 0000000..ee2e2f9 --- /dev/null +++ b/src/lib/searchAnalytics.js @@ -0,0 +1,77 @@ +/** @param {(event: string, params?: Record) => void} send */ +export function createSearchAnalytics(send) { + let isOpen = false; + let query = ''; + let version = 0; + let reported = ''; + /** @type {{ query: string, count: number } | undefined} */ + let pending; + /** @type {ReturnType | undefined} */ + let timer; + + function cancel() { + clearTimeout(timer); + pending = undefined; + } + + function flush() { + clearTimeout(timer); + if (!pending || pending.query === reported) return; + const params = { + search_term: pending.query, + result_count: pending.count, + search_source: 'docs', + }; + send('view_search_results', params); + if (pending.count === 0) send('search_no_results', params); + reported = pending.query; + pending = undefined; + } + + return { + open() { + if (isOpen) return; + cancel(); + isOpen = true; + query = ''; + reported = ''; + version++; + send('search_open', { search_source: 'docs' }); + }, + /** @param {string} value */ + change(value) { + cancel(); + query = value.trim().slice(0, 64); + return ++version; + }, + /** @param {number} requestVersion @param {number} count */ + results(requestVersion, count) { + if (!isOpen || requestVersion !== version || !query) return; + pending = { query, count }; + // Report the settled query, rather than every intermediate keystroke. + timer = setTimeout(flush, 700); + }, + /** @param {string} url */ + select(url) { + if (!isOpen) return; + flush(); + const target = new URL(url, 'https://suites.dev'); + send('search_result_click', { + search_term: query, + link_url: `${target.origin}${target.pathname}${target.hash}`, + search_source: 'docs', + }); + }, + close() { + flush(); + cancel(); + isOpen = false; + version++; + }, + dispose() { + cancel(); + isOpen = false; + version++; + }, + }; +} diff --git a/src/styles/components.css b/src/styles/components.css index ad46b42..9b071e2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -3853,8 +3853,6 @@ html.marketing-menu-open { * Scope: Algolia DocSearch (.DocSearch-*) and AskAI menu (.ask-ai*). * * Notes: - * - components.css currently has NO rules in either namespace, so every block - * below is NEW. No REPLACES/APPEND-TO entries. * - We scope DocSearch overrides under .suites-docsearch where possible so * they cannot leak. The full-screen modal (.DocSearch-Container) is portaled * to , so its theming uses --docsearch-* CSS vars set at :root level @@ -3978,252 +3976,243 @@ html.marketing-menu-open { border-radius: 6px; } -/* ============================================================ - * DocSearch modal (portaled to , so styled at top level) - * ============================================================ */ - -/* NEW */ +/* DocSearch is portaled to body. Keep every modal rule in its namespace. */ .DocSearch-Container { - /* Slightly stronger backdrop blur for legibility on busy docs pages */ - backdrop-filter: blur(8px) saturate(120%); - -webkit-backdrop-filter: blur(8px) saturate(120%); - z-index: var(--z-modal, 50); + --docsearch-modal-width: 680px; + --docsearch-logo-color: var(--text-muted); + --docsearch-text-color: var(--text); + --docsearch-muted-color: var(--text-muted); + --docsearch-hit-active-color: var(--text); + --docsearch-hit-background: transparent; + --docsearch-hit-shadow: none; + --docsearch-key-gradient: var(--bg-subtle); + --docsearch-key-shadow: none; + background: var(--docsearch-container-background); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: var(--z-modal); + padding: 0 16px; } .DocSearch-Modal { - background: var(--surface, #17171c); - border: 1px solid var(--border, color-mix(in srgb, var(--text) 9%, transparent)); - border-radius: var(--radius-md, 20px); - box-shadow: var(--shadow, 0 24px 90px rgba(0, 0, 0, 0.45)); - font-family: var(--font-body); - max-width: 720px; + display: flex; + flex-direction: column; + width: 100%; + max-width: var(--docsearch-modal-width); + max-height: min(680px, calc(100dvh - 120px)); + margin: min(12vh, 96px) auto 24px; + overflow: hidden; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 18px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.3); + font-family: var(--font-interface); } -/* Search bar sits flush at the top, separated from hits by a hairline. */ .DocSearch-SearchBar { - padding: 0; - border-bottom: 1px solid var(--border, color-mix(in srgb, var(--text) 9%, transparent)); + flex: none; + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--border); } -/* Form is borderless so the searchbar reads as a single field, not a card. */ .DocSearch-Form { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; + flex: 1; + min-width: 0; height: 56px; - padding: 0 16px; + padding: 0 14px; + gap: 10px; + background: var(--bg-subtle); + border: 1px solid var(--control-border); + border-radius: 10px; + box-shadow: none; } .DocSearch-Form:focus-within { - background: transparent; + background: var(--bg); + border-color: var(--primary-light); box-shadow: none; } -/* NEW */ -.DocSearch-Input { - color: var(--text, #f4f4f5); - font-size: 15px; - font-family: var(--font-body); -} - -/* NEW */ -.DocSearch-Input::placeholder { - color: var(--text-soft, #8b8b94); - opacity: 1; +.DocSearch-Modal .DocSearch-Input { + appearance: none; + -webkit-appearance: none; + background: transparent; + border: 0; + border-radius: 0; + min-width: 0; + height: 100%; + padding: 0; + color: var(--text); + font-family: inherit; + font-size: 17px; + font-weight: var(--fw-regular); + letter-spacing: -0.01em; + caret-color: var(--primary-light); } -/* NEW */ -.DocSearch-Input:focus { +.DocSearch-Input::placeholder { color: var(--text-soft); opacity: 1; } +/* The form border supplies one focus indicator for the whole search field. */ +.DocSearch-Modal .DocSearch-Input:is(:focus, :focus-visible) { outline: none; + box-shadow: none; } - -/* NEW */ -.DocSearch-MagnifierLabel, -.DocSearch-Reset { - color: var(--text-soft, #8b8b94); +.DocSearch-Input::selection { + background: color-mix(in srgb, var(--brand-mid) 35%, transparent); + color: var(--text); } - -/* NEW */ +.DocSearch-MagnifierLabel { color: var(--primary-light); } +.DocSearch-MagnifierLabel svg { width: 20px; height: 20px; } +.DocSearch-Reset { width: 44px; height: 44px; padding: 12px; color: var(--text-muted); } +.DocSearch-Reset:hover { color: var(--text); } .DocSearch-Cancel { - color: var(--primary-light, #ef9dab); + appearance: none; + display: block; + align-self: center; + min-width: 44px; + min-height: 44px; + padding: 0 4px; + margin: 0; + background: transparent; + border: 0; + cursor: pointer; + color: var(--text-muted); + font-size: 13px; font-weight: var(--fw-medium); } - -/* NEW: keyboard focus on the close button */ -.DocSearch-Cancel:focus-visible { - outline: 2px solid var(--primary-light, #ef9dab); - outline-offset: 2px; - border-radius: 4px; +.DocSearch-Cancel:hover { color: var(--text); } +.DocSearch-Modal :is(a, button):focus-visible { + outline: 2px solid var(--primary-light); + outline-offset: -2px; + border-radius: 6px; } -/* ============================================================ - * Hits list - * ============================================================ */ - -.DocSearch-Hits { - padding: 6px 8px 12px; +.DocSearch-Dropdown { + flex: 1 1 auto; + min-height: 0; + max-height: none; + padding: 0 10px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + scroll-padding-block: 12px; } - -/* Group-source label (e.g. "Documentation > Guides") above each hit cluster. */ +.DocSearch-Hits { padding: 0 0 8px; } .DocSearch-Hit-source { - color: var(--text-soft, #8b8b94); - font-size: 11px; - letter-spacing: 0.06em; - text-transform: uppercase; - background: transparent; - margin: 14px 12px 4px; + position: static; + margin: 0; + padding: 18px 12px 8px; + background: var(--surface); + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; font-weight: var(--fw-semibold); + letter-spacing: 0.02em; + text-transform: none; } - -/* Flat hit list, no per-row card. Resting is transparent so the modal reads */ -/* as a single panel instead of a stack of pills. */ -.DocSearch-Hit { - padding-bottom: 2px; -} - +.DocSearch-Hit { padding-bottom: 3px; } .DocSearch-Hit a { + display: block; + min-height: 60px; + padding: 10px 12px; background: transparent; - border: 0; - border-radius: var(--radius-xs, 10px); - padding: 8px 12px; - transition: - background-color 140ms ease, - color 140ms ease; + border: 1px solid transparent; + border-radius: 9px; box-shadow: none; + color: var(--text); + transition: background-color 140ms ease, border-color 140ms ease; } - -/* Active hit: brand-pink wash + pink text, not a full pink fill. */ -/* Matches the sidebar active pattern so the modal feels like the same */ -/* navigation surface. */ .DocSearch-Hit[aria-selected="true"] a, .DocSearch-Hit a:hover { - background: var(--primary-soft, rgba(219, 72, 111, 0.12)); - color: var(--primary-light, #ef9dab); -} - -.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-title, -.DocSearch-Hit a:hover .DocSearch-Hit-title { - color: var(--primary-light, #ef9dab); -} - -.DocSearch-Hit-path { - color: var(--text-soft, #8b8b94); - font-size: 12px; + background: var(--primary-soft); + border-color: var(--primary-border); + color: var(--text); + box-shadow: none; } - -.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-path, -.DocSearch-Hit a:hover .DocSearch-Hit-path { - color: var(--primary-light, #ef9dab); - opacity: 1; +.DocSearch-Hit-Container { min-height: 44px; height: 44px; } +.DocSearch-Hit-content-wrapper { min-width: 0; } +.DocSearch-Hit-title { + color: var(--text); + font-size: 15px; + line-height: 1.5; + font-weight: var(--fw-medium); } - -/* Highlighted matched substring inside a hit title. */ -.DocSearch-Hit mark, -.DocSearch-Hit-Tree { +.DocSearch-Hit-path { margin-top: 2px; color: var(--text-muted); font-size: 12px; line-height: 1.5; } +.DocSearch-Hit-title, +.DocSearch-Hit-path { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.DocSearch-Hit mark { + color: var(--primary-light); background: transparent; - color: var(--primary-light, #ef9dab); font-weight: var(--fw-semibold); + text-decoration: none; } +.DocSearch-Hit-Tree { width: 20px; height: 44px; color: var(--border-strong); } +.DocSearch-Hit[aria-selected="true"] mark { color: var(--primary-light) !important; text-decoration: none; } +.DocSearch-Hit-icon { color: var(--text-soft); } +.DocSearch-Hit-action { color: var(--text-muted); } +.DocSearch-Hit[aria-selected="true"] :is(.DocSearch-Hit-icon, .DocSearch-Hit-action) { color: var(--primary-light); } +.DocSearch-Hit-action-button { min-width: 32px; min-height: 32px; } -.DocSearch-Hit[aria-selected="true"] mark, -.DocSearch-Hit a:hover mark { - color: var(--primary-light, #ef9dab); - text-decoration: underline; - text-underline-offset: 2px; -} - -.DocSearch-Hit-icon, -.DocSearch-Hit-action { - color: var(--text-soft, #8b8b94); -} - -.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-icon, -.DocSearch-Hit a:hover .DocSearch-Hit-icon { - color: var(--primary-light, #ef9dab); -} - -/* Action icon (right side, the return-arrow on the active hit). */ -.DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-action, -.DocSearch-Hit a:hover .DocSearch-Hit-action { - color: var(--primary-light, #ef9dab); -} - -/* ============================================================ - * Start screen (recent + favorites) - * ============================================================ */ - -/* NEW */ .DocSearch-StartScreen, -.DocSearch-NoResults { - padding: 18px 14px 8px; -} - -/* NEW */ -.DocSearch-Help { - color: var(--text-muted, #a1a1aa); - font-size: 13px; -} - -/* NEW */ -.DocSearch-Title { - color: var(--text, #f4f4f5); - font-family: var(--font-title); -} - -/* NEW */ -.DocSearch-NoResults-Prefill-List { - padding-top: 6px; +.DocSearch-NoResults, +.DocSearch-ErrorScreen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 240px; + padding: 36px 20px; + text-align: center; } +.DocSearch-Help { margin: 8px 0; color: var(--text-muted); font-size: 14px; line-height: 1.6; } +.DocSearch-StartScreen .DocSearch-Help { max-width: 280px; color: var(--text-muted); font-size: 16px; } +.DocSearch-Title { margin: 12px 0 4px; color: var(--text); font-family: inherit; font-size: 17px; line-height: 1.5; overflow-wrap: anywhere; } +.DocSearch-Screen-Icon { color: var(--text-soft); } +.DocSearch-NoResults-Prefill-List { padding: 8px 0; } +.DocSearch-Prefill { min-height: 44px; padding: 8px 12px; color: var(--primary-light); } +.DocSearch-Help a { color: var(--primary-light); text-underline-offset: 3px; } -/* ============================================================ - * Footer (keyboard hint row) - * ============================================================ */ - -/* NEW */ .DocSearch-Footer { - border-radius: 0 0 var(--radius-md, 20px) var(--radius-md, 20px); - padding: 8px 14px; -} - -/* NEW */ -.DocSearch-Commands-Key { - background: var(--surface-raised, #202027); - box-shadow: var(--docsearch-key-shadow); - color: var(--text-muted, #a1a1aa); - border-radius: 6px; -} - -/* NEW */ -.DocSearch-Label { - color: var(--text-soft, #8b8b94); - font-size: 11px; -} - -/* NEW */ -.DocSearch-Logo a { - color: var(--text-soft, #8b8b94); - opacity: 1; - transition: opacity 180ms ease; + flex: none; + height: auto; + min-height: 48px; + gap: 16px; + padding: 12px 18px; + background: var(--bg-subtle); + border-top: 1px solid var(--border); + border-radius: 0; + box-shadow: none; } +.DocSearch-Commands { gap: 12px; } +.DocSearch-Commands-Key { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text-muted); box-shadow: none; } +.DocSearch-Label { color: var(--text-muted); font-size: 12px; } +.DocSearch-Logo a { color: var(--text-muted); opacity: 1; } +.DocSearch-Logo svg { color: var(--text-muted); } -/* NEW */ -.DocSearch-Logo a:hover { - opacity: 1; +@media (max-width: 640px) { + .DocSearch-Container { padding: 0; } + .DocSearch-Modal { + height: calc(var(--docsearch-vh, 1vh) * 100); + max-height: none; + max-width: none; + margin: 0; + border: 0; + border-radius: 0; + } + .DocSearch-SearchBar { gap: 8px; padding: max(12px, env(safe-area-inset-top)) 12px 12px; } + .DocSearch-Form { height: 52px; padding-inline: 12px; } + .DocSearch-Modal .DocSearch-Input { font-size: 16px; } + .DocSearch-Dropdown { padding-inline: 8px; } + .DocSearch-Hit a { min-height: 64px; } + .DocSearch-Hit-action-button { min-width: 44px; min-height: 44px; } + .DocSearch-Footer { padding-bottom: max(12px, env(safe-area-inset-bottom)); } } -/* ============================================================ - * Reduced motion: kill all transitions inside the modal + trigger - * ============================================================ */ - -/* NEW */ @media (prefers-reduced-motion: reduce) { .suites-docsearch .DocSearch-Button, .DocSearch-Hit a, - .DocSearch-Form, - .DocSearch-Logo a { - transition: none !important; - } + .DocSearch-Form { transition: none; } } /* ============================================================ @@ -4370,7 +4359,7 @@ html.marketing-menu-open { } :root[data-theme="light"] .DocSearch-Form:focus-within { - box-shadow: inset 0 0 0 2px var(--primary); + box-shadow: none; } :root[data-theme="light"] .DocSearch-Input:focus { diff --git a/tests/search-analytics.test.mjs b/tests/search-analytics.test.mjs new file mode 100644 index 0000000..8c978be --- /dev/null +++ b/tests/search-analytics.test.mjs @@ -0,0 +1,75 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createSearchAnalytics } from '../src/lib/searchAnalytics.js'; + +function setup(t) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const events = []; + const tracker = createSearchAnalytics((name, params) => events.push({ name, params })); + t.after(() => tracker.dispose()); + tracker.open(); + return { tracker, events }; +} + +test('reports a settled search once and ignores intermediate queries and stale responses', t => { + const { tracker, events } = setup(t); + const old = tracker.change('nest'); + tracker.results(old, 20); + t.mock.timers.tick(400); + const latest = tracker.change('nestjs'); + tracker.open(); + tracker.results(old, 20); + tracker.results(latest, 12); + t.mock.timers.tick(700); + assert.deepEqual(events.map(e => e.name), ['search_open', 'view_search_results']); + assert.equal(events[1].params.search_term, 'nestjs'); + assert.equal(events[1].params.result_count, 12); + tracker.results(latest, 12); + t.mock.timers.tick(700); + assert.equal(events.length, 2); +}); + +test('empty results produce the search event and a separate missing-content signal', t => { + const { tracker, events } = setup(t); + const request = tracker.change('missing example'); + tracker.results(request, 0); + t.mock.timers.tick(700); + assert.deepEqual(events.map(e => e.name), ['search_open', 'view_search_results', 'search_no_results']); + assert.equal(events[2].params.result_count, 0); +}); + +test('selecting before the debounce finishes records the query before the selected document', t => { + const { tracker, events } = setup(t); + const request = tracker.change('solitary'); + tracker.results(request, 10); + tracker.select('https://suites.dev/docs/guides/solitary/?tracking=discard#example'); + tracker.close(); + t.mock.timers.tick(1000); + assert.deepEqual(events.map(e => e.name), ['search_open', 'view_search_results', 'search_result_click']); + assert.equal(events[2].params.link_url, 'https://suites.dev/docs/guides/solitary/#example'); +}); + +test('clearing, closing, and reopening do not leak pending searches across sessions', t => { + const { tracker, events } = setup(t); + const old = tracker.change('old'); + tracker.results(old, 10); + tracker.change(''); + tracker.close(); + tracker.open(); + tracker.results(old, 10); + t.mock.timers.tick(1000); + assert.deepEqual(events.map(e => e.name), ['search_open', 'search_open']); + const next = tracker.change('old'); + tracker.results(next, 5); + t.mock.timers.tick(700); + assert.equal(events[2].params.result_count, 5); +}); + +test('unmounting ignores an outstanding search response', t => { + const { tracker, events } = setup(t); + const request = tracker.change('nestjs'); + tracker.dispose(); + tracker.results(request, 12); + t.mock.timers.tick(1000); + assert.deepEqual(events.map(e => e.name), ['search_open']); +});