Skip to content

Commit

Permalink
fix(platform): Enable scrolling via keyboard buttons (#12460)
Browse files Browse the repository at this point in the history
* enable scrolling via keyboard buttons

* conditionally add event listeners in search

* undo first try

* refactor
  • Loading branch information
chargome authored Jan 24, 2025
1 parent 42e073e commit e3f3915
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 96 deletions.
106 changes: 12 additions & 94 deletions src/components/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ import {
SentryGlobalSearch,
standardSDKSlug,
} from '@sentry-internal/global-search';
import DOMPurify from 'dompurify';
import Link from 'next/link';
import {usePathname, useRouter} from 'next/navigation';
import {usePathname} from 'next/navigation';
import algoliaInsights from 'search-insights';

import {useOnClickOutside} from 'sentry-docs/clientUtils';
import {useKeyboardNavigate} from 'sentry-docs/hooks/useKeyboardNavigate';
import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs';

import styles from './search.module.scss';

import {Logo} from '../logo';

import {SearchResultItems} from './searchResultItems';
import {relativizeUrl} from './util';

// Initialize Algolia Insights
algoliaInsights('init', {
appId: process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
Expand All @@ -33,8 +33,6 @@ algoliaInsights('init', {
// treat it as a random user.
const randomUserToken = crypto.randomUUID();

const MAX_HITS = 10;

// this type is not exported from the global-search package
type SentryGlobalSearchConfig = ConstructorParameters<typeof SentryGlobalSearch>[0];

Expand All @@ -59,12 +57,6 @@ const userDocsSites: SentryGlobalSearchConfig = [
const config = isDeveloperDocs ? developerDocsSites : userDocsSites;
const search = new SentryGlobalSearch(config);

function relativizeUrl(url: string) {
return isDeveloperDocs
? url
: url.replace(/^(https?:\/\/docs\.sentry\.io)(?=\/|$)/, '');
}

type Props = {
autoFocus?: boolean;
path?: string;
Expand All @@ -79,7 +71,7 @@ export function Search({path, autoFocus, searchPlatforms = [], showChatBot}: Pro
const [inputFocus, setInputFocus] = useState(false);
const [showOffsiteResults, setShowOffsiteResults] = useState(false);
const [loading, setLoading] = useState(true);
const router = useRouter();

const pathname = usePathname();

const handleClickOutside = useCallback((ev: MouseEvent) => {
Expand Down Expand Up @@ -176,16 +168,6 @@ export function Search({path, autoFocus, searchPlatforms = [], showChatBot}: Pro

const totalHits = results.reduce((a, x) => a + x.hits.length, 0);

const flatHits = results.reduce<Hit[]>(
(items, item) => [...items, ...item.hits.slice(0, MAX_HITS)],
[]
);

const {focused} = useKeyboardNavigate({
list: flatHits,
onSelect: hit => router.push(relativizeUrl(hit.url)),
});

const trackSearchResultClick = useCallback((hit: Hit, position: number): void => {
try {
algoliaInsights('clickedObjectIDsAfterSearch', {
Expand Down Expand Up @@ -305,77 +287,13 @@ export function Search({path, autoFocus, searchPlatforms = [], showChatBot}: Pro
{loading && <Logo loading />}

{!loading && totalHits > 0 && (
<div className={styles['sgs-search-results-scroll-container']}>
{results
.filter(x => x.hits.length > 0)
.map((result, i) => (
<Fragment key={result.site}>
{showOffsiteResults && (
<h4 className={styles['sgs-site-result-heading']}>
From {result.name}
</h4>
)}
<ul
className={`${styles['sgs-hit-list']} ${i === 0 ? '' : styles['sgs-offsite']}`}
>
{result.hits.slice(0, MAX_HITS).map((hit, index) => (
<li
key={hit.id}
className={`${styles['sgs-hit-item']} ${
focused?.id === hit.id ? styles['sgs-hit-focused'] : ''
}`}
ref={
// Scroll to element on focus
hit.id === focused?.id
? el => el?.scrollIntoView({block: 'nearest'})
: undefined
}
>
<Link
href={relativizeUrl(hit.url)}
onClick={e => handleSearchResultClick(e, hit, index)}
>
{hit.title && (
<h6>
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(hit.title, {
ALLOWED_TAGS: ['mark'],
}),
}}
/>
</h6>
)}
{hit.text && (
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(hit.text, {
ALLOWED_TAGS: ['mark'],
}),
}}
/>
)}
{hit.context && (
<div className={styles['sgs-hit-context']}>
{hit.context.context1 && (
<div className={styles['sgs-hit-context-left']}>
{hit.context.context1}
</div>
)}
{hit.context.context2 && (
<div className={styles['sgs-hit-context-right']}>
{hit.context.context2}
</div>
)}
</div>
)}
</Link>
</li>
))}
</ul>
</Fragment>
))}
</div>
<SearchResultItems
results={results}
onSearchResultClick={({event, hit, position}) =>
handleSearchResultClick(event, hit, position)
}
showOffsiteResults={showOffsiteResults}
/>
)}

{!loading && totalHits === 0 && (
Expand Down
111 changes: 111 additions & 0 deletions src/components/search/searchResultItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {Fragment} from 'react';
import {Hit, Result} from '@sentry-internal/global-search';
import DOMPurify from 'dompurify';
import Link from 'next/link';
import {useRouter} from 'next/navigation';

import {useListKeyboardNavigate} from 'sentry-docs/hooks/useListKeyboardNavigate';

import styles from './search.module.scss';

import {relativizeUrl} from './util';

const MAX_HITS = 10;

interface SearchResultClickHandler {
event: React.MouseEvent<HTMLAnchorElement>;
hit: Hit;
position: number;
}

export function SearchResultItems({
results,
showOffsiteResults,
onSearchResultClick,
}: {
onSearchResultClick: (params: SearchResultClickHandler) => void;
results: Result[];
showOffsiteResults: boolean;
}) {
const router = useRouter();
const flatHits = results.reduce<Hit[]>(
(items, item) => [...items, ...item.hits.slice(0, MAX_HITS)],
[]
);
const {focused} = useListKeyboardNavigate({
list: flatHits,
onSelect: hit => router.push(relativizeUrl(hit.url)),
});

return (
<div className={styles['sgs-search-results-scroll-container']}>
{results
.filter(x => x.hits.length > 0)
.map((result, i) => (
<Fragment key={result.site}>
{showOffsiteResults && (
<h4 className={styles['sgs-site-result-heading']}>From {result.name}</h4>
)}
<ul
className={`${styles['sgs-hit-list']} ${i === 0 ? '' : styles['sgs-offsite']}`}
>
{result.hits.slice(0, MAX_HITS).map((hit, index) => (
<li
key={hit.id}
className={`${styles['sgs-hit-item']} ${
focused?.id === hit.id ? styles['sgs-hit-focused'] : ''
}`}
ref={
// Scroll to element on focus
hit.id === focused?.id
? el => el?.scrollIntoView({block: 'nearest'})
: undefined
}
>
<Link
href={relativizeUrl(hit.url)}
onClick={event => onSearchResultClick({event, hit, position: index})}
>
{hit.title && (
<h6>
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(hit.title, {
ALLOWED_TAGS: ['mark'],
}),
}}
/>
</h6>
)}
{hit.text && (
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(hit.text, {
ALLOWED_TAGS: ['mark'],
}),
}}
/>
)}
{hit.context && (
<div className={styles['sgs-hit-context']}>
{hit.context.context1 && (
<div className={styles['sgs-hit-context-left']}>
{hit.context.context1}
</div>
)}
{hit.context.context2 && (
<div className={styles['sgs-hit-context-right']}>
{hit.context.context2}
</div>
)}
</div>
)}
</Link>
</li>
))}
</ul>
</Fragment>
))}
</div>
);
}
7 changes: 7 additions & 0 deletions src/components/search/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs';

export function relativizeUrl(url: string) {
return isDeveloperDocs
? url
: url.replace(/^(https?:\/\/docs\.sentry\.io)(?=\/|$)/, '');
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type Props<T> = {
* The list of values to navigate through
*/
list: T[];

/**
* Callback triggered when the item is selected
*/
Expand All @@ -14,7 +15,7 @@ type Props<T> = {
/**
* Navigate a list of items using the up/down arrow and ^j/^k keys
*/
function useKeyboardNavigate<T>({list, onSelect}: Props<T>) {
function useListKeyboardNavigate<T>({list, onSelect}: Props<T>) {
const [focused, setFocus] = useState<T | null>(null);

const setFocusIndex = useCallback(
Expand Down Expand Up @@ -92,4 +93,4 @@ function useKeyboardNavigate<T>({list, onSelect}: Props<T>) {
return {focused, setFocus};
}

export {useKeyboardNavigate};
export {useListKeyboardNavigate};

0 comments on commit e3f3915

Please sign in to comment.