Skip to content

Commit

Permalink
React search component (github#19659)
Browse files Browse the repository at this point in the history
* React search component

* Update Search.tsx

* Update Search.tsx

* Standalone mode, start on search with your keyboard

* Update Search.tsx

* Update Search.tsx

* Yay typescript

* Update search.js

* Update events.js
  • Loading branch information
heiskr committed Jun 4, 2021
1 parent 516106b commit 5b5354d
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 30 deletions.
13 changes: 2 additions & 11 deletions components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HeaderNotifications } from 'components/HeaderNotifications'
import { MobileProductDropdown } from 'components/MobileProductDropdown'
import { useTranslation } from 'components/hooks/useTranslation'
import { HomepageVersionPicker } from 'components/landing/HomepageVersionPicker'
import { Search } from 'components/Search'

export const Header = () => {
const router = useRouter()
Expand Down Expand Up @@ -98,17 +99,7 @@ export const Header = () => {
</div>

{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
{relativePath !== 'index.md' && error !== '404'}
<div
className="pt-3 pt-md-0 d-md-inline-block ml-md-3 border-top border-md-top-0"
dangerouslySetInnerHTML={{
__html: `
<div id="search-input-container" aria-hidden="true"></div>
<div id="search-results-container"></div>
<div class="search-overlay-desktop"></div>
`,
}}
/>
{relativePath !== 'index.md' && error !== '404' && <Search />}
</div>
</div>
</div>
Expand Down
204 changes: 204 additions & 0 deletions components/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/router'
import debounce from 'lodash/debounce'
import { useTranslation } from 'components/hooks/useTranslation'
import { sendEvent } from '../javascripts/events'
import { useMainContext } from './context/MainContext'
import { useVersion } from 'components/hooks/useVersion'

type SearchResult = {
url: string
breadcrumbs: string
heading: string
title: string
content: string
}

// Homepage and 404 should be `isStandalone`, all others not
// `updateSearchParams` should be false on the GraphQL explorer page
export function Search({ isStandalone = false, updateSearchParams = true }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Array<SearchResult>>([])
const [activeHit, setActiveHit] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation('search')
const { currentVersion } = useVersion()

// Figure out language and version for index
const { expose } = useMainContext()
const { searchOptions: { languages, versions, nonEnterpriseDefaultVersion } } = JSON.parse(expose)
const router = useRouter()
// fall back to the non-enterprise default version (FPT currently) on the homepage, 404 page, etc.
const version = versions[currentVersion] || versions[nonEnterpriseDefaultVersion]
const language = 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(() => {
const params = new URLSearchParams(location.search)
if (params.has('query')) {
const xquery = params.get('query')?.trim() || ''
setQuery(xquery)
/* await */ fetchSearchResults(xquery)
}
return () => setQuery('')
}, [])

// 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 the input is focused, `/` should have no special behavior
if ((event.target as HTMLInputElement)?.type === 'search') 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) {
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([])
}

// Analytics tracking
if (xquery) {
sendEvent({
type: '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()
}

return (
<div className="pt-3 pt-md-0 d-md-inline-block ml-md-3 border-top border-md-top-0">
<div id="search-input-container" aria-hidden="true">
<div className="ais-SearchBox">
<form role="search" className="ais-SearchBox-form" noValidate onSubmit={preventRefresh}>
<input
ref={inputRef}
className={"ais-SearchBox-input" + (isStandalone || query ? ' js-open' : '')}
type="search"
placeholder={t`placeholder`}
autoFocus={!isStandalone}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
maxLength={512}
onChange={debounce(onSearch, 200)}
defaultValue={query}
/>
<button
className="ais-SearchBox-submit"
type="submit"
title="Submit the search query."
hidden
/>
</form>
</div>
</div>
<div id="search-results-container" className={results.length ? 'js-open' : ''}>
{Boolean(results.length) && (
<div className="ais-Hits d-block">
<ol className="ais-Hits-list">
{results.map(({ url, breadcrumbs, heading, title, content }, index) => (
<li key={url} className={'ais-Hits-item' + ((index + 1) === activeHit ? ' active' : '')}>
<div className="search-result 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="search-result-breadcrumbs d-block color-text-primary opacity-60 text-small pb-1"
dangerouslySetInnerHTML={{ __html: breadcrumbs }}
/>
<div
className="search-result-title d-block h4-mktg color-text-primary"
dangerouslySetInnerHTML={{ __html: heading ? `${title}: ${heading}` : title }}
/>
<div
className="search-result-content d-block color-text-secondary"
dangerouslySetInnerHTML={{ __html: content }}
/>
</a>
</div>
</li>
))}
</ol>
</div>
)}
</div>
<div className={'search-overlay-desktop' + (!isStandalone && query ? ' js-open' : '')} onClick={closeSearch}></div>
</div>
)
}
39 changes: 20 additions & 19 deletions javascripts/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,26 @@ export function getUserEventsId () {
export function sendEvent ({
type,
version = '1.0.0',
exit_render_duration,
exit_first_paint,
exit_dom_interactive,
exit_dom_complete,
exit_visit_duration,
exit_scroll_length,
link_url,
search_query,
search_context,
navigate_label,
survey_vote,
survey_comment,
survey_email,
experiment_name,
experiment_variation,
experiment_success,
clipboard_operation,
preference_name,
preference_value
// `= undefined` is a TypeScript hint.
exit_render_duration = undefined,
exit_first_paint = undefined,
exit_dom_interactive = undefined,
exit_dom_complete = undefined,
exit_visit_duration = undefined,
exit_scroll_length = undefined,
link_url = undefined,
search_query = undefined,
search_context = undefined,
navigate_label = undefined,
survey_vote = undefined,
survey_comment = undefined,
survey_email = undefined,
experiment_name = undefined,
experiment_variation = undefined,
experiment_success = undefined,
clipboard_operation = undefined,
preference_name = undefined,
preference_value = undefined
}) {
const body = {
_csrf: getCsrf(),
Expand Down
2 changes: 2 additions & 0 deletions javascripts/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ let version
let language

export default function search () {
if (window.next) return

// We don't want to mess with query params intended for the GraphQL Explorer
isExplorerPage = Boolean(document.getElementById('graphiql'))

Expand Down

0 comments on commit 5b5354d

Please sign in to comment.