Skip to content

Commit db72542

Browse files
feat(search): improve search result navigation and keyboard interaction
1 parent 46bae84 commit db72542

File tree

5 files changed

+94
-14
lines changed

5 files changed

+94
-14
lines changed

apps/desktop/src/components/main/body/advanced-search/index.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { SearchIcon } from "lucide-react";
22
import { useCallback } from "react";
3-
import { useShallow } from "zustand/shallow";
43

54
import { type Tab, useTabs } from "../../../../store/zustand/tabs";
65
import { StandardTabWrapper } from "../index";
@@ -48,11 +47,7 @@ export function TabContentSearch({
4847

4948
function SearchView({ tab }: { tab: Extract<Tab, { type: "search" }> }) {
5049
const updateSearchTabState = useTabs((state) => state.updateSearchTabState);
51-
const { openCurrent } = useTabs(
52-
useShallow((state) => ({
53-
openCurrent: state.openCurrent,
54-
})),
55-
);
50+
const openNew = useTabs((state) => state.openNew);
5651

5752
const { selectedTypes } = tab.state;
5853

@@ -69,20 +64,20 @@ function SearchView({ tab }: { tab: Extract<Tab, { type: "search" }> }) {
6964
const handleResultClick = useCallback(
7065
(type: string, id: string) => {
7166
if (type === "session") {
72-
openCurrent({ type: "sessions", id });
67+
openNew({ type: "sessions", id });
7368
} else if (type === "human") {
74-
openCurrent({
69+
openNew({
7570
type: "contacts",
7671
state: { selectedPerson: id, selectedOrganization: null },
7772
});
7873
} else if (type === "organization") {
79-
openCurrent({
74+
openNew({
8075
type: "contacts",
8176
state: { selectedOrganization: id, selectedPerson: null },
8277
});
8378
}
8479
},
85-
[openCurrent],
80+
[openNew],
8681
);
8782

8883
return (

apps/desktop/src/components/main/body/advanced-search/result-item.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,21 @@ const TYPE_ICONS = {
1313
interface ResultItemProps {
1414
result: SearchResult;
1515
onClick: () => void;
16+
isSelected?: boolean;
1617
}
1718

18-
export function ResultItem({ result, onClick }: ResultItemProps) {
19+
export function ResultItem({ result, onClick, isSelected }: ResultItemProps) {
1920
const Icon = TYPE_ICONS[result.type] || FileTextIcon;
2021

2122
return (
2223
<button
24+
data-result-id={result.id}
2325
onClick={onClick}
2426
className={cn([
2527
"w-full flex items-start gap-3 p-3",
2628
"rounded-lg text-left",
2729
"hover:bg-neutral-100 transition-colors",
30+
isSelected && "bg-neutral-100",
2831
])}
2932
>
3033
<div className="mt-0.5 shrink-0">

apps/desktop/src/components/main/body/advanced-search/view.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,34 @@ export function AdvancedSearchView({
154154
};
155155
}, [results, selectedTypes]);
156156

157+
const [selectedIndex, setSelectedIndex] = useState(-1);
158+
159+
const flatResults = useMemo(() => {
160+
if (!filteredResults) return [];
161+
return filteredResults.groups.flatMap((g) => g.results);
162+
}, [filteredResults]);
163+
164+
useEffect(() => {
165+
setSelectedIndex(-1);
166+
}, [filteredResults]);
167+
168+
const selectedId =
169+
selectedIndex >= 0 && selectedIndex < flatResults.length
170+
? flatResults[selectedIndex].id
171+
: null;
172+
173+
const resultsRef = useRef<HTMLDivElement>(null);
174+
175+
useEffect(() => {
176+
if (!selectedId || !resultsRef.current) return;
177+
const el = resultsRef.current.querySelector(
178+
`[data-result-id="${selectedId}"]`,
179+
);
180+
if (el) {
181+
el.scrollIntoView({ block: "nearest" });
182+
}
183+
}, [selectedId]);
184+
157185
const showLoading = isSearching || isIndexing;
158186
const hasQuery = query.trim().length > 0;
159187
const hasResults = filteredResults && filteredResults.totalResults > 0;
@@ -173,6 +201,34 @@ export function AdvancedSearchView({
173201
placeholder="Try 'budget', '@john', or '#design'"
174202
value={localQuery}
175203
onChange={(e) => setLocalQuery(e.target.value)}
204+
onKeyDown={(e) => {
205+
if (e.key === "Escape") {
206+
if (localQuery.trim()) {
207+
setLocalQuery("");
208+
} else {
209+
e.currentTarget.blur();
210+
}
211+
}
212+
if (e.key === "ArrowDown" && flatResults.length > 0) {
213+
e.preventDefault();
214+
setSelectedIndex(
215+
Math.min(selectedIndex + 1, flatResults.length - 1),
216+
);
217+
}
218+
if (e.key === "ArrowUp" && flatResults.length > 0) {
219+
e.preventDefault();
220+
setSelectedIndex(Math.max(selectedIndex - 1, -1));
221+
}
222+
if (
223+
e.key === "Enter" &&
224+
selectedIndex >= 0 &&
225+
selectedIndex < flatResults.length
226+
) {
227+
e.preventDefault();
228+
const item = flatResults[selectedIndex];
229+
onResultClick(item.type, item.id);
230+
}
231+
}}
176232
className={cn([
177233
"w-full pl-[38px] pr-8 py-2",
178234
"text-base placeholder:text-neutral-400",
@@ -234,16 +290,18 @@ export function AdvancedSearchView({
234290
</div>
235291
</div>
236292

237-
<div className="flex-1 overflow-y-auto">
293+
<div ref={resultsRef} className="flex-1 overflow-y-auto">
238294
{!hasQuery ? (
239295
<SuggestionsView
240296
results={filteredResults}
241297
onResultClick={onResultClick}
298+
selectedId={selectedId}
242299
/>
243300
) : hasResults ? (
244301
<SearchResultsView
245302
results={filteredResults!}
246303
onResultClick={onResultClick}
304+
selectedId={selectedId}
247305
/>
248306
) : (
249307
<NoResultsView query={query} />
@@ -256,9 +314,11 @@ export function AdvancedSearchView({
256314
function SuggestionsView({
257315
results,
258316
onResultClick,
317+
selectedId,
259318
}: {
260319
results: GroupedSearchResults | null;
261320
onResultClick: (type: string, id: string) => void;
321+
selectedId: string | null;
262322
}) {
263323
return (
264324
<div className="pl-[14px] pr-3 pt-3">
@@ -274,6 +334,7 @@ function SuggestionsView({
274334
key={result.id}
275335
result={result}
276336
onClick={() => onResultClick(result.type, result.id)}
337+
isSelected={result.id === selectedId}
277338
/>
278339
)),
279340
)}
@@ -291,9 +352,11 @@ function SuggestionsView({
291352
function SearchResultsView({
292353
results,
293354
onResultClick,
355+
selectedId,
294356
}: {
295357
results: GroupedSearchResults;
296358
onResultClick: (type: string, id: string) => void;
359+
selectedId: string | null;
297360
}) {
298361
return (
299362
<div className="pl-[14px] pr-3 pt-3">
@@ -308,6 +371,7 @@ function SearchResultsView({
308371
key={result.id}
309372
result={result}
310373
onClick={() => onResultClick(result.type, result.id)}
374+
isSelected={result.id === selectedId}
311375
/>
312376
))}
313377
</div>

apps/desktop/src/components/main/body/search.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@ function ExpandedSearch({
154154
onChange={(e) => setQuery(e.target.value)}
155155
onKeyDown={(e) => {
156156
if (e.key === "Escape") {
157-
e.currentTarget.blur();
157+
if (query.trim()) {
158+
setQuery("");
159+
setSelectedIndex(-1);
160+
} else {
161+
e.currentTarget.blur();
162+
}
158163
}
159164
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && query.trim()) {
160165
e.preventDefault();
@@ -206,7 +211,6 @@ function ExpandedSearch({
206211
},
207212
});
208213
}
209-
setQuery("");
210214
e.currentTarget.blur();
211215
}
212216
}}

apps/desktop/src/contexts/search/ui.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,20 @@ export function SearchUIProvider({ children }: { children: React.ReactNode }) {
195195
enableOnContentEditable: true,
196196
});
197197

198+
useHotkeys(
199+
"Escape",
200+
() => {
201+
if (query.trim()) {
202+
setQuery("");
203+
setSelectedIndex(-1);
204+
}
205+
},
206+
{
207+
enableOnFormTags: true,
208+
enableOnContentEditable: true,
209+
},
210+
);
211+
198212
const resetSearchState = useCallback(() => {
199213
setSearchHits([]);
200214
setSearchQuery("");

0 commit comments

Comments
 (0)