From 66494002ad4e3c37373919da5481b2c5261ac4ce Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Nov 2025 14:02:11 +0100 Subject: [PATCH 1/4] Implement new search UI design and add keyboard navigation --- .../SearchOrAskAi/Search/AskAiButton.tsx | 110 ++++ .../SearchOrAskAi/Search/Search.test.tsx | 470 ++++++++++++++++-- .../SearchOrAskAi/Search/Search.tsx | 234 ++++----- .../SearchOrAskAi/Search/SearchResults.tsx | 268 ---------- .../Search/SearchResults/SearchResults.tsx | 81 +++ .../SearchResults/SearchResultsListItem.tsx | 245 +++++++++ .../SearchOrAskAi/Search/TellMeMoreButton.tsx | 110 ++++ .../SearchOrAskAi/Search/search.store.ts | 5 + .../Search/useKeyboardNavigation.ts | 88 ++++ .../SearchOrAskAi/Search/useSearchQuery.ts | 10 +- .../SearchOrAskAi/SearchOrAskAiButton.tsx | 3 +- .../SearchOrAskAi/SearchOrAskAiModal.tsx | 53 +- 12 files changed, 1203 insertions(+), 474 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/AskAiButton.tsx delete mode 100644 src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResults.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/TellMeMoreButton.tsx create mode 100644 src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/useKeyboardNavigation.ts 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..b9fcc1ff2 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx @@ -0,0 +1,245 @@ +/** @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 && ( + <> + + + + )} +
    + +
    + {/**/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* {result.title}*/} + {/* */} + {/*
  • */} + {/* */} + {/**/} + + ) +} + +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! + + +
    + ) } From d98e0e5c9fbdd89d891ebd5d0844766bda9f6c27 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Nov 2025 14:08:24 +0100 Subject: [PATCH 2/4] Update src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx --- .../SearchResults/SearchResultsListItem.tsx | 39 ------------------- 1 file changed, 39 deletions(-) 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 index b9fcc1ff2..475587ca8 100644 --- 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 @@ -131,45 +131,6 @@ export function SearchResultListItem({ size="m" /> - {/**/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* {result.title}*/} - {/* */} - {/*
    */} - {/* */} - {/**/} ) } From cfa5ed060d5fe1c70f9ef7ca22103a651d7d6113 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Nov 2025 14:08:50 +0100 Subject: [PATCH 3/4] Update src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx --- .../Search/SearchResults/SearchResultsListItem.tsx | 5 ----- 1 file changed, 5 deletions(-) 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 index 475587ca8..359ddc08e 100644 --- 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 @@ -162,11 +162,6 @@ function Breadcrumbs({ parents }: { parents: SearchResultItem['parents'] }) { display: inline-flex; `} > - {/**/} {parent.title} From 49cf314f8975430710d915005c3c982171aaad83 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Wed, 19 Nov 2025 14:09:10 +0100 Subject: [PATCH 4/4] Update src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx --- .../SearchOrAskAi/Search/SearchResults/SearchResultsListItem.tsx | 1 - 1 file changed, 1 deletion(-) 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 index 359ddc08e..28e07a43c 100644 --- 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 @@ -165,7 +165,6 @@ function Breadcrumbs({ parents }: { parents: SearchResultItem['parents'] }) { {parent.title} - {/**/} ))}