1
0
mirror of synced 2025-12-21 19:06:49 -05:00
Files
docs/components/Search.tsx
Mike Surowiec 2a25efcd8e Scope Search scss (#20964)
* scope Search scss, reduce custom styling

* use translation for loading and no results found

* remove unnescessary test
2021-08-23 09:15:39 -04:00

277 lines
9.1 KiB
TypeScript

import { useState, useEffect, useRef, ReactNode } from 'react'
import { useRouter } from 'next/router'
import debounce from 'lodash/debounce'
import cx from 'classnames'
import { useTranslation } from 'components/hooks/useTranslation'
import { sendEvent, EventType } from 'components/lib/events'
import { useMainContext } from './context/MainContext'
import { useVersion } from 'components/hooks/useVersion'
import { useLanguages } from './context/LanguagesContext'
import styles from './Search.module.scss'
type SearchResult = {
url: string
breadcrumbs: string
heading: string
title: string
content: string
}
type Props = {
isOverlay?: boolean
variant?: 'compact' | 'expanded'
autoFocus?: boolean
updateSearchParams?: boolean
children?: (props: { SearchInput: ReactNode; SearchResults: ReactNode }) => ReactNode
}
export function Search({
autoFocus = false,
isOverlay = false,
updateSearchParams = true,
variant = 'compact',
children,
}: Props) {
const router = useRouter()
const [query, setQuery] = useState(router.query.query || '')
const [results, setResults] = useState<Array<SearchResult>>([])
const [isLoading, setIsLoading] = useState(false)
const [activeHit, setActiveHit] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation('search')
const { currentVersion } = useVersion()
const { languages } = useLanguages()
// Figure out language and version for index
const { searchVersions, nonEnterpriseDefaultVersion } = useMainContext()
// fall back to the non-enterprise default version (FPT currently) on the homepage, 404 page, etc.
const version = searchVersions[currentVersion] || searchVersions[nonEnterpriseDefaultVersion]
const language = (Object.keys(languages).includes(router.locale || '') && router.locale) || 'en'
// If the user shows up with a query in the URL, go ahead and search for it
useEffect(() => {
if (updateSearchParams && router.query.query) {
/* await */ fetchSearchResults((router.query.query as string).trim())
}
}, [])
// Search with your keyboard
useEffect(() => {
document.addEventListener('keydown', searchWithYourKeyboard)
return () => document.removeEventListener('keydown', searchWithYourKeyboard)
}, [results, activeHit])
function searchWithYourKeyboard(event: KeyboardEvent) {
switch (event.key) {
case '/':
// when an input is focused, `/` should have no special behavior
if (['INPUT', 'TEXTAREA', 'SEARCH'].includes(document?.activeElement?.tagName || '')) break
event.preventDefault() // prevent slash from being typed into input
inputRef.current?.focus()
break
case 'Escape':
closeSearch()
break
case 'ArrowDown':
if (!results.length) break
event.preventDefault() // prevent window scrolling
if (activeHit >= results.length) break
setActiveHit(activeHit + 1)
break
case 'ArrowUp':
if (!results.length) break
event.preventDefault() // prevent window scrolling
if (activeHit === 0) break
setActiveHit(activeHit - 1)
break
case 'Enter':
// look for a link in the given hit, then visit it
if (activeHit === 0 || !results.length) break
window.location.href = results[activeHit - 1]?.url
break
}
}
// When the user finishes typing, update the results
async function onSearch(e: React.ChangeEvent<HTMLInputElement>) {
const xquery = e.target?.value?.trim()
setQuery(xquery)
// Update the URL with the search parameters in the query string
if (updateSearchParams) {
const pushUrl = new URL(location.toString())
pushUrl.searchParams.set('query', xquery)
history.pushState({}, '', pushUrl.toString())
}
// deactivate any active hit when typing in search box
setActiveHit(0)
return await fetchSearchResults(xquery)
}
// If there's a query, call the endpoint
// Otherwise, there's no results by default
async function fetchSearchResults(xquery: string) {
setIsLoading(true)
try {
if (xquery) {
const endpointUrl = new URL(location.origin)
endpointUrl.pathname = '/search'
const endpointParams: Record<string, string> = {
language,
version,
query: xquery,
}
endpointUrl.search = new URLSearchParams(endpointParams).toString()
const response = await fetch(endpointUrl.toString(), {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
setResults(response.ok ? await response.json() : [])
} else {
setResults([])
}
} finally {
setIsLoading(false)
}
// Analytics tracking
if (xquery) {
sendEvent({
type: EventType.search,
search_query: xquery,
// search_context
})
}
}
// Close panel if overlay is clicked
function closeSearch() {
setQuery('')
setResults([])
}
// Prevent the page from refreshing when you "submit" the form
function preventRefresh(evt: React.FormEvent) {
evt.preventDefault()
}
const SearchResults = (
<>
<div
id="search-results-container"
className={cx(
'z-1',
styles.resultsContainer,
isOverlay && styles.resultsContainerOverlay,
query && styles.resultsContainerOpen
)}
>
{results.length > 0 ? (
<ol data-testid="search-results" className="d-block">
{results.map(({ url, breadcrumbs, heading, title, content }, index) => (
<li
key={url}
data-testid="search-result"
className={cx(
'list-style-none overflow-hidden hover:color-bg-info',
index + 1 === activeHit && 'color-bg-info'
)}
>
<div className="border-top color-border-secondary py-3 px-2">
<a className="no-underline" href={url}>
{/* Breadcrumbs in search records don't include the page title. These fields may contain <mark> elements that we need to render */}
<div
className="d-block color-text-primary opacity-60 text-small pb-1"
dangerouslySetInnerHTML={{ __html: breadcrumbs }}
/>
<div
className={cx(styles.searchResultTitle, 'd-block h4-mktg color-text-primary')}
dangerouslySetInnerHTML={{
__html: heading ? `${title}: ${heading}` : title,
}}
/>
<div
className={cx(
styles.searchResultContent,
'd-block color-text-secondary overflow-hidden'
)}
style={{ maxHeight: '4rem' }}
dangerouslySetInnerHTML={{ __html: content }}
/>
</a>
</div>
</li>
))}
</ol>
) : (
isOverlay && (
<div className="mt-2">
{isLoading ? <span>{t('loading')}...</span> : <span>{t('no_results')}.</span>}
</div>
)
)}
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={cx('-z-1', isOverlay && query ? styles.searchOverlayOpen : 'd-none')}
onClick={closeSearch}
/>
</>
)
const SearchInput = (
<div data-testid="search" aria-hidden="true">
<div className="position-relative z-2">
<form role="search" className="width-full d-flex" noValidate onSubmit={preventRefresh}>
<input
data-testid="site-search-input"
ref={inputRef}
className={cx(
styles.searchInput,
'form-control px-5 f4',
variant === 'compact' && 'py-2',
variant === 'expanded' && 'py-3',
isOverlay && styles.searchInputOverlay,
!isOverlay && 'width-full',
isOverlay && query && styles.searchInputExpanded
)}
style={{
background:
'var(--color-bg-primary) url("/assets/images/octicons/search.svg") no-repeat 6px',
}}
type="search"
placeholder={t`placeholder`}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={autoFocus}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
maxLength={512}
onChange={debounce(onSearch, 200)}
defaultValue={query}
/>
<button className="d-none" type="submit" title="Submit the search query." hidden />
</form>
</div>
</div>
)
return (
<>
{typeof children === 'function' ? (
children({ SearchInput, SearchResults })
) : (
<>
{SearchInput}
{SearchResults}
</>
)}
</>
)
}