Skip to content

Commit 31e622c

Browse files
committed
search feature logic rework to handle search across all languages, categories and snippets
1 parent 2196f7c commit 31e622c

File tree

11 files changed

+348
-110
lines changed

11 files changed

+348
-110
lines changed

src/components/CategoryList.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@ const CategoryListItem: FC<CategoryListItemProps> = ({ name }) => {
1313
const navigate = useNavigate();
1414
const [searchParams] = useSearchParams();
1515

16-
const { language, category, setCategory } = useAppContext();
16+
const { language, category } = useAppContext();
1717

1818
const handleSelect = () => {
19-
setCategory(name);
2019
navigate({
2120
pathname: `/${slugify(language.name)}/${slugify(name)}`,
2221
search: searchParams.toString(),

src/components/Icons.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ const ACCENT_ICON_COLOR = "var(--clr-accent)";
55

66
interface IconProps {
77
fillColor?: string;
8+
width?: string;
9+
height?: string;
810
}
911

1012
export const LogoIcon: FC<IconProps> = ({ fillColor = ACCENT_ICON_COLOR }) => (
@@ -123,10 +125,12 @@ export const ExpandIcon: FC<IconProps> = ({
123125

124126
export const CloseIcon: FC<IconProps> = ({
125127
fillColor = DEFAULT_ICON_COLOR,
128+
width = "31",
129+
height = "30",
126130
}) => (
127131
<svg
128-
width="31"
129-
height="30"
132+
width={width}
133+
height={height}
130134
viewBox="0 0 31 30"
131135
fill="none"
132136
xmlns="http://www.w3.org/2000/svg"

src/components/LanguageSelector.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { slugify } from "@utils/slugify";
1515
const LanguageSelector = () => {
1616
const navigate = useNavigate();
1717

18-
const { language, setLanguage, setCategory, setSearchText } = useAppContext();
18+
const { language, setSearchText } = useAppContext();
1919
const { fetchedLanguages, loading, error } = useLanguages();
2020

2121
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -33,8 +33,6 @@ const LanguageSelector = () => {
3333
});
3434

3535
setSearchText("");
36-
setLanguage(newLanguage);
37-
setCategory(newCategory);
3836
navigate(`/${slugify(newLanguage.name)}/${slugify(newCategory)}`);
3937
setIsOpen(false);
4038
};

src/components/SearchInput.tsx

Lines changed: 181 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,76 @@
1-
import { useCallback, useEffect, useRef } from "react";
2-
import { useSearchParams } from "react-router-dom";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import { useNavigate, useSearchParams } from "react-router-dom";
33

44
import { useAppContext } from "@contexts/AppContext";
5+
import { useFetch } from "@hooks/useFetch";
6+
import { AllSnippetsType, SearchItemType } from "@types";
57
import { QueryParams } from "@utils/enums";
8+
import { slugify } from "@utils/slugify";
69

7-
import { SearchIcon } from "./Icons";
10+
import Button from "./Button";
11+
import { CloseIcon, SearchIcon } from "./Icons";
812

913
const SearchInput = () => {
14+
const navigate = useNavigate();
1015
const [searchParams, setSearchParams] = useSearchParams();
1116

1217
const { searchText, setSearchText } = useAppContext();
18+
const { data } = useFetch<AllSnippetsType[]>(`/consolidated/all.json`);
19+
20+
const filteredData: SearchItemType[] = useMemo(() => {
21+
if (!data) {
22+
return [];
23+
}
24+
25+
const searchTerm = searchText.toLowerCase();
26+
27+
return data
28+
.map((language) => {
29+
const filteredCategories = language.categories
30+
.map((category) => {
31+
const filteredSnippets = category.snippets.filter(
32+
(snippet) =>
33+
snippet.title.toLowerCase().includes(searchTerm) ||
34+
snippet.description.toLowerCase().includes(searchTerm) ||
35+
snippet.tags.some((tag) =>
36+
tag.toLowerCase().includes(searchTerm)
37+
)
38+
);
39+
40+
if (filteredSnippets.length > 0) {
41+
return {
42+
categoryName: category.name,
43+
snippets: filteredSnippets,
44+
};
45+
}
46+
47+
return null;
48+
})
49+
.filter(Boolean); // Remove null categories
50+
51+
if (filteredCategories.length > 0) {
52+
return filteredCategories.map((filteredCategory) => ({
53+
languageName: language.languageName,
54+
languageIcon: language.languageIcon,
55+
categoryName: filteredCategory!.categoryName,
56+
snippets: filteredCategory!.snippets,
57+
}));
58+
}
59+
60+
return [];
61+
})
62+
.flat();
63+
}, [data, searchText]);
1364

1465
const inputRef = useRef<HTMLInputElement | null>(null);
1566

67+
const [searchOpen, setSearchOpen] = useState<boolean>(false);
68+
1669
const handleSearchFieldClick = () => {
70+
setSearchOpen(true);
71+
};
72+
73+
const handleInnerSearchFieldClick = () => {
1774
inputRef.current?.focus();
1875
};
1976

@@ -23,31 +80,13 @@ const SearchInput = () => {
2380
setSearchParams(searchParams);
2481
}, [searchParams, setSearchParams, setSearchText]);
2582

26-
const performSearch = useCallback(() => {
27-
// Check if the input element is focused.
28-
if (document.activeElement !== inputRef.current) {
29-
return;
30-
}
31-
32-
const formattedVal = searchText.toLowerCase();
33-
34-
setSearchText(formattedVal);
35-
if (!formattedVal) {
36-
searchParams.delete(QueryParams.SEARCH);
37-
setSearchParams(searchParams);
38-
} else {
39-
searchParams.set(QueryParams.SEARCH, formattedVal);
40-
setSearchParams(searchParams);
41-
}
42-
}, [searchParams, searchText, setSearchParams, setSearchText]);
43-
4483
/**
4584
* Focus the search input when the user presses the `/` key.
4685
*/
4786
const handleSearchKeyPress = (e: KeyboardEvent) => {
4887
if (e.key === "/") {
4988
e.preventDefault();
50-
inputRef.current?.focus();
89+
setSearchOpen(true);
5190
}
5291
};
5392

@@ -60,18 +99,30 @@ const SearchInput = () => {
6099
return;
61100
}
62101

63-
// Check if the input element is focused.
64-
if (document.activeElement !== inputRef.current) {
65-
return;
66-
}
67-
68-
inputRef.current?.blur();
69-
102+
setSearchOpen(false);
70103
clearSearch();
71104
},
72105
[clearSearch]
73106
);
74107

108+
const handleSearchItemClick =
109+
({
110+
languageName,
111+
categoryName,
112+
snippetName,
113+
}: {
114+
languageName: string;
115+
categoryName: string;
116+
snippetName: string;
117+
}) =>
118+
() => {
119+
navigate(
120+
`/${slugify(languageName)}/${slugify(categoryName)}?${QueryParams.SEARCH}=${searchText.toLowerCase()}&${QueryParams.SNIPPET}=${slugify(snippetName)}`,
121+
{ replace: true }
122+
);
123+
setSearchOpen(false);
124+
};
125+
75126
useEffect(() => {
76127
window.addEventListener("keydown", handleSearchKeyPress);
77128
window.addEventListener("keyup", handleEscapeKeyPress);
@@ -82,13 +133,6 @@ const SearchInput = () => {
82133
};
83134
}, [handleEscapeKeyPress]);
84135

85-
/**
86-
* Update the search query in the URL when the search text changes.
87-
*/
88-
useEffect(() => {
89-
performSearch();
90-
}, [searchText, performSearch]);
91-
92136
/**
93137
* Set the search text to the search query from the URL on mount.
94138
*/
@@ -102,30 +146,108 @@ const SearchInput = () => {
102146
// eslint-disable-next-line react-hooks/exhaustive-deps
103147
}, []);
104148

149+
useEffect(() => {
150+
if (searchOpen) {
151+
inputRef.current?.focus();
152+
}
153+
}, [searchOpen]);
154+
105155
return (
106-
<div className="search-field" onClick={handleSearchFieldClick}>
107-
<SearchIcon />
108-
<input
109-
ref={inputRef}
110-
value={searchText}
111-
type="search"
112-
id="search"
113-
autoComplete="off"
114-
onChange={(e) => {
115-
const newValue = e.target.value;
116-
if (!newValue) {
117-
clearSearch();
118-
return;
119-
}
120-
setSearchText(newValue);
121-
}}
122-
/>
123-
{!searchText && (
124-
<label htmlFor="search">
125-
Type <kbd>/</kbd> to search
126-
</label>
127-
)}
128-
</div>
156+
<>
157+
<div className="search-field" onClick={handleSearchFieldClick}>
158+
<SearchIcon />
159+
<input
160+
disabled
161+
id="search"
162+
type="text"
163+
value={searchText}
164+
onChange={() => {}}
165+
/>
166+
{!searchText && (
167+
<label htmlFor="search">
168+
Type <kbd>/</kbd> to search
169+
</label>
170+
)}
171+
{searchText && (
172+
<Button
173+
isIcon={true}
174+
className="search-field__clear"
175+
onClick={(e: React.MouseEvent) => {
176+
e.stopPropagation();
177+
clearSearch();
178+
}}
179+
>
180+
<CloseIcon width="20" height="20" />
181+
</Button>
182+
)}
183+
</div>
184+
185+
<div
186+
className={`search-field__results search-field__results${searchOpen ? "--open" : "--closed"}`}
187+
>
188+
<div
189+
className="search-field search-field--inner"
190+
onClick={handleInnerSearchFieldClick}
191+
>
192+
<SearchIcon />
193+
<input
194+
ref={inputRef}
195+
value={searchText}
196+
type="text"
197+
autoComplete="off"
198+
onChange={(e) => {
199+
const newValue = e.target.value;
200+
if (!newValue) {
201+
clearSearch();
202+
return;
203+
}
204+
setSearchText(newValue);
205+
}}
206+
/>
207+
<Button
208+
isIcon={true}
209+
onClick={() => {
210+
setSearchOpen(false);
211+
clearSearch();
212+
}}
213+
>
214+
<CloseIcon />
215+
</Button>
216+
</div>
217+
218+
<div className="search-field__results__list">
219+
{filteredData.map(
220+
(
221+
{ languageName, languageIcon, categoryName, snippets },
222+
languageIndex
223+
) => (
224+
<div key={`${languageName}-${languageIndex}`}>
225+
<ul>
226+
{snippets.map((snippet, snippetIndex) => (
227+
<li
228+
key={`${languageName}-${categoryName}-${snippetIndex}`}
229+
onClick={handleSearchItemClick({
230+
languageName,
231+
categoryName,
232+
snippetName: snippet.title,
233+
})}
234+
>
235+
<img src={languageIcon} alt={languageName} />
236+
<div>
237+
<h4>
238+
{snippet.title} ({languageName})
239+
</h4>
240+
<p>{snippet.description}</p>
241+
</div>
242+
</li>
243+
))}
244+
</ul>
245+
</div>
246+
)
247+
)}
248+
</div>
249+
</div>
250+
</>
129251
);
130252
};
131253

src/components/SnippetList.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ const SnippetList = () => {
3434
setSearchParams(searchParams);
3535
};
3636

37+
const handleSearchKeyPress = (e: KeyboardEvent) => {
38+
if (e.key === "/") {
39+
e.preventDefault();
40+
setIsModalOpen(false);
41+
}
42+
};
43+
3744
/**
3845
* open the relevant modal if the snippet is in the search params
3946
*/
@@ -52,6 +59,14 @@ const SnippetList = () => {
5259
// eslint-disable-next-line react-hooks/exhaustive-deps
5360
}, [fetchedSnippets, searchParams]);
5461

62+
useEffect(() => {
63+
window.addEventListener("keydown", handleSearchKeyPress);
64+
65+
return () => {
66+
window.removeEventListener("keydown", handleSearchKeyPress);
67+
};
68+
}, []);
69+
5570
if (!fetchedSnippets) {
5671
return (
5772
<div>

src/contexts/AppContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const AppProvider: FC<{ children: React.ReactNode }> = ({
3535
useEffect(() => {
3636
configure();
3737
// eslint-disable-next-line react-hooks/exhaustive-deps
38-
}, [fetchedLanguages]);
38+
}, [fetchedLanguages, languageName, categoryName]);
3939

4040
/**
4141
* Set the default language if the language is not found in the URL.

0 commit comments

Comments
 (0)