Skip to content

Commit d4a6dd2

Browse files
authored
Preselect first search result item (#2309)
* Preselect first search result item Also refactor some hooks * Preselect first search result item
1 parent 4e0354b commit d4a6dd2

File tree

14 files changed

+705
-425
lines changed

14 files changed

+705
-425
lines changed

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/Chat.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export const Chat = () => {
3939
const handleScroll = useScrollPersistence(scrollRef)
4040
useFocusOnComplete(inputRef)
4141

42+
// Focus input when Cmd+; is pressed
43+
const handleMetaSemicolon = useCallback(() => {
44+
inputRef.current?.focus()
45+
}, [])
46+
4247
const {
4348
inputValue,
4449
setInputValue,
@@ -73,6 +78,7 @@ export const Chat = () => {
7378
onAbort={handleAbort}
7479
disabled={isCooldownActive}
7580
isStreaming={isStreaming}
81+
onMetaSemicolon={handleMetaSemicolon}
7682
/>
7783

7884
<InfoBanner />
@@ -222,6 +228,7 @@ interface ChatInputAreaProps {
222228
onAbort: () => void
223229
disabled: boolean
224230
isStreaming: boolean
231+
onMetaSemicolon?: () => void
225232
}
226233

227234
const ChatInputArea = ({
@@ -232,6 +239,7 @@ const ChatInputArea = ({
232239
onAbort,
233240
disabled,
234241
isStreaming,
242+
onMetaSemicolon,
235243
}: ChatInputAreaProps) => {
236244
const { euiTheme } = useEuiTheme()
237245

@@ -252,6 +260,7 @@ const ChatInputArea = ({
252260
disabled={disabled}
253261
inputRef={inputRef}
254262
isStreaming={isStreaming}
263+
onMetaSemicolon={onMetaSemicolon}
255264
/>
256265
</div>
257266
</EuiFlexItem>

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/AskAi/ChatInput.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface ChatInputProps {
1919
placeholder?: string
2020
inputRef?: React.MutableRefObject<HTMLTextAreaElement | null>
2121
isStreaming?: boolean
22+
onMetaSemicolon?: () => void
2223
}
2324

2425
export const ChatInput = ({
@@ -30,6 +31,7 @@ export const ChatInput = ({
3031
placeholder = 'Ask the Elastic Docs AI Assistant',
3132
inputRef,
3233
isStreaming = false,
34+
onMetaSemicolon,
3335
}: ChatInputProps) => {
3436
const { euiTheme } = useEuiTheme()
3537
const scollbarStyling = useEuiScrollBar()
@@ -71,6 +73,19 @@ export const ChatInput = ({
7173
)}px`
7274
}, [value])
7375

76+
// Listen for Cmd+; to focus input
77+
useEffect(() => {
78+
if (!onMetaSemicolon) return
79+
const handleGlobalKeyDown = (e: KeyboardEvent) => {
80+
if ((e.metaKey || e.ctrlKey) && e.code === 'Semicolon') {
81+
e.preventDefault()
82+
onMetaSemicolon()
83+
}
84+
}
85+
window.addEventListener('keydown', handleGlobalKeyDown)
86+
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
87+
}, [onMetaSemicolon])
88+
7489
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
7590
if (e.key === 'Enter' && !e.shiftKey) {
7691
e.preventDefault()

src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/Search/Search.test.tsx

Lines changed: 160 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { chatStore } from '../AskAi/chat.store'
22
import { cooldownStore } from '../cooldown.store'
33
import { modalStore } from '../modal.store'
44
import { Search } from './Search'
5-
import { searchStore } from './search.store'
5+
import { searchStore, NO_SELECTION } from './search.store'
66
import { SearchResultItem } from './useSearchQuery'
77
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8-
import { render, screen, waitFor } from '@testing-library/react'
8+
import { render, screen, waitFor, act } from '@testing-library/react'
99
import userEvent from '@testing-library/user-event'
1010
import * as React from 'react'
1111

@@ -42,7 +42,12 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => {
4242

4343
// Helper to reset all stores
4444
const resetStores = () => {
45-
searchStore.setState({ searchTerm: '', page: 1 })
45+
searchStore.setState({
46+
searchTerm: '',
47+
page: 1,
48+
typeFilter: 'all',
49+
selectedIndex: NO_SELECTION,
50+
})
4651
chatStore.setState({
4752
chatMessages: [],
4853
conversationId: null,
@@ -117,6 +122,20 @@ describe('Search Component', () => {
117122
) as HTMLInputElement
118123
expect(input.value).toBe('kibana')
119124
})
125+
126+
it('should reset selectedIndex when search term changes', async () => {
127+
// Arrange
128+
searchStore.setState({ searchTerm: 'test', selectedIndex: 2 })
129+
const user = userEvent.setup()
130+
131+
// Act
132+
render(<Search />, { wrapper: TestWrapper })
133+
const input = screen.getByPlaceholderText(/search in docs/i)
134+
await user.type(input, 'x')
135+
136+
// Assert - selectedIndex should reset to 0
137+
expect(searchStore.getState().selectedIndex).toBe(0)
138+
})
120139
})
121140

122141
describe('Ask AI button', () => {
@@ -158,7 +177,9 @@ describe('Search Component', () => {
158177
await waitFor(() => {
159178
const messages = chatStore.getState().chatMessages
160179
expect(messages.length).toBeGreaterThanOrEqual(1)
161-
expect(messages[0].content).toBe('what is kibana')
180+
expect(messages[0].content).toBe(
181+
'Tell me more about what is kibana'
182+
)
162183
})
163184
expect(modalStore.getState().mode).toBe('askAi')
164185
})
@@ -184,7 +205,7 @@ describe('Search Component', () => {
184205
})
185206

186207
describe('Search on Enter', () => {
187-
it('should trigger chat when Enter is pressed with valid search', async () => {
208+
it('should trigger chat when Enter is pressed with valid search and no results', async () => {
188209
// Arrange
189210
searchStore.setState({ searchTerm: 'elasticsearch query' })
190211
const user = userEvent.setup()
@@ -198,7 +219,9 @@ describe('Search Component', () => {
198219
// Assert
199220
await waitFor(() => {
200221
const messages = chatStore.getState().chatMessages
201-
expect(messages[0]?.content).toBe('elasticsearch query')
222+
expect(messages[0]?.content).toBe(
223+
'Tell me more about elasticsearch query'
224+
)
202225
})
203226
})
204227

@@ -247,7 +270,7 @@ describe('Search Component', () => {
247270
})
248271
})
249272

250-
describe('Arrow key navigation', () => {
273+
describe('Selection navigation', () => {
251274
beforeEach(() => {
252275
mockSearchResponse([
253276
{
@@ -266,36 +289,60 @@ describe('Search Component', () => {
266289
score: 0.8,
267290
parents: [],
268291
},
292+
{
293+
type: 'doc',
294+
url: '/test3',
295+
title: 'Test Result 3',
296+
description: 'Description 3',
297+
score: 0.7,
298+
parents: [],
299+
},
269300
])
270301
})
271302

272-
it('should navigate from input to first result on ArrowDown', async () => {
273-
// Arrange
274-
searchStore.setState({ searchTerm: 'test' })
303+
it('should select first item after typing (selectedIndex = 0)', async () => {
304+
// Arrange - start with no selection
305+
expect(searchStore.getState().selectedIndex).toBe(NO_SELECTION)
275306
const user = userEvent.setup()
276307

277308
// Act
278309
render(<Search />, { wrapper: TestWrapper })
310+
const input = screen.getByPlaceholderText(/search in docs/i)
311+
await user.type(input, 'test')
279312

280-
// Wait for results to load
281313
await waitFor(() => {
282314
expect(screen.getByText('Test Result 1')).toBeInTheDocument()
283315
})
284316

317+
// Assert - selection appears after typing
318+
expect(searchStore.getState().selectedIndex).toBe(0)
319+
})
320+
321+
it('should move focus to second result on ArrowDown from input (first is already visually selected)', async () => {
322+
// Arrange
323+
const user = userEvent.setup()
324+
325+
// Act
326+
render(<Search />, { wrapper: TestWrapper })
327+
285328
const input = screen.getByPlaceholderText(/search in docs/i)
286-
await user.click(input)
287-
await user.keyboard('{ArrowDown}')
329+
await user.type(input, 'test')
288330

289-
// Assert
290331
await waitFor(() => {
291-
const firstResult = screen
292-
.getByText('Test Result 1')
293-
.closest('a')
294-
expect(firstResult).toHaveFocus()
332+
expect(screen.getByText('Test Result 1')).toBeInTheDocument()
295333
})
334+
335+
// selectedIndex is now 0 after typing
336+
expect(searchStore.getState().selectedIndex).toBe(0)
337+
338+
await user.keyboard('{ArrowDown}')
339+
340+
// Assert - focus moved to second result (first is already visually selected)
341+
const secondResult = screen.getByText('Test Result 2').closest('a')
342+
expect(secondResult).toHaveFocus()
296343
})
297344

298-
it('should navigate between results with ArrowDown', async () => {
345+
it('should move focus between results with ArrowDown/ArrowUp', async () => {
299346
// Arrange
300347
searchStore.setState({ searchTerm: 'test' })
301348
const user = userEvent.setup()
@@ -307,13 +354,104 @@ describe('Search Component', () => {
307354
expect(screen.getByText('Test Result 1')).toBeInTheDocument()
308355
})
309356

357+
// Focus first result
310358
const firstResult = screen.getByText('Test Result 1').closest('a')!
311-
firstResult.focus()
312-
await user.keyboard('{ArrowDown}')
359+
await act(async () => {
360+
firstResult.focus()
361+
})
313362

314-
// Assert
363+
// Navigate down
364+
await user.keyboard('{ArrowDown}')
315365
const secondResult = screen.getByText('Test Result 2').closest('a')
316366
expect(secondResult).toHaveFocus()
367+
expect(searchStore.getState().selectedIndex).toBe(1)
368+
369+
// Navigate up
370+
await user.keyboard('{ArrowUp}')
371+
expect(firstResult).toHaveFocus()
372+
expect(searchStore.getState().selectedIndex).toBe(0)
373+
})
374+
375+
it('should clear selection when ArrowUp from first item goes to input', async () => {
376+
// Arrange
377+
const user = userEvent.setup()
378+
379+
// Act
380+
render(<Search />, { wrapper: TestWrapper })
381+
const input = screen.getByPlaceholderText(/search in docs/i)
382+
await user.type(input, 'test')
383+
384+
await waitFor(() => {
385+
expect(screen.getByText('Test Result 1')).toBeInTheDocument()
386+
})
387+
388+
// Focus first result then press ArrowUp
389+
const firstResult = screen.getByText('Test Result 1').closest('a')!
390+
await act(async () => {
391+
firstResult.focus()
392+
})
393+
expect(searchStore.getState().selectedIndex).toBe(0)
394+
395+
await user.keyboard('{ArrowUp}')
396+
397+
// Assert - focus goes to input, selection is cleared
398+
expect(input).toHaveFocus()
399+
expect(searchStore.getState().selectedIndex).toBe(NO_SELECTION)
400+
})
401+
402+
it('should clear selection when ArrowDown from last item goes to button', async () => {
403+
// Arrange
404+
const user = userEvent.setup()
405+
406+
// Act
407+
render(<Search />, { wrapper: TestWrapper })
408+
const input = screen.getByPlaceholderText(/search in docs/i)
409+
await user.type(input, 'test')
410+
411+
await waitFor(() => {
412+
expect(screen.getByText('Test Result 3')).toBeInTheDocument()
413+
})
414+
415+
// Focus the last item directly
416+
const lastResult = screen.getByText('Test Result 3').closest('a')!
417+
await act(async () => {
418+
lastResult.focus()
419+
})
420+
expect(searchStore.getState().selectedIndex).toBe(2)
421+
422+
// Try to go down from last item
423+
await user.keyboard('{ArrowDown}')
424+
425+
// Assert - focus moves to button, selection is cleared
426+
const button = screen.getByRole('button', {
427+
name: /tell me more about/i,
428+
})
429+
expect(button).toHaveFocus()
430+
expect(searchStore.getState().selectedIndex).toBe(NO_SELECTION)
431+
})
432+
433+
it('should render isSelected prop on the selected item', async () => {
434+
// Arrange
435+
searchStore.setState({ searchTerm: 'test', selectedIndex: 1 })
436+
437+
// Act
438+
render(<Search />, { wrapper: TestWrapper })
439+
440+
await waitFor(() => {
441+
expect(screen.getByText('Test Result 2')).toBeInTheDocument()
442+
})
443+
444+
// Assert - the second result should have the data-selected attribute
445+
const secondResultLink = screen
446+
.getByText('Test Result 2')
447+
.closest('a')
448+
expect(secondResultLink).toHaveAttribute('data-selected', 'true')
449+
450+
// First and third should not be selected
451+
const firstResultLink = screen
452+
.getByText('Test Result 1')
453+
.closest('a')
454+
expect(firstResultLink).not.toHaveAttribute('data-selected')
317455
})
318456
})
319457

0 commit comments

Comments
 (0)