11import { css , cva } from "@hashintel/ds-helpers/css" ;
22import fuzzysort from "fuzzysort" ;
33import type { ComponentType , ReactNode } from "react" ;
4- import { use , useEffect , useMemo , useState } from "react" ;
4+ import { use , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
55import { LuSearch } from "react-icons/lu" ;
66
77import { IconButton } from "../../../../../components/icon-button" ;
@@ -47,6 +47,7 @@ const resultListStyle = css({
4747 gap : "[1px]" ,
4848 py : "1" ,
4949 mx : "-1" ,
50+ outline : "none" ,
5051} ) ;
5152
5253const resultRowStyle = cva ( {
@@ -74,6 +75,11 @@ const resultRowStyle = cva({
7475 _hover : { backgroundColor : "neutral.bg.surface.hover" } ,
7576 } ,
7677 } ,
78+ isFocused : {
79+ true : {
80+ backgroundColor : "neutral.bg.subtle.hover" ,
81+ } ,
82+ } ,
7783 } ,
7884} ) ;
7985
@@ -202,6 +208,9 @@ const SearchContent: React.FC = () => {
202208 const { isSelected : checkIsSelected , selectItem } = use ( EditorContext ) ;
203209 const allItems = useSearchableItems ( ) ;
204210 const [ query , setQuery ] = useState ( "" ) ;
211+ const [ focusedIndex , setFocusedIndex ] = useState < number | null > ( null ) ;
212+ const listRef = useRef < HTMLDivElement > ( null ) ;
213+ const rowRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
205214
206215 const { searchInputRef } = use ( EditorContext ) ;
207216
@@ -212,7 +221,11 @@ const SearchContent: React.FC = () => {
212221 return ;
213222 }
214223
215- const handleInput = ( ) => setQuery ( input . value ) ;
224+ const handleInput = ( ) => {
225+ setQuery ( input . value ) ;
226+ // Reset focus when query changes
227+ setFocusedIndex ( null ) ;
228+ } ;
216229 input . addEventListener ( "input" , handleInput ) ;
217230 setQuery ( input . value ) ;
218231 return ( ) => input . removeEventListener ( "input" , handleInput ) ;
@@ -231,15 +244,82 @@ const SearchContent: React.FC = () => {
231244
232245 return fuzzyResults . map ( ( result ) => ( {
233246 item : result . obj ,
234- highlighted :
235- fuzzysort . highlight ( result [ 0 ] , ( match , i : number ) => (
236- < span key = { i } className = { highlightStyle } >
237- { match }
238- </ span >
239- ) ) ?? result . obj . name ,
247+ highlighted : result . highlight ( ( match , i ) => (
248+ < span key = { i } className = { highlightStyle } >
249+ { match }
250+ </ span >
251+ ) ) ,
240252 } ) ) ;
241253 } , [ query , allItems ] ) ;
242254
255+ // Clamp focusedIndex when results shrink
256+ useEffect ( ( ) => {
257+ if ( results . length === 0 ) {
258+ setFocusedIndex ( null ) ;
259+ } else {
260+ setFocusedIndex ( ( prev ) =>
261+ prev !== null ? Math . min ( prev , results . length - 1 ) : prev ,
262+ ) ;
263+ }
264+ } , [ results . length ] ) ;
265+
266+ // Scroll focused item into view
267+ useEffect ( ( ) => {
268+ if ( focusedIndex !== null ) {
269+ rowRefs . current [ focusedIndex ] ?. scrollIntoView ( { block : "nearest" } ) ;
270+ }
271+ } , [ focusedIndex ] ) ;
272+
273+ const handleListKeyDown = useCallback (
274+ ( event : React . KeyboardEvent ) => {
275+ switch ( event . key ) {
276+ case "ArrowDown" : {
277+ event . preventDefault ( ) ;
278+ if ( results . length === 0 ) {
279+ return ;
280+ }
281+ const nextIndex =
282+ focusedIndex === null
283+ ? 0
284+ : Math . min ( focusedIndex + 1 , results . length - 1 ) ;
285+ setFocusedIndex ( nextIndex ) ;
286+ const item = results [ nextIndex ] ;
287+ if ( item ) {
288+ selectItem ( item . item . selectionItem ) ;
289+ }
290+ break ;
291+ }
292+ case "ArrowUp" : {
293+ event . preventDefault ( ) ;
294+ if ( focusedIndex === null || focusedIndex === 0 ) {
295+ // Move focus back to the search input
296+ setFocusedIndex ( null ) ;
297+ searchInputRef . current ?. focus ( ) ;
298+ } else {
299+ const nextIndex = focusedIndex - 1 ;
300+ setFocusedIndex ( nextIndex ) ;
301+ const item = results [ nextIndex ] ;
302+ if ( item ) {
303+ selectItem ( item . item . selectionItem ) ;
304+ }
305+ }
306+ break ;
307+ }
308+ case "Enter" : {
309+ event . preventDefault ( ) ;
310+ if ( focusedIndex !== null ) {
311+ const item = results [ focusedIndex ] ;
312+ if ( item ) {
313+ selectItem ( item . item . selectionItem ) ;
314+ }
315+ }
316+ break ;
317+ }
318+ }
319+ } ,
320+ [ results , focusedIndex , selectItem , searchInputRef ] ,
321+ ) ;
322+
243323 const matchLabel =
244324 query . trim ( ) === ""
245325 ? `${ results . length } items`
@@ -249,14 +329,42 @@ const SearchContent: React.FC = () => {
249329 < >
250330 < div className = { matchCountStyle } > { matchLabel } </ div >
251331 { results . length > 0 ? (
252- < div className = { resultListStyle } >
253- { results . map ( ( { item, highlighted } ) => {
332+ < div
333+ ref = { listRef }
334+ className = { resultListStyle }
335+ role = "listbox"
336+ tabIndex = { 0 }
337+ onKeyDown = { handleListKeyDown }
338+ onFocus = { ( ) => {
339+ // When the list receives focus (e.g. from ArrowDown in input),
340+ // highlight and select the first item
341+ if ( focusedIndex === null && results . length > 0 ) {
342+ setFocusedIndex ( 0 ) ;
343+ const first = results [ 0 ] ;
344+ if ( first ) {
345+ selectItem ( first . item . selectionItem ) ;
346+ }
347+ }
348+ } }
349+ >
350+ { results . map ( ( { item, highlighted } , index ) => {
254351 const isSelected = checkIsSelected ( item . id ) ;
352+ const isFocused = focusedIndex === index ;
255353 return (
256354 < div
257355 key = { item . id }
258- className = { resultRowStyle ( { isSelected } ) }
259- onClick = { ( ) => selectItem ( item . selectionItem ) }
356+ ref = { ( el ) => {
357+ rowRefs . current [ index ] = el ;
358+ } }
359+ role = "option"
360+ tabIndex = { - 1 }
361+ aria-selected = { isSelected }
362+ className = { resultRowStyle ( { isSelected, isFocused } ) }
363+ onClick = { ( ) => {
364+ selectItem ( item . selectionItem ) ;
365+ setFocusedIndex ( index ) ;
366+ } }
367+ onKeyDown = { handleListKeyDown }
260368 >
261369 < div className = { resultContentStyle } >
262370 < span
@@ -299,6 +407,15 @@ const SearchTitle: React.FC = () => {
299407 type = "text"
300408 placeholder = "Find…"
301409 className = { searchInputStyle }
410+ onKeyDown = { ( event ) => {
411+ if ( event . key === "ArrowDown" ) {
412+ event . preventDefault ( ) ;
413+ // Find the result list within the same sub-view section and focus it
414+ const section = searchInputRef . current ?. closest ( "[data-panel]" ) ;
415+ const list = section ?. querySelector < HTMLElement > ( "[role=listbox]" ) ;
416+ list ?. focus ( ) ;
417+ }
418+ } }
302419 />
303420 ) ;
304421} ;
0 commit comments