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
102 changes: 77 additions & 25 deletions src/components/islands/DocSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,66 @@
import { useEffect } from 'react';
import { useEffect, useMemo, type ComponentProps } from 'react';
import { DocSearch } from '@docsearch/react';
import '@docsearch/css';
import {
ALGOLIA_APP_ID,
ALGOLIA_API_KEY,
ALGOLIA_INDEX_NAME,
} from '@/lib/algolia';
import { createSearchAnalytics } from '@/lib/searchAnalytics';
import { trackEvent } from '@/utils/analytics';

type SearchProps = ComponentProps<typeof DocSearch>;

/*
* 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<SearchProps['transformSearchClient']>(() => 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<SearchProps['hitComponent']>(() => ({ hit, children }) => (
<a href={hit.url} onClick={() => analytics.select(hit.url)}>{children}</a>
), [analytics]);
const navigator = useMemo<SearchProps['navigator']>(() => ({
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<HTMLButtonElement>('[data-docsearch-root] .DocSearch-Button')
?.focus({ preventScroll: true });
Expand All @@ -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 (
<div className="suites-docsearch" data-docsearch-root>
Expand All @@ -35,43 +97,34 @@ 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',
buttonAriaLabel: 'Search documentation (press / or Cmd K)',
},
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',
Expand All @@ -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}
}}
/>
</div>
);
Expand Down
77 changes: 77 additions & 0 deletions src/lib/searchAnalytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/** @param {(event: string, params?: Record<string, unknown>) => 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<typeof setTimeout> | 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++;
},
};
}
Loading
Loading