Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { KeyboardShortcutsFooter } from '../KeyboardShortcutsFooter'
import { useSearchTerm, useSearchActions } from '../Search/search.store'
import { useIsSearchCooldownActive } from '../Search/useSearchCooldown'
import { useSearchQuery } from '../Search/useSearchQuery'
import { SearchDropdownHeader } from './SearchDropdownHeader'
import { SearchInput } from './SearchInput'
import { SearchResultsList } from './SearchResultsList'
import { useGlobalKeyboardShortcut } from './useGlobalKeyboardShortcut'
import { useNavigationSearchKeyboardNavigation } from './useNavigationSearchKeyboardNavigation'
import { EuiInputPopover, EuiHorizontalRule } from '@elastic/eui'
import { css } from '@emotion/react'
import { useState, useRef } from 'react'

export const NavigationSearch = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const popoverContentRef = useRef<HTMLDivElement>(null)
const searchTerm = useSearchTerm()
const { setSearchTerm } = useSearchActions()
const isSearchCooldownActive = useIsSearchCooldownActive()
const { isLoading, isFetching, data } = useSearchQuery()

const results = data?.results ?? []
const hasContent = !!searchTerm.trim()
const isSearching = isLoading || isFetching

const {
inputRef,
itemRefs,
isKeyboardNavigating,
handleInputKeyDown,
handleMouseMove,
} = useNavigationSearchKeyboardNavigation({
resultsCount: results.length,
isLoading: isSearching,
onClose: () => setIsPopoverOpen(false),
})

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setSearchTerm(value)
setIsPopoverOpen(!!value.trim())
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault()
setSearchTerm('')
setIsPopoverOpen(false)
return
}
handleInputKeyDown(e)
}

const handleBlur = (e: React.FocusEvent) => {
// Check if focus is moving to something inside the popover
const relatedTarget = e.relatedTarget as Node | null
if (
relatedTarget &&
popoverContentRef.current?.contains(relatedTarget)
) {
// Focus is moving inside the popover, don't close
return
}
setIsPopoverOpen(false)
}

useGlobalKeyboardShortcut('k', () => inputRef.current?.focus())

return (
<EuiInputPopover
isOpen={isPopoverOpen && hasContent}
closePopover={() => setIsPopoverOpen(false)}
ownFocus={false}
disableFocusTrap={true}
panelMinWidth={640}
panelPaddingSize="none"
offset={12}
panelProps={{
css: css`
max-width: 640px;
`,
onMouseDown: (e: React.MouseEvent) => {
// Prevent input blur when clicking anywhere inside the popover panel
e.preventDefault()
},
}}
input={
<SearchInput
inputRef={inputRef}
value={searchTerm}
onChange={handleChange}
onFocus={() => hasContent && setIsPopoverOpen(true)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
disabled={isSearchCooldownActive}
isLoading={isSearching}
/>
}
>
{hasContent && (
<div ref={popoverContentRef}>
<SearchDropdownContent
itemRefs={itemRefs}
isKeyboardNavigating={isKeyboardNavigating}
onMouseMove={handleMouseMove}
/>
</div>
)}
</EuiInputPopover>
)
}

const KEYBOARD_SHORTCUTS = [
{ keys: ['returnKey'], label: 'Jump to' },
{ keys: ['sortUp', 'sortDown'], label: 'Navigate' },
{ keys: ['Esc'], label: 'Close' },
]

interface SearchDropdownContentProps {
itemRefs: React.MutableRefObject<(HTMLAnchorElement | null)[]>
isKeyboardNavigating: React.MutableRefObject<boolean>
onMouseMove: () => void
}

const SearchDropdownContent = ({
itemRefs,
isKeyboardNavigating,
onMouseMove,
}: SearchDropdownContentProps) => (
<>
<SearchDropdownHeader />
<EuiHorizontalRule margin="none" />
<SearchResultsList
itemRefs={itemRefs}
isKeyboardNavigating={isKeyboardNavigating}
onMouseMove={onMouseMove}
/>
<KeyboardShortcutsFooter shortcuts={KEYBOARD_SHORTCUTS} />
</>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import DOMPurify from 'dompurify'
import { memo, useMemo } from 'react'

interface SanitizedHtmlContentProps {
htmlContent: string
ellipsis?: boolean
}

export const SanitizedHtmlContent = memo(
({ htmlContent, ellipsis }: SanitizedHtmlContentProps) => {
const processed = useMemo(() => {
if (!htmlContent) return ''

const sanitized = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['mark'],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
})

if (!ellipsis) {
return sanitized
}

const temp = document.createElement('div')
temp.innerHTML = sanitized

const text = temp.textContent || ''
const firstChar = text.trim()[0]

// Add ellipsis when text starts mid-sentence to indicate continuation
if (firstChar && /[a-z]/.test(firstChar)) {
return '… ' + sanitized
}

return sanitized
}, [htmlContent, ellipsis])

return <span dangerouslySetInnerHTML={{ __html: processed }} />
}
)

SanitizedHtmlContent.displayName = 'SanitizedHtmlContent'
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
EuiBetaBadge,
EuiLink,
useEuiTheme,
useEuiFontSize,
} from '@elastic/eui'
import { css } from '@emotion/react'

const FEEDBACK_URL =
'https://github.com/elastic/docs-eng-team/issues/new?template=search-or-ask-ai-feedback.yml'

export const SearchDropdownHeader = () => {
const { euiTheme } = useEuiTheme()
const { fontSize: xsFontsize } = useEuiFontSize('xs')

return (
<div
css={css`
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: ${euiTheme.size.base};
padding-block: ${euiTheme.size.m};
`}
>
<div
css={css`
display: flex;
align-items: center;
gap: ${euiTheme.size.s};
`}
>
<span
css={css`
color: ${euiTheme.colors.textParagraph};
`}
>
Pages
</span>
<span>·</span>
<EuiBetaBadge
color="accent"
label="ALPHA"
css={css`
display: inline-flex;
align-items: center;
`}
/>
</div>
<EuiLink
href={FEEDBACK_URL}
target="_blank"
external
css={css`
font-size: ${xsFontsize};
`}
>
Give feedback
</EuiLink>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { EuiIcon, EuiLoadingSpinner, useEuiTheme } from '@elastic/eui'
import { css } from '@emotion/react'

export interface SearchInputProps {
inputRef: React.RefObject<HTMLInputElement>
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onFocus: () => void
onBlur: (e: React.FocusEvent) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
disabled: boolean
isLoading: boolean
}

export const SearchInput = ({
inputRef,
value,
onChange,
onFocus,
onBlur,
onKeyDown,
disabled,
isLoading,
}: SearchInputProps) => {
const { euiTheme } = useEuiTheme()

return (
<div
css={css`
position: relative;
display: flex;
align-items: center;
`}
>
<span
css={css`
position: absolute;
left: ${euiTheme.size.m};
display: flex;
align-items: center;
pointer-events: none;
`}
>
{isLoading ? (
<EuiLoadingSpinner size="s" />
) : (
<EuiIcon type="search" size="m" color="subdued" />
)}
</span>

<input
ref={inputRef}
type="text"
placeholder="Search in Docs"
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
disabled={disabled}
css={css`
width: 100%;
padding: ${euiTheme.size.s} ${euiTheme.size.m};
padding-left: ${euiTheme.size.xxl};
padding-right: 62px;
border: 1px solid ${euiTheme.colors.borderBaseSubdued};
border-radius: ${euiTheme.border.radius.medium};
background: ${euiTheme.colors.backgroundBasePlain};
font-size: ${euiTheme.font.scale.m * euiTheme.base}px;
color: ${euiTheme.colors.textParagraph};
outline: none;

&::placeholder {
color: ${euiTheme.colors.textSubdued};
}

&:focus {
border-color: ${euiTheme.colors.primary};
}
`}
/>

<span
css={css`
position: absolute;
right: ${euiTheme.size.m};
display: flex;
align-items: center;
pointer-events: none;
height: ${euiTheme.size.l};
padding: 0 ${euiTheme.size.m};
gap: ${euiTheme.size.s};
border-radius: ${euiTheme.border.radius.small};
border: 1px solid ${euiTheme.colors.borderBasePlain};
background: ${euiTheme.colors.backgroundBasePlain};
color: ${euiTheme.colors.textDisabled};
font-size: ${euiTheme.size.m};
font-weight: ${euiTheme.font.weight.regular};
line-height: ${euiTheme.size.base};
`}
>
⌘K
</span>
</div>
)
}
Loading
Loading