Skip to content

Commit ae532f6

Browse files
kubeclaude
andcommitted
Add keyboard navigation to search results and fix fuzzysort highlight call
ArrowDown from the search input moves focus to the first result. ArrowUp/Down navigates through results, selecting each item. ArrowUp on the first result returns focus to the input. Also fixes the fuzzysort highlight call to use the instance method (result.highlight) instead of the non-existent static method. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1efa164 commit ae532f6

1 file changed

Lines changed: 129 additions & 12 deletions

File tree

  • libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews

libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { css, cva } from "@hashintel/ds-helpers/css";
22
import fuzzysort from "fuzzysort";
33
import type { ComponentType, ReactNode } from "react";
4-
import { use, useEffect, useMemo, useState } from "react";
4+
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import { LuSearch } from "react-icons/lu";
66

77
import { 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

5253
const 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

Comments
 (0)