diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx
new file mode 100644
index 000000000..60917a20a
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx
@@ -0,0 +1,110 @@
+import { useIsAskAiCooldownActive } from '../AskAi/useAskAiCooldown'
+import { EuiButton, EuiIcon, useEuiTheme } from '@elastic/eui'
+import { css } from '@emotion/react'
+import { forwardRef } from 'react'
+
+interface TellMeMoreButtonProps {
+ term: string
+ onAsk: () => void
+ onArrowUp?: () => void
+ isInputFocused: boolean
+}
+
+export const TellMeMoreButton = forwardRef<
+ HTMLButtonElement,
+ TellMeMoreButtonProps
+>(({ term, onAsk, onArrowUp, isInputFocused }, ref) => {
+ const isAskAiCooldownActive = useIsAskAiCooldownActive()
+ const { euiTheme } = useEuiTheme()
+
+ const askAiButtonStyles = css`
+ font-weight: ${euiTheme.font.weight.bold};
+ color: ${euiTheme.colors.link};
+ `
+
+ return (
+
+ span {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ gap: ${euiTheme.size.s};
+ }
+ margin-inline: 1px;
+ border: none;
+ position: relative;
+ :focus .return-key-icon {
+ visibility: visible;
+ }
+ `}
+ color="text"
+ fullWidth
+ onClick={onAsk}
+ disabled={isAskAiCooldownActive}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ onArrowUp?.()
+ }
+ }}
+ >
+
+ Tell me more about
+ {term}
+
+
+
+
+ )
+})
+
+TellMeMoreButton.displayName = 'TellMeMoreButton'
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx
index 9253a098b..7d7208e43 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx
@@ -1,5 +1,5 @@
import { Search } from './Search'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@@ -17,8 +17,11 @@ import * as React from 'react'
// Mock dependencies
jest.mock('./search.store', () => ({
useSearchTerm: jest.fn(() => ''),
+ usePageNumber: jest.fn(() => 0),
useSearchActions: jest.fn(() => ({
setSearchTerm: jest.fn(),
+ clearSearchTerm: jest.fn(),
+ setPageNumber: jest.fn(),
})),
}))
@@ -60,18 +63,30 @@ jest.mock('../useCooldown', () => ({
useCooldown: jest.fn(),
}))
-jest.mock('./SearchResults', () => ({
- SearchResults: () => Search Results
,
+// Use the real SearchResults component - its dependencies are already mocked
+
+const mockUseSearchQuery = jest.fn(() => ({
+ isLoading: false,
+ isFetching: false,
+ data: null as {
+ results: Array<{
+ url: string
+ title: string
+ description: string
+ score: number
+ parents: Array<{ url: string; title: string }>
+ }>
+ totalResults: number
+ pageCount: number
+ pageNumber: number
+ pageSize: number
+ } | null,
+ error: null as Error | null,
+ cancelQuery: jest.fn(),
}))
jest.mock('./useSearchQuery', () => ({
- useSearchQuery: jest.fn(() => ({
- isLoading: false,
- isFetching: false,
- data: null,
- error: null,
- cancelQuery: jest.fn(),
- })),
+ useSearchQuery: () => mockUseSearchQuery(),
}))
// Mock SearchOrAskAiErrorCallout
@@ -111,6 +126,8 @@ describe('Search Component', () => {
jest.clearAllMocks()
mockUseSearchActions.mockReturnValue({
setSearchTerm: mockSetSearchTerm,
+ clearSearchTerm: jest.fn(),
+ setPageNumber: jest.fn(),
})
mockUseChatActions.mockReturnValue({
submitQuestion: mockSubmitQuestion,
@@ -132,7 +149,7 @@ describe('Search Component', () => {
// Assert
expect(
- screen.getByPlaceholderText(/search the docs as you type/i)
+ screen.getByPlaceholderText(/search in docs/i)
).toBeInTheDocument()
})
@@ -146,7 +163,7 @@ describe('Search Component', () => {
// Assert
const input = screen.getByPlaceholderText(
- /search the docs as you type/i
+ /search in docs/i
) as HTMLInputElement
expect(input.value).toBe(searchTerm)
})
@@ -158,9 +175,7 @@ describe('Search Component', () => {
// Act
render()
- const input = screen.getByPlaceholderText(
- /search the docs as you type/i
- )
+ const input = screen.getByPlaceholderText(/search in docs/i)
await user.type(input, 'kibana')
// Assert
@@ -191,7 +206,7 @@ describe('Search Component', () => {
// Assert
expect(
- screen.getByRole('button', { name: /ask ai about/i })
+ screen.getByRole('button', { name: /tell me more about/i })
).toBeInTheDocument()
expect(screen.getByText(/elasticsearch/i)).toBeInTheDocument()
})
@@ -205,7 +220,7 @@ describe('Search Component', () => {
// Act
render()
await user.click(
- screen.getByRole('button', { name: /ask ai about/i })
+ screen.getByRole('button', { name: /tell me more about/i })
)
// Assert - verify the workflow is triggered
@@ -222,7 +237,7 @@ describe('Search Component', () => {
// Act
render()
await user.click(
- screen.getByRole('button', { name: /ask ai about/i })
+ screen.getByRole('button', { name: /tell me more about/i })
)
// Assert - submission should be blocked
@@ -239,9 +254,7 @@ describe('Search Component', () => {
// Act
render()
- const input = screen.getByPlaceholderText(
- /search the docs as you type/i
- )
+ const input = screen.getByPlaceholderText(/search in docs/i)
await user.click(input)
await user.keyboard('{Enter}')
@@ -258,9 +271,7 @@ describe('Search Component', () => {
// Act
render()
- const input = screen.getByPlaceholderText(
- /search the docs as you type/i
- )
+ const input = screen.getByPlaceholderText(/search in docs/i)
await user.click(input)
await user.keyboard('{Enter}')
@@ -273,41 +284,436 @@ describe('Search Component', () => {
it('should render SearchResults component', () => {
// Arrange
mockUseSearchTerm.mockReturnValue('test')
+ mockUseSearchQuery.mockReturnValue({
+ isLoading: false,
+ isFetching: false,
+ data: {
+ results: [],
+ totalResults: 0,
+ pageCount: 1,
+ pageNumber: 1,
+ pageSize: 10,
+ },
+ error: null,
+ cancelQuery: jest.fn(),
+ })
// Act
render()
- // Assert
- expect(screen.getByTestId('search-results')).toBeInTheDocument()
+ // Assert - SearchResults renders a div with data-search-results attribute
+ expect(
+ document.querySelector('[data-search-results]')
+ ).toBeInTheDocument()
})
})
describe('Search cancellation', () => {
- const mockUseSearchQuery = jest.mocked(
- jest.requireMock('./useSearchQuery').useSearchQuery
- )
const mockCancelQuery = jest.fn()
beforeEach(() => {
mockCancelQuery.mockClear()
+ mockUseSearchQuery.mockReturnValue({
+ isLoading: false,
+ isFetching: false,
+ data: null,
+ error: null,
+ cancelQuery: mockCancelQuery,
+ })
})
it('should provide cancelQuery function from useSearchQuery', () => {
// Arrange
mockUseSearchTerm.mockReturnValue('')
+
+ // Act
+ render()
+
+ // Assert - verify the hook is called and returns cancelQuery
+ expect(mockUseSearchQuery).toHaveBeenCalled()
+ })
+ })
+
+ describe('Return key icon visibility', () => {
+ it('should show return key icon when input is focused', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('elasticsearch')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const input = screen.getByPlaceholderText(/search in docs/i)
+ await user.click(input)
+
+ // Assert - return key icon should be visible
+ const returnKeyIcon = document.querySelector('.return-key-icon')
+ expect(returnKeyIcon).toBeInTheDocument()
+ expect(returnKeyIcon).toHaveStyle({ visibility: 'visible' })
+ })
+
+ it('should hide return key icon when input loses focus', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('elasticsearch')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ // Input is auto-focused, so icon should be visible initially
+ const returnKeyIcon = document.querySelector('.return-key-icon')
+ expect(returnKeyIcon).toHaveStyle({ visibility: 'visible' })
+
+ // Blur the input
+ await act(async () => {
+ await user.click(document.body)
+ })
+
+ // Assert - return key icon should be hidden after blur
+ await waitFor(() => {
+ expect(returnKeyIcon).toHaveStyle({ visibility: 'hidden' })
+ })
+ })
+
+ it('should show return key icon when Ask AI button is focused', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('elasticsearch')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ await user.tab() // Tab to focus the button
+
+ // Assert - return key icon should be visible when button is focused
+ // Note: CSS :focus selector makes it visible, but we can check the icon exists
+ const returnKeyIcon = document.querySelector('.return-key-icon')
+ expect(returnKeyIcon).toBeInTheDocument()
+ })
+ })
+
+ describe('Arrow key navigation', () => {
+ beforeEach(() => {
+ mockUseSearchQuery.mockReturnValue({
+ isLoading: false,
+ isFetching: false,
+ data: {
+ results: [
+ {
+ url: '/test1',
+ title: 'Test Result 1',
+ description: 'Description 1',
+ score: 0.9,
+ parents: [],
+ },
+ {
+ url: '/test2',
+ title: 'Test Result 2',
+ description: 'Description 2',
+ score: 0.8,
+ parents: [],
+ },
+ {
+ url: '/test3',
+ title: 'Test Result 3',
+ description: 'Description 3',
+ score: 0.7,
+ parents: [],
+ },
+ ],
+ totalResults: 3,
+ pageCount: 1,
+ pageNumber: 1,
+ pageSize: 10,
+ },
+ error: null,
+ cancelQuery: jest.fn(),
+ })
+ })
+
+ it('should navigate from input to first list item on ArrowDown', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const input = screen.getByPlaceholderText(/search in docs/i)
+ await act(async () => {
+ await user.click(input)
+ await user.keyboard('{ArrowDown}')
+ })
+
+ // Assert - first list item should be focused
+ await waitFor(() => {
+ const firstItem = screen.getByText('Test Result 1').closest('a')
+ expect(firstItem).toHaveFocus()
+ })
+ })
+
+ it('should navigate between list items with ArrowDown', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const firstItem = screen.getByText('Test Result 1').closest('a')
+ if (!firstItem) throw new Error('First item not found')
+
+ await act(async () => {
+ firstItem.focus()
+ await user.keyboard('{ArrowDown}')
+ })
+
+ // Assert - second item should be focused
+ const secondItem = screen.getByText('Test Result 2').closest('a')
+ expect(secondItem).toHaveFocus()
+ })
+
+ it('should navigate between list items with ArrowUp', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const secondItem = screen.getByText('Test Result 2').closest('a')
+ if (!secondItem) throw new Error('Second item not found')
+
+ await act(async () => {
+ secondItem.focus()
+ await user.keyboard('{ArrowUp}')
+ })
+
+ // Assert - first item should be focused
+ const firstItem = screen.getByText('Test Result 1').closest('a')
+ expect(firstItem).toHaveFocus()
+ })
+
+ it('should navigate from last list item to Ask AI button on ArrowDown', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const lastItem = screen.getByText('Test Result 3').closest('a')
+ if (!lastItem) throw new Error('Last item not found')
+
+ await act(async () => {
+ lastItem.focus()
+ await user.keyboard('{ArrowDown}')
+ })
+
+ // Assert - Ask AI button should be focused
+ await waitFor(() => {
+ const askAiButton = screen.getByRole('button', {
+ name: /tell me more about/i,
+ })
+ expect(askAiButton).toHaveFocus()
+ })
+ })
+
+ it('should navigate from first list item to input on ArrowUp', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const firstItem = screen.getByText('Test Result 1').closest('a')
+ if (!firstItem) throw new Error('First item not found')
+
+ await act(async () => {
+ firstItem.focus()
+ await user.keyboard('{ArrowUp}')
+ })
+
+ // Assert - input should be focused
+ await waitFor(() => {
+ const input = screen.getByPlaceholderText(/search in docs/i)
+ expect(input).toHaveFocus()
+ })
+ })
+
+ it('should navigate from Ask AI button to last list item on ArrowUp', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const askAiButton = screen.getByRole('button', {
+ name: /tell me more about/i,
+ })
+ await act(async () => {
+ askAiButton.focus()
+ await user.keyboard('{ArrowUp}')
+ })
+
+ // Assert - last list item should be focused
+ await waitFor(() => {
+ const lastItem = screen.getByText('Test Result 3').closest('a')
+ expect(lastItem).toHaveFocus()
+ })
+ })
+
+ it('should navigate from input to Ask AI button when there are no results', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
mockUseSearchQuery.mockReturnValue({
isLoading: false,
isFetching: false,
+ data: {
+ results: [],
+ totalResults: 0,
+ pageCount: 1,
+ pageNumber: 1,
+ pageSize: 10,
+ },
+ error: null,
+ cancelQuery: jest.fn(),
+ })
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const input = screen.getByPlaceholderText(/search in docs/i)
+ await act(async () => {
+ await user.click(input)
+ await user.keyboard('{ArrowDown}')
+ })
+
+ // Assert - Ask AI button should be focused (fallback when no results)
+ await waitFor(() => {
+ const askAiButton = screen.getByRole('button', {
+ name: /tell me more about/i,
+ })
+ expect(askAiButton).toHaveFocus()
+ })
+ })
+
+ it('should navigate from Ask AI button to input when there are no results', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ mockUseSearchQuery.mockReturnValue({
+ isLoading: false,
+ isFetching: false,
+ data: {
+ results: [],
+ totalResults: 0,
+ pageCount: 1,
+ pageNumber: 1,
+ pageSize: 10,
+ },
+ error: null,
+ cancelQuery: jest.fn(),
+ })
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const askAiButton = screen.getByRole('button', {
+ name: /tell me more about/i,
+ })
+ await act(async () => {
+ askAiButton.focus()
+ await user.keyboard('{ArrowUp}')
+ })
+
+ // Assert - input should be focused
+ await waitFor(() => {
+ const input = screen.getByPlaceholderText(/search in docs/i)
+ expect(input).toHaveFocus()
+ })
+ })
+ })
+
+ describe('Loading states', () => {
+ it('should show loading spinner when isLoading is true', () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ mockUseSearchQuery.mockReturnValue({
+ isLoading: true,
+ isFetching: false,
data: null,
error: null,
- cancelQuery: mockCancelQuery,
+ cancelQuery: jest.fn(),
})
// Act
render()
- // Assert - verify the hook is called and returns cancelQuery
- expect(mockUseSearchQuery).toHaveBeenCalled()
+ // Assert
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
+ })
+
+ it('should show loading spinner when isFetching is true', () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ mockUseSearchQuery.mockReturnValue({
+ isLoading: false,
+ isFetching: true,
+ data: null,
+ error: null,
+ cancelQuery: jest.fn(),
+ })
+
+ // Act
+ render()
+
+ // Assert
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
+ })
+ })
+
+ describe('Input value synchronization', () => {
+ it('should use searchTerm directly from store', () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test search')
+ render()
+ const input = screen.getByPlaceholderText(
+ /search in docs/i
+ ) as HTMLInputElement
+
+ // Assert - input value should match searchTerm from store
+ expect(input.value).toBe('test search')
+
+ // Act - update search term in store
+ mockUseSearchTerm.mockReturnValue('updated search')
+ const { rerender } = render()
+ rerender()
+
+ // Assert - input should reflect updated value
+ expect(input.value).toBe('updated search')
+ })
+ })
+
+ describe('Esc button', () => {
+ it('should call clearSearchTerm and closeModal when Esc button is clicked', async () => {
+ // Arrange
+ mockUseSearchTerm.mockReturnValue('test')
+ const mockClearSearchTerm = jest.fn()
+ const mockCloseModal = jest.fn()
+ mockUseSearchActions.mockReturnValue({
+ setSearchTerm: jest.fn(),
+ clearSearchTerm: mockClearSearchTerm,
+ setPageNumber: jest.fn(),
+ })
+ mockUseModalActions.mockReturnValue({
+ setModalMode: jest.fn(),
+ openModal: jest.fn(),
+ closeModal: mockCloseModal,
+ toggleModal: jest.fn(),
+ })
+ const user = userEvent.setup()
+
+ // Act
+ render()
+ const escButton = screen.getByRole('button', { name: /esc/i })
+ await user.click(escButton)
+
+ // Assert
+ expect(mockClearSearchTerm).toHaveBeenCalled()
+ expect(mockCloseModal).toHaveBeenCalled()
})
})
})
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx
index 985f2323f..85a8f22da 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.tsx
@@ -1,55 +1,43 @@
-/** @jsxImportSource @emotion/react */
import { useChatActions } from '../AskAi/chat.store'
import { useIsAskAiCooldownActive } from '../AskAi/useAskAiCooldown'
import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout'
import { useModalActions } from '../modal.store'
-import { SearchResults } from './SearchResults'
+import { SearchResults } from './SearchResults/SearchResults'
+import { TellMeMoreButton } from './TellMeMoreButton'
import { useSearchActions, useSearchTerm } from './search.store'
+import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useIsSearchCooldownActive } from './useSearchCooldown'
import { useSearchQuery } from './useSearchQuery'
-import { EuiFieldText, EuiSpacer, EuiButton, EuiButtonIcon } from '@elastic/eui'
+import {
+ EuiFieldText,
+ EuiSpacer,
+ EuiButton,
+ EuiIcon,
+ EuiLoadingSpinner,
+ EuiText,
+ useEuiTheme,
+ useEuiFontSize,
+} from '@elastic/eui'
import { css } from '@emotion/react'
-import { useCallback, useRef, useState, useEffect } from 'react'
-
-const askAiButtonStyles = css`
- font-weight: bold;
-`
+import { useState } from 'react'
export const Search = () => {
const searchTerm = useSearchTerm()
- const { setSearchTerm } = useSearchActions()
+ const { setSearchTerm, clearSearchTerm } = useSearchActions()
const { submitQuestion, clearChat } = useChatActions()
- const { setModalMode } = useModalActions()
+ const { setModalMode, closeModal } = useModalActions()
const isSearchCooldownActive = useIsSearchCooldownActive()
const isAskAiCooldownActive = useIsAskAiCooldownActive()
- const inputRef = useRef(null)
- const [inputValue, setInputValue] = useState(searchTerm)
- const { isLoading, isFetching, cancelQuery, refetch } = useSearchQuery({
- searchTerm,
- })
- const [isButtonVisible, setIsButtonVisible] = useState(false)
- const [isAnimatingOut, setIsAnimatingOut] = useState(false)
+ const [isInputFocused, setIsInputFocused] = useState(false)
+ const { isLoading, isFetching } = useSearchQuery()
+ const xsFontSize = useEuiFontSize('xs').fontSize
+ const { euiTheme } = useEuiTheme()
- const triggerSearch = useCallback(() => {
- refetch()
- }, [refetch])
+ const handleSearch = (e: React.ChangeEvent) => {
+ setSearchTerm(e.target.value)
+ }
- const handleSearch = useCallback(
- (e?: React.ChangeEvent) => {
- const newValue = e?.target.value ?? inputRef.current?.value ?? ''
- setInputValue(newValue)
- setSearchTerm(newValue)
- },
- [
- searchTerm,
- isSearchCooldownActive,
- isAskAiCooldownActive,
- clearChat,
- submitQuestion,
- setModalMode,
- ]
- )
- const handleChat = useCallback(() => {
+ const handleAskAi = () => {
if (isAskAiCooldownActive || searchTerm.trim() === '') {
return
}
@@ -57,40 +45,16 @@ export const Search = () => {
clearChat()
submitQuestion(searchTerm)
setModalMode('askAi')
- }, [
- searchTerm,
- isAskAiCooldownActive,
- clearChat,
- submitQuestion,
- setModalMode,
- ])
-
- // Sync inputValue with searchTerm from store (when cleared externally)
- useEffect(() => {
- if (searchTerm === '' && inputValue !== '') {
- setInputValue('')
- }
- }, [searchTerm, inputValue])
+ }
- // Handle button visibility and animation
- useEffect(() => {
- const hasSearchTerm = searchTerm.trim() !== ''
-
- if (hasSearchTerm && !isButtonVisible) {
- // Show button with slide in animation
- setIsButtonVisible(true)
- setIsAnimatingOut(false)
- } else if (!hasSearchTerm && isButtonVisible) {
- // Start exit animation
- setIsAnimatingOut(true)
- // Remove button after animation completes
- const timer = setTimeout(() => {
- setIsButtonVisible(false)
- setIsAnimatingOut(false)
- }, 200) // Match animation duration
- return () => clearTimeout(timer)
- }
- }, [searchTerm, isButtonVisible])
+ const {
+ inputRef,
+ buttonRef,
+ handleInputKeyDown,
+ handleListItemKeyDown,
+ focusLastAvailable,
+ setItemRef,
+ } = useKeyboardNavigation(handleAskAi)
return (
<>
@@ -104,100 +68,86 @@ export const Search = () => {
`}
>
{
- if (e.key === 'Enter') {
- handleChat()
- }
- }}
- disabled={isSearchCooldownActive}
- />
- setIsInputFocused(true)}
+ onBlur={() => setIsInputFocused(false)}
+ onKeyDown={handleInputKeyDown}
disabled={isSearchCooldownActive}
- isLoading={isLoading || isFetching}
/>
- {isButtonVisible && (
-
+
+
+ ) : (
+ {
- if (isLoading || isFetching) {
- cancelQuery()
- } else {
- setSearchTerm('')
- }
- }}
/>
)}
+ {
+ clearSearchTerm()
+ closeModal()
+ }}
+ >
+ Esc
+
+
{searchTerm && (
<>
-
+ Ask AI assistant
+
+
+ {
- // Prevent submission during countdown
- if (isAskAiCooldownActive) {
- return
- }
- clearChat()
- if (searchTerm.trim()) submitQuestion(searchTerm)
- setModalMode('askAi')
- }}
+ isInputFocused={isInputFocused}
+ onAsk={handleAskAi}
+ onArrowUp={focusLastAvailable}
/>
>
)}
-
-
-
>
)
}
-const AskAiButton = ({ term, onAsk }: { term: string; onAsk: () => void }) => {
- const isAskAiCooldownActive = useIsAskAiCooldownActive()
- return (
-
- Ask AI about "{term}"
-
- )
-}
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults.tsx
deleted file mode 100644
index 516cd6d2b..000000000
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-import { SearchOrAskAiErrorCallout } from '../SearchOrAskAiErrorCallout'
-import { useSearchTerm } from './search.store'
-import { SearchResultItem, useSearchQuery } from './useSearchQuery'
-import {
- useEuiFontSize,
- EuiLink,
- EuiLoadingSpinner,
- EuiSpacer,
- EuiText,
- useEuiTheme,
- EuiIcon,
- EuiPagination,
- EuiHorizontalRule,
-} from '@elastic/eui'
-import { css } from '@emotion/react'
-import { useDebounce } from '@uidotdev/usehooks'
-import DOMPurify from 'dompurify'
-import { useEffect, useMemo, useState, memo } from 'react'
-
-export const SearchResults = () => {
- const searchTerm = useSearchTerm()
- const [activePage, setActivePage] = useState(0)
- const debouncedSearchTerm = useDebounce(searchTerm, 300)
-
- useEffect(() => {
- setActivePage(0)
- }, [debouncedSearchTerm])
-
- const { data, error, isLoading, isFetching } = useSearchQuery({
- searchTerm,
- pageNumber: activePage + 1,
- })
- const { euiTheme } = useEuiTheme()
-
- if (!searchTerm) {
- return null
- }
-
- return (
- <>
-
- {error && }
-
- {!error && (
-
-
- {isLoading || isFetching ? (
-
- ) : (
-
- )}
-
- Search results for{' '}
-
- {searchTerm}
-
-
-
-
- {data && (
- <>
-
- {data.results.map((result) => (
-
- ))}
-
-
-
-
- setActivePage(activePage)
- }
- />
-
- >
- )}
-
-
- )}
- >
- )
-}
-
-interface SearchResultListItemProps {
- item: SearchResultItem
-}
-
-function SearchResultListItem({ item: result }: SearchResultListItemProps) {
- const { euiTheme } = useEuiTheme()
- const titleFontSize = useEuiFontSize('m')
- return (
-
-
-
-
-
-
-
- {result.title}
-
-
-
-
-
- {result.highlightedBody ? (
-
- ) : (
- {result.description}
- )}
-
-
-
-
-
- )
-}
-
-function Breadcrumbs({ parents }: { parents: SearchResultItem['parents'] }) {
- const { euiTheme } = useEuiTheme()
- const { fontSize: smallFontsize } = useEuiFontSize('xs')
- return (
-
- {parents
- // .slice(1) // skip /docs
- .map((parent) => (
- -
-
-
- {parent.title}
-
-
-
- ))}
-
- )
-}
-
-const SanitizedHtmlContent = memo(
- ({ htmlContent }: { htmlContent: string }) => {
- const processed = useMemo(() => {
- if (!htmlContent) return ''
-
- const sanitized = DOMPurify.sanitize(htmlContent, {
- ALLOWED_TAGS: ['mark'],
- ALLOWED_ATTR: [],
- KEEP_CONTENT: true,
- })
-
- // Check if text starts mid-sentence (lowercase first letter)
- const temp = document.createElement('div')
- temp.innerHTML = sanitized
- const text = temp.textContent || ''
- const firstChar = text.trim()[0]
-
- // Add leading ellipsis if starts with lowercase
- if (firstChar && /[a-z]/.test(firstChar)) {
- return '… ' + sanitized
- }
-
- return sanitized
- }, [htmlContent])
-
- return
- }
-)
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx
new file mode 100644
index 000000000..a53c27c5d
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx
@@ -0,0 +1,81 @@
+import { SearchOrAskAiErrorCallout } from '../../SearchOrAskAiErrorCallout'
+import { usePageNumber, useSearchActions, useSearchTerm } from '../search.store'
+import { useSearchQuery } from '../useSearchQuery'
+import { SearchResultListItem } from './SearchResultsListItem'
+import { EuiPagination, EuiSpacer } from '@elastic/eui'
+import { css } from '@emotion/react'
+import { useDebounce } from '@uidotdev/usehooks'
+import { useEffect } from 'react'
+
+interface SearchResultsProps {
+ onKeyDown?: (e: React.KeyboardEvent, index: number) => void
+ setItemRef?: (element: HTMLAnchorElement | null, index: number) => void
+}
+
+export const SearchResults = ({
+ onKeyDown,
+ setItemRef,
+}: SearchResultsProps) => {
+ const searchTerm = useSearchTerm()
+ const activePage = usePageNumber()
+ const { setPageNumber: setActivePage } = useSearchActions()
+ const debouncedSearchTerm = useDebounce(searchTerm, 300)
+
+ useEffect(() => {
+ setActivePage(0)
+ }, [debouncedSearchTerm])
+
+ const { data, error } = useSearchQuery()
+
+ if (!searchTerm) {
+ return null
+ }
+
+ return (
+ <>
+
+ {error && }
+
+ {!error && (
+
+
+ {data && (
+ <>
+
+ {data.results.map((result, index) => (
+
+ ))}
+
+
+
+
+ setActivePage(activePage)
+ }
+ />
+
+ >
+ )}
+
+ )}
+ >
+ )
+}
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx
new file mode 100644
index 000000000..28e07a43c
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx
@@ -0,0 +1,200 @@
+/** @jsxImportSource @emotion/react */
+import { type SearchResultItem } from '../useSearchQuery'
+import {
+ EuiText,
+ useEuiTheme,
+ useEuiFontSize,
+ EuiIcon,
+ EuiSpacer,
+} from '@elastic/eui'
+import { css } from '@emotion/react'
+import DOMPurify from 'dompurify'
+import { memo, useMemo } from 'react'
+
+interface SearchResultListItemProps {
+ item: SearchResultItem
+ index: number
+ onKeyDown?: (e: React.KeyboardEvent, index: number) => void
+ setRef?: (element: HTMLAnchorElement | null, index: number) => void
+}
+
+export function SearchResultListItem({
+ item: result,
+ index,
+ onKeyDown,
+ setRef,
+}: SearchResultListItemProps) {
+ const { euiTheme } = useEuiTheme()
+ const titleFontSize = useEuiFontSize('s')
+ return (
+
+ setRef?.(el, index)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ window.location.href = result.url
+ } else {
+ // Type mismatch: event is from anchor but handler expects HTMLLIElement
+ onKeyDown?.(
+ e as unknown as React.KeyboardEvent,
+ index
+ )
+ }
+ }}
+ css={css`
+ display: flex;
+ align-items: center;
+ gap: ${euiTheme.size.base};
+ border-radius: ${euiTheme.border.radius.small};
+ width: 100%;
+ padding-inline: ${euiTheme.size.base};
+ padding-block: ${euiTheme.size.m};
+ :hover {
+ background-color: ${euiTheme.colors
+ .backgroundBaseSubdued};
+ }
+ :focus {
+ background-color: ${euiTheme.colors
+ .backgroundBaseSubdued};
+ }
+ :focus .return-key-icon {
+ visibility: visible;
+ }
+ `}
+ tabIndex={0}
+ href={result.url}
+ >
+
+
+
+ {result.title}
+
+
+
+
+ {result.highlightedBody ? (
+
+ ) : (
+ {result.description}
+ )}
+
+
+ {result.parents.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ )
+}
+
+function Breadcrumbs({ parents }: { parents: SearchResultItem['parents'] }) {
+ const { euiTheme } = useEuiTheme()
+ const { fontSize: smallFontsize } = useEuiFontSize('xs')
+ return (
+
+ {parents.slice(1).map((parent) => (
+ -
+
+ {parent.title}
+
+
+ ))}
+
+ )
+}
+
+const SanitizedHtmlContent = memo(
+ ({ htmlContent }: { htmlContent: string }) => {
+ const processed = useMemo(() => {
+ if (!htmlContent) return ''
+
+ const sanitized = DOMPurify.sanitize(htmlContent, {
+ ALLOWED_TAGS: ['mark'],
+ ALLOWED_ATTR: [],
+ KEEP_CONTENT: true,
+ })
+
+ 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])
+
+ return
+ }
+)
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx
new file mode 100644
index 000000000..ef6d82faa
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx
@@ -0,0 +1,110 @@
+import { useIsAskAiCooldownActive } from '../AskAi/useAskAiCooldown'
+import { EuiButton, EuiIcon, useEuiTheme } from '@elastic/eui'
+import { css } from '@emotion/react'
+import { forwardRef } from 'react'
+
+const gradientContainerStyles = css`
+ @keyframes gradientMove {
+ from {
+ background-position: 0% 0%;
+ }
+ to {
+ background-position: 100% 0%;
+ }
+ }
+ height: 42px;
+ background: linear-gradient(
+ 90deg,
+ #f04e98 0%,
+ #02bcb7 25%,
+ #f04e98 50%,
+ #02bcb7 75%,
+ #f04e98 100%
+ );
+ background-size: 200% 100%;
+ background-position: 0% 0%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ animation: gradientMove 3s ease infinite;
+`
+
+interface TellMeMoreButtonProps {
+ term: string
+ onAsk: () => void
+ onArrowUp?: () => void
+ isInputFocused: boolean
+}
+
+export const TellMeMoreButton = forwardRef<
+ HTMLButtonElement,
+ TellMeMoreButtonProps
+>(({ term, onAsk, onArrowUp, isInputFocused }, ref) => {
+ const isAskAiCooldownActive = useIsAskAiCooldownActive()
+ const { euiTheme } = useEuiTheme()
+
+ const highlightedTextStyles = css`
+ font-weight: ${euiTheme.font.weight.bold};
+ color: ${euiTheme.colors.link};
+ `
+
+ return (
+
+ span {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ gap: ${euiTheme.size.s};
+ }
+ margin-inline: 1px;
+ border: none;
+ position: relative;
+ :focus .return-key-icon {
+ visibility: visible;
+ }
+ `}
+ color="text"
+ fullWidth
+ onClick={onAsk}
+ disabled={isAskAiCooldownActive}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ onArrowUp?.()
+ }
+ }}
+ >
+
+ Tell me more about
+ {term}
+
+
+
+
+ )
+})
+
+TellMeMoreButton.displayName = 'TellMeMoreButton'
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts
index 1ef622c10..3b4710863 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/search.store.ts
@@ -2,19 +2,24 @@ import { create } from 'zustand/react'
interface SearchState {
searchTerm: string
+ page: number
actions: {
setSearchTerm: (term: string) => void
+ setPageNumber: (page: number) => void
clearSearchTerm: () => void
}
}
export const searchStore = create((set) => ({
searchTerm: '',
+ page: 1,
actions: {
setSearchTerm: (term: string) => set({ searchTerm: term }),
+ setPageNumber: (page: number) => set({ page }),
clearSearchTerm: () => set({ searchTerm: '' }),
},
}))
export const useSearchTerm = () => searchStore((state) => state.searchTerm)
+export const usePageNumber = () => searchStore((state) => state.page)
export const useSearchActions = () => searchStore((state) => state.actions)
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts
new file mode 100644
index 000000000..83418a840
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts
@@ -0,0 +1,88 @@
+import { useRef, MutableRefObject } from 'react'
+
+interface KeyboardNavigationReturn {
+ inputRef: React.RefObject
+ buttonRef: React.RefObject
+ listItemRefs: MutableRefObject<(HTMLAnchorElement | null)[]>
+ handleInputKeyDown: (e: React.KeyboardEvent) => void
+ handleListItemKeyDown: (
+ e: React.KeyboardEvent,
+ currentIndex: number
+ ) => void
+ focusLastAvailable: () => void
+ setItemRef: (element: HTMLAnchorElement | null, index: number) => void
+}
+
+export const useKeyboardNavigation = (
+ onEnter?: () => void
+): KeyboardNavigationReturn => {
+ const inputRef = useRef(null)
+ const buttonRef = useRef(null)
+ const listItemRefs = useRef<(HTMLAnchorElement | null)[]>([])
+
+ const setItemRef = (element: HTMLAnchorElement | null, index: number) => {
+ listItemRefs.current[index] = element
+ }
+
+ const focusFirstAvailable = () => {
+ const firstItem = listItemRefs.current.find((item) => item !== null)
+ if (firstItem) {
+ firstItem.focus()
+ } else if (buttonRef.current) {
+ buttonRef.current.focus()
+ }
+ }
+
+ const focusLastAvailable = () => {
+ const lastItem = [...listItemRefs.current]
+ .reverse()
+ .find((item) => item !== null)
+ if (lastItem) {
+ lastItem.focus()
+ } else if (inputRef.current) {
+ inputRef.current.focus()
+ }
+ }
+
+ const handleInputKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ onEnter?.()
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ focusFirstAvailable()
+ }
+ }
+
+ const handleListItemKeyDown = (
+ e: React.KeyboardEvent,
+ currentIndex: number
+ ) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ const nextItem = listItemRefs.current[currentIndex + 1]
+ if (nextItem) {
+ nextItem.focus()
+ } else {
+ buttonRef.current?.focus()
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ const prevItem = listItemRefs.current[currentIndex - 1]
+ if (prevItem) {
+ prevItem.focus()
+ } else {
+ inputRef.current?.focus()
+ }
+ }
+ }
+
+ return {
+ inputRef,
+ buttonRef,
+ listItemRefs,
+ handleInputKeyDown,
+ handleListItemKeyDown,
+ focusLastAvailable,
+ setItemRef,
+ }
+}
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts
index 7e085dc30..4eca72490 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useSearchQuery.ts
@@ -1,5 +1,6 @@
import { createApiErrorFromResponse, shouldRetry } from '../errorHandling'
import { ApiError } from '../errorHandling'
+import { usePageNumber, useSearchTerm } from './search.store'
import {
useIsSearchAwaitingNewInput,
useSearchCooldownActions,
@@ -40,12 +41,9 @@ const SearchResponse = z.object({
export type SearchResponse = z.infer
-type Props = {
- searchTerm: string
- pageNumber?: number
-}
-
-export const useSearchQuery = ({ searchTerm, pageNumber = 1 }: Props) => {
+export const useSearchQuery = () => {
+ const searchTerm = useSearchTerm()
+ const pageNumber = usePageNumber() + 1
const trimmedSearchTerm = searchTerm.trim()
const debouncedSearchTerm = useDebounce(trimmedSearchTerm, 300)
const isCooldownActive = useIsSearchCooldownActive()
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx
index 977c9f77e..2ee2adb5b 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiButton.tsx
@@ -14,7 +14,6 @@ import {
} from '@elastic/eui'
import { css } from '@emotion/react'
import { useQuery } from '@tanstack/react-query'
-import * as React from 'react'
import { useEffect, Suspense, lazy } from 'react'
// Lazy load the modal component
@@ -45,7 +44,7 @@ export const SearchOrAskAiButton = () => {
left: 50%;
transform: translateX(-50%);
top: 48px;
- width: 90ch;
+ width: 80ch;
max-width: 100%;
`
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx
index 1520c9c8d..d6eb3b0e6 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/SearchOrAskAiModal.tsx
@@ -16,6 +16,7 @@ import {
EuiLink,
EuiTabbedContent,
EuiIcon,
+ EuiHorizontalRule,
type EuiTabbedContentTab,
} from '@elastic/eui'
import { css } from '@emotion/react'
@@ -80,32 +81,36 @@ export const SearchOrAskAiModal = React.memo(() => {
const ModalFooter = () => {
return (
-
-
+
+
+ >
+
-
- This feature is in private preview (alpha).{' '}
-
- Got feedback? We'd love to hear it!
-
-
-
+
+ This feature is in private preview (alpha).{' '}
+
+ Got feedback? We'd love to hear it!
+
+
+
+ >
)
}