Skip to content

Commit 46bae84

Browse files
feat(search): add keyboard navigation for search results
1 parent 7bb0837 commit 46bae84

File tree

12 files changed

+286
-27
lines changed

12 files changed

+286
-27
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function SearchView({ tab }: { tab: Extract<Tab, { type: "search" }> }) {
8787

8888
return (
8989
<AdvancedSearchView
90+
initialQuery={tab.state.initialQuery ?? undefined}
9091
selectedTypes={selectedTypes}
9192
setSelectedTypes={setSelectedTypes}
9293
onResultClick={handleResultClick}

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

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,68 @@ const FILTER_OPTIONS: { type: SearchEntityType; label: string }[] = [
1818
{ type: "organization", label: "Organization" },
1919
];
2020

21+
type DatePreset = "today" | "week" | "month";
22+
23+
const DATE_PRESETS: { key: DatePreset; label: string }[] = [
24+
{ key: "today", label: "Today" },
25+
{ key: "week", label: "This week" },
26+
{ key: "month", label: "This month" },
27+
];
28+
29+
function getDateRange(preset: DatePreset): { gte: number; lte: number } {
30+
const now = new Date();
31+
const endOfDay = new Date(
32+
now.getFullYear(),
33+
now.getMonth(),
34+
now.getDate(),
35+
23,
36+
59,
37+
59,
38+
999,
39+
);
40+
const lte = endOfDay.getTime();
41+
42+
switch (preset) {
43+
case "today": {
44+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
45+
return { gte: start.getTime(), lte };
46+
}
47+
case "week": {
48+
const start = new Date(
49+
now.getFullYear(),
50+
now.getMonth(),
51+
now.getDate() - now.getDay(),
52+
);
53+
return { gte: start.getTime(), lte };
54+
}
55+
case "month": {
56+
const start = new Date(now.getFullYear(), now.getMonth(), 1);
57+
return { gte: start.getTime(), lte };
58+
}
59+
}
60+
}
61+
2162
interface AdvancedSearchViewProps {
63+
initialQuery?: string;
2264
selectedTypes: string[] | null;
2365
setSelectedTypes: (types: string[] | null) => void;
2466
onResultClick: (type: string, id: string) => void;
2567
}
2668

2769
export function AdvancedSearchView({
70+
initialQuery,
2871
selectedTypes,
2972
setSelectedTypes,
3073
onResultClick,
3174
}: AdvancedSearchViewProps) {
3275
const { search, isIndexing } = useSearchEngine();
33-
const [localQuery, setLocalQuery] = useState("");
34-
const [query, setQuery] = useState("");
76+
const [localQuery, setLocalQuery] = useState(initialQuery ?? "");
77+
const [query, setQuery] = useState(initialQuery ?? "");
3578
const [results, setResults] = useState<GroupedSearchResults | null>(null);
3679
const [isSearching, setIsSearching] = useState(false);
80+
const [activeDatePreset, setActiveDatePreset] = useState<DatePreset | null>(
81+
null,
82+
);
3783
const inputRef = useRef<HTMLInputElement>(null);
3884

3985
useEffect(() => {
@@ -43,6 +89,12 @@ export function AdvancedSearchView({
4389
return () => clearTimeout(timer);
4490
}, [localQuery]);
4591

92+
const dateFilter = useMemo(
93+
() =>
94+
activeDatePreset ? { created_at: getDateRange(activeDatePreset) } : null,
95+
[activeDatePreset],
96+
);
97+
4698
useEffect(() => {
4799
if (query.trim().length < 1) {
48100
setResults(null);
@@ -53,7 +105,7 @@ export function AdvancedSearchView({
53105
let cancelled = false;
54106
setIsSearching(true);
55107

56-
search(query).then((hits) => {
108+
search(query, dateFilter).then((hits) => {
57109
if (!cancelled) {
58110
setResults(groupSearchResults(hits, query.trim()));
59111
setIsSearching(false);
@@ -63,7 +115,7 @@ export function AdvancedSearchView({
63115
return () => {
64116
cancelled = true;
65117
};
66-
}, [query, search]);
118+
}, [query, search, dateFilter]);
67119

68120
useEffect(() => {
69121
inputRef.current?.focus();
@@ -83,6 +135,10 @@ export function AdvancedSearchView({
83135
[selectedTypes, setSelectedTypes],
84136
);
85137

138+
const toggleDatePreset = useCallback((key: DatePreset) => {
139+
setActiveDatePreset((prev) => (prev === key ? null : key));
140+
}, []);
141+
86142
const filteredResults = useMemo(() => {
87143
if (!results || !selectedTypes || selectedTypes.length === 0) {
88144
return results;
@@ -138,7 +194,7 @@ export function AdvancedSearchView({
138194
</div>
139195

140196
<div className="pl-[14px] pr-3 py-2 border-b border-neutral-200">
141-
<div className="flex gap-2">
197+
<div className="flex gap-2 flex-wrap">
142198
{FILTER_OPTIONS.map((option) => {
143199
const isActive = selectedTypes?.includes(option.type);
144200
return (
@@ -157,6 +213,24 @@ export function AdvancedSearchView({
157213
</Badge>
158214
);
159215
})}
216+
{DATE_PRESETS.map((preset) => {
217+
const isActive = activeDatePreset === preset.key;
218+
return (
219+
<Badge
220+
key={preset.key}
221+
variant="outline"
222+
className={cn([
223+
"cursor-pointer transition-all",
224+
isActive
225+
? "bg-linear-to-t from-stone-600 to-stone-500 text-white border-stone-600 hover:from-stone-700 hover:to-stone-600"
226+
: "bg-linear-to-b from-white to-stone-50 text-neutral-600 border-neutral-200 hover:from-neutral-50 hover:to-stone-100",
227+
])}
228+
onClick={() => toggleDatePreset(preset.key)}
229+
>
230+
{preset.label}
231+
</Badge>
232+
);
233+
})}
160234
</div>
161235
</div>
162236

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

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Loader2Icon, SearchIcon, XIcon } from "lucide-react";
2-
import { useEffect, useState } from "react";
2+
import { useEffect, useMemo, useState } from "react";
33

44
import { Button } from "@hypr/ui/components/ui/button";
55
import { Kbd } from "@hypr/ui/components/ui/kbd";
66
import { cn } from "@hypr/utils";
77

88
import { useSearch } from "../../../contexts/search/ui";
99
import { useCmdKeyPressed } from "../../../hooks/useCmdKeyPressed";
10+
import { useTabs } from "../../../store/zustand/tabs";
1011

1112
export function Search({
1213
hasSpace,
@@ -95,10 +96,24 @@ function ExpandedSearch({
9596
onFocus?: () => void;
9697
onBlur?: () => void;
9798
}) {
98-
const { query, setQuery, isSearching, isIndexing, inputRef, results } =
99-
useSearch();
99+
const {
100+
query,
101+
setQuery,
102+
isSearching,
103+
isIndexing,
104+
inputRef,
105+
results,
106+
selectedIndex,
107+
setSelectedIndex,
108+
} = useSearch();
100109
const [isFocused, setIsFocused] = useState(false);
101110
const isCmdPressed = useCmdKeyPressed();
111+
const openNew = useTabs((state) => state.openNew);
112+
113+
const flatResults = useMemo(() => {
114+
if (!results) return [];
115+
return results.groups.flatMap((g) => g.results);
116+
}, [results]);
102117

103118
const showLoading = isSearching || isIndexing;
104119
const showShortcut = isCmdPressed && !query;
@@ -141,6 +156,59 @@ function ExpandedSearch({
141156
if (e.key === "Escape") {
142157
e.currentTarget.blur();
143158
}
159+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && query.trim()) {
160+
e.preventDefault();
161+
openNew({
162+
type: "search",
163+
state: {
164+
selectedTypes: null,
165+
initialQuery: query.trim(),
166+
},
167+
});
168+
setQuery("");
169+
e.currentTarget.blur();
170+
}
171+
if (e.key === "ArrowDown" && flatResults.length > 0) {
172+
e.preventDefault();
173+
setSelectedIndex(
174+
Math.min(selectedIndex + 1, flatResults.length - 1),
175+
);
176+
}
177+
if (e.key === "ArrowUp" && flatResults.length > 0) {
178+
e.preventDefault();
179+
setSelectedIndex(Math.max(selectedIndex - 1, -1));
180+
}
181+
if (
182+
e.key === "Enter" &&
183+
!e.metaKey &&
184+
!e.ctrlKey &&
185+
selectedIndex >= 0 &&
186+
selectedIndex < flatResults.length
187+
) {
188+
e.preventDefault();
189+
const item = flatResults[selectedIndex];
190+
if (item.type === "session") {
191+
openNew({ type: "sessions", id: item.id });
192+
} else if (item.type === "human") {
193+
openNew({
194+
type: "contacts",
195+
state: {
196+
selectedPerson: item.id,
197+
selectedOrganization: null,
198+
},
199+
});
200+
} else if (item.type === "organization") {
201+
openNew({
202+
type: "contacts",
203+
state: {
204+
selectedOrganization: item.id,
205+
selectedPerson: null,
206+
},
207+
});
208+
}
209+
setQuery("");
210+
e.currentTarget.blur();
211+
}
144212
}}
145213
onFocus={() => {
146214
setIsFocused(true);

apps/desktop/src/components/main/sidebar/search/group.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import { SearchResultItem } from "./item";
99
const ITEMS_PER_PAGE = 3;
1010
const LOAD_MORE_STEP = 5;
1111

12-
export function SearchResultGroup({ group }: { group: SearchGroup }) {
12+
export function SearchResultGroup({
13+
group,
14+
selectedId,
15+
}: {
16+
group: SearchGroup;
17+
selectedId: string | null;
18+
}) {
1319
const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE);
1420

1521
if (group.totalCount === 0) {
@@ -30,7 +36,11 @@ export function SearchResultGroup({ group }: { group: SearchGroup }) {
3036
</div>
3137
<div>
3238
{visibleResults.map((result) => (
33-
<SearchResultItem key={result.id} result={result} />
39+
<SearchResultItem
40+
key={result.id}
41+
result={result}
42+
isSelected={result.id === selectedId}
43+
/>
3444
))}
3545
</div>
3646
{hasMore && (

apps/desktop/src/components/main/sidebar/search/index.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { SearchXIcon } from "lucide-react";
2+
import { useEffect, useMemo, useRef } from "react";
3+
import { forwardRef } from "react";
4+
5+
import { cn } from "@hypr/utils";
26

37
import {
48
type GroupedSearchResults,
@@ -7,30 +11,62 @@ import {
711
import { SearchResultGroup } from "./group";
812

913
export function SearchResults() {
10-
const { results, query, setQuery } = useSearch();
14+
const { results, query, setQuery, selectedIndex } = useSearch();
15+
const containerRef = useRef<HTMLDivElement>(null);
1116

1217
const empty = !query || !results || results.totalResults === 0;
1318

19+
const flatResults = useMemo(() => {
20+
if (!results) return [];
21+
return results.groups.flatMap((g) => g.results);
22+
}, [results]);
23+
24+
const selectedId =
25+
selectedIndex >= 0 && selectedIndex < flatResults.length
26+
? flatResults[selectedIndex].id
27+
: null;
28+
29+
useEffect(() => {
30+
if (!selectedId || !containerRef.current) return;
31+
const el = containerRef.current.querySelector(
32+
`[data-result-id="${selectedId}"]`,
33+
);
34+
if (el) {
35+
el.scrollIntoView({ block: "nearest" });
36+
}
37+
}, [selectedId]);
38+
1439
return (
15-
<div className="h-full rounded-xl bg-neutral-50">
40+
<div className={cn(["h-full rounded-xl bg-neutral-50"])}>
1641
{empty ? (
1742
<SearchNoResults query={query} setQuery={setQuery} />
1843
) : (
19-
<SearchYesResults results={results} />
44+
<SearchYesResults
45+
ref={containerRef}
46+
results={results}
47+
selectedId={selectedId}
48+
/>
2049
)}
2150
</div>
2251
);
2352
}
2453

25-
function SearchYesResults({ results }: { results: GroupedSearchResults }) {
54+
const SearchYesResults = forwardRef<
55+
HTMLDivElement,
56+
{ results: GroupedSearchResults; selectedId: string | null }
57+
>(({ results, selectedId }, ref) => {
2658
return (
27-
<div className="h-full overflow-y-auto scrollbar-hide">
59+
<div ref={ref} className="h-full overflow-y-auto scrollbar-hide">
2860
{results.groups.map((group) => (
29-
<SearchResultGroup key={group.key} group={group} />
61+
<SearchResultGroup
62+
key={group.key}
63+
group={group}
64+
selectedId={selectedId}
65+
/>
3066
))}
3167
</div>
3268
);
33-
}
69+
});
3470

3571
function SearchNoResults({
3672
query,

0 commit comments

Comments
 (0)