Skip to content

Commit 94cf03c

Browse files
authored
Merge pull request #159 from barrymun/feature/search
Feature - Implement basic search functionality v2
2 parents df7dcbf + c0b3bff commit 94cf03c

23 files changed

+1079
-321
lines changed

package-lock.json

+125-101
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"prismjs": "^1.29.0",
2424
"react": "^18.3.1",
2525
"react-dom": "^18.3.1",
26-
"react-router-dom": "^6.27.0",
26+
"react-router-dom": "^7.1.1",
2727
"react-syntax-highlighter": "^15.6.1"
2828
},
2929
"devDependencies": {

src/AppRouter.tsx

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Route, Routes } from "react-router-dom";
2+
3+
import App from "@components/App";
4+
import SnippetList from "@components/SnippetList";
5+
6+
const AppRouter = () => {
7+
return (
8+
<Routes>
9+
<Route element={<App />}>
10+
<Route path="/" element={<SnippetList />} />
11+
<Route path="/:languageName" element={<SnippetList />} />
12+
<Route
13+
path="/:languageName/:subLanguageName/:categoryName"
14+
element={<SnippetList />}
15+
/>
16+
</Route>
17+
</Routes>
18+
);
19+
};
20+
21+
export default AppRouter;

src/components/App.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { FC } from "react";
2+
3+
import { AppProvider } from "@contexts/AppContext";
4+
5+
import Container from "./Container";
6+
7+
interface AppProps {}
8+
9+
const App: FC<AppProps> = () => {
10+
return (
11+
<AppProvider>
12+
<Container />
13+
</AppProvider>
14+
);
15+
};
16+
17+
export default App;

src/components/CategoryList.tsx

+43-18
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,58 @@
1-
import { useEffect } from "react";
1+
import { FC } from "react";
2+
import { useNavigate, useSearchParams } from "react-router-dom";
23

34
import { useAppContext } from "@contexts/AppContext";
45
import { useCategories } from "@hooks/useCategories";
6+
import { defaultCategoryName } from "@utils/consts";
7+
import { slugify } from "@utils/slugify";
8+
9+
interface CategoryListItemProps {
10+
name: string;
11+
}
12+
13+
const CategoryListItem: FC<CategoryListItemProps> = ({ name }) => {
14+
const navigate = useNavigate();
15+
const [searchParams] = useSearchParams();
16+
17+
const { language, subLanguage, category } = useAppContext();
18+
19+
const handleSelect = () => {
20+
navigate({
21+
pathname: `/${slugify(language.name)}/${slugify(subLanguage)}/${slugify(name)}`,
22+
search: searchParams.toString(),
23+
});
24+
};
25+
26+
return (
27+
<li className="category">
28+
<button
29+
className={`category__btn ${
30+
slugify(name) === slugify(category) ? "category__btn--active" : ""
31+
}`}
32+
onClick={handleSelect}
33+
>
34+
{name}
35+
</button>
36+
</li>
37+
);
38+
};
539

640
const CategoryList = () => {
7-
const { category, setCategory } = useAppContext();
841
const { fetchedCategories, loading, error } = useCategories();
942

10-
useEffect(() => {
11-
setCategory(fetchedCategories[0]);
12-
}, [setCategory, fetchedCategories]);
13-
14-
if (loading) return <div>Loading...</div>;
43+
if (loading) {
44+
return <div>Loading...</div>;
45+
}
1546

16-
if (error) return <div>Error occurred: {error}</div>;
47+
if (error) {
48+
return <div>Error occurred: {error}</div>;
49+
}
1750

1851
return (
1952
<ul role="list" className="categories">
53+
<CategoryListItem name={defaultCategoryName} />
2054
{fetchedCategories.map((name, idx) => (
21-
<li key={idx} className="category">
22-
<button
23-
className={`category__btn ${
24-
name === category ? "category__btn--active" : ""
25-
}`}
26-
onClick={() => setCategory(name)}
27-
>
28-
{name}
29-
</button>
30-
</li>
55+
<CategoryListItem key={idx} name={name} />
3156
))}
3257
</ul>
3358
);

src/App.tsx renamed to src/components/Container.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import SnippetList from "@components/SnippetList";
1+
import { FC } from "react";
2+
import { Outlet } from "react-router-dom";
3+
24
import { useAppContext } from "@contexts/AppContext";
35
import Banner from "@layouts/Banner";
46
import Footer from "@layouts/Footer";
57
import Header from "@layouts/Header";
68
import Sidebar from "@layouts/Sidebar";
79

8-
const App = () => {
10+
interface ContainerProps {}
11+
12+
const Container: FC<ContainerProps> = () => {
913
const { category } = useAppContext();
1014

1115
return (
@@ -18,12 +22,12 @@ const App = () => {
1822
<h2 className="section-title">
1923
{category ? category : "Select a category"}
2024
</h2>
21-
<SnippetList />
25+
<Outlet />
2226
</section>
2327
</main>
2428
<Footer />
2529
</div>
2630
);
2731
};
2832

29-
export default App;
33+
export default Container;

src/components/LanguageSelector.tsx

+116-57
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,127 @@
1+
/**
2+
* Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
3+
*/
4+
15
import { useRef, useEffect, useState, useMemo } from "react";
6+
import { useNavigate } from "react-router-dom";
27

38
import { useAppContext } from "@contexts/AppContext";
49
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
510
import { useLanguages } from "@hooks/useLanguages";
611
import { LanguageType } from "@types";
12+
import { configureUserSelection } from "@utils/configureUserSelection";
13+
import {
14+
getLanguageDisplayLogo,
15+
getLanguageDisplayName,
16+
} from "@utils/languageUtils";
17+
import { slugify } from "@utils/slugify";
718

819
import SubLanguageSelector from "./SubLanguageSelector";
920

10-
// Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
11-
1221
const LanguageSelector = () => {
13-
const { language, setLanguage } = useAppContext();
22+
const navigate = useNavigate();
23+
24+
const { language, subLanguage, setSearchText } = useAppContext();
1425
const { fetchedLanguages, loading, error } = useLanguages();
15-
const allLanguages = useMemo(
16-
() =>
17-
fetchedLanguages.flatMap((lang) =>
18-
lang.subLanguages.length > 0
19-
? [
20-
lang,
21-
...lang.subLanguages.map((subLang) => ({
22-
...subLang,
23-
mainLanguage: lang,
24-
subLanguages: [],
25-
})),
26-
]
27-
: [lang]
28-
),
29-
[fetchedLanguages]
30-
);
3126

3227
const dropdownRef = useRef<HTMLDivElement>(null);
33-
const [isOpen, setIsOpen] = useState(false);
28+
const [isOpen, setIsOpen] = useState<boolean>(false);
3429
const [openedLanguages, setOpenedLanguages] = useState<LanguageType[]>([]);
3530

36-
const handleSelect = (selected: LanguageType) => {
37-
setLanguage(selected);
31+
const keyboardItems = useMemo(() => {
32+
return fetchedLanguages.flatMap((lang) =>
33+
openedLanguages.map((ol) => ol.name).includes(lang.name)
34+
? [
35+
{ languageName: lang.name },
36+
...lang.subLanguages.map((sl) => ({
37+
languageName: lang.name,
38+
subLanguageName: sl.name,
39+
})),
40+
]
41+
: [{ languageName: lang.name }]
42+
);
43+
}, [fetchedLanguages, openedLanguages]);
44+
45+
const displayName = useMemo(
46+
() => getLanguageDisplayName(language.name, subLanguage),
47+
[language.name, subLanguage]
48+
);
49+
50+
const displayLogo = useMemo(
51+
() => getLanguageDisplayLogo(language.name, subLanguage),
52+
[language.name, subLanguage]
53+
);
54+
55+
const handleToggleSubLanguage = (name: LanguageType["name"]) => {
56+
const isAlreadyOpened = openedLanguages.some((lang) => lang.name === name);
57+
const openedLang = fetchedLanguages.find((lang) => lang.name === name);
58+
if (openedLang === undefined || openedLang.subLanguages.length === 0) {
59+
return;
60+
}
61+
62+
if (!isAlreadyOpened) {
63+
setOpenedLanguages((prev) => [...prev, openedLang]);
64+
} else {
65+
setOpenedLanguages((prev) =>
66+
prev.filter((lang) => lang.name !== openedLang.name)
67+
);
68+
}
69+
};
70+
71+
/**
72+
* When setting a new language we need to ensure that a category
73+
* has been set given this new language.
74+
* Ensure that the search text is cleared.
75+
*/
76+
const handleSelect = async (selected: LanguageType) => {
77+
const {
78+
language: newLanguage,
79+
subLanguage: newSubLanguage,
80+
category: newCategory,
81+
} = await configureUserSelection({
82+
languageName: selected.name,
83+
});
84+
85+
setSearchText("");
86+
navigate(
87+
`/${slugify(newLanguage.name)}/${slugify(newSubLanguage)}/${slugify(newCategory)}`
88+
);
3889
setIsOpen(false);
3990
setOpenedLanguages([]);
4091
};
4192

93+
const afterSelect = () => {
94+
setIsOpen(false);
95+
};
96+
97+
const handleSubLanguageSelect = async (
98+
selectedLanguageName: LanguageType["name"],
99+
selectedSubLanguageName:
100+
| LanguageType["subLanguages"][number]["name"]
101+
| undefined
102+
) => {
103+
const {
104+
language: newLanguage,
105+
subLanguage: newSubLanguage,
106+
category: newCategory,
107+
} = await configureUserSelection({
108+
languageName: selectedLanguageName,
109+
subLanguageName: selectedSubLanguageName,
110+
});
111+
112+
setSearchText("");
113+
navigate(
114+
`/${slugify(newLanguage.name)}/${slugify(newSubLanguage)}/${slugify(newCategory)}`
115+
);
116+
afterSelect();
117+
};
118+
42119
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
43120
useKeyboardNavigation({
44-
items: allLanguages,
121+
items: keyboardItems,
45122
isOpen,
46-
openedLanguages,
47-
toggleDropdown: (openedLang) => handleToggleSublanguage(openedLang),
48-
onSelect: handleSelect,
123+
toggleDropdown: (l) => handleToggleSubLanguage(l),
124+
onSelect: (l, sl) => handleSubLanguageSelect(l, sl),
49125
onClose: () => setIsOpen(false),
50126
});
51127

@@ -60,20 +136,6 @@ const LanguageSelector = () => {
60136
}, 0);
61137
};
62138

63-
const handleToggleSublanguage = (openedLang: LanguageType) => {
64-
const isAlreadyOpened = openedLanguages.some(
65-
(lang) => lang.name === openedLang.name
66-
);
67-
68-
if (!isAlreadyOpened) {
69-
setOpenedLanguages((prev) => [...prev, openedLang]);
70-
} else {
71-
setOpenedLanguages((prev) =>
72-
prev.filter((lang) => lang.name !== openedLang.name)
73-
);
74-
}
75-
};
76-
77139
const toggleDropdown = () => {
78140
setIsOpen((prev) => {
79141
if (!prev) setTimeout(focusFirst, 0);
@@ -88,13 +150,6 @@ const LanguageSelector = () => {
88150
// eslint-disable-next-line react-hooks/exhaustive-deps
89151
}, [isOpen]);
90152

91-
useEffect(() => {
92-
if (language.mainLanguage) {
93-
handleToggleSublanguage(language.mainLanguage);
94-
}
95-
// eslint-disable-next-line react-hooks/exhaustive-deps
96-
}, [language]);
97-
98153
useEffect(() => {
99154
if (isOpen && focusedIndex >= 0) {
100155
const element = document.querySelector(
@@ -104,8 +159,13 @@ const LanguageSelector = () => {
104159
}
105160
}, [isOpen, focusedIndex]);
106161

107-
if (loading) return <p>Loading languages...</p>;
108-
if (error) return <p>Error fetching languages: {error}</p>;
162+
if (loading) {
163+
return <p>Loading languages...</p>;
164+
}
165+
166+
if (error) {
167+
return <p>Error fetching languages: {error}</p>;
168+
}
109169

110170
return (
111171
<div
@@ -121,8 +181,8 @@ const LanguageSelector = () => {
121181
onClick={toggleDropdown}
122182
>
123183
<div className="selector__value">
124-
<img src={language.icon} alt="" />
125-
<span>{language.name || "Select a language"}</span>
184+
<img src={displayLogo} alt="" />
185+
<span>{displayName}</span>
126186
</div>
127187
<span className="selector__arrow" />
128188
</button>
@@ -136,13 +196,12 @@ const LanguageSelector = () => {
136196
{fetchedLanguages.map((lang, index) =>
137197
lang.subLanguages.length > 0 ? (
138198
<SubLanguageSelector
139-
key={index}
140-
mainLanguage={lang}
141-
afterSelect={() => {
142-
setIsOpen(false);
143-
}}
199+
key={lang.name}
144200
opened={openedLanguages.includes(lang)}
145-
onDropdownToggle={handleToggleSublanguage}
201+
parentLanguage={lang}
202+
onDropdownToggle={handleToggleSubLanguage}
203+
handleParentSelect={handleSelect}
204+
afterSelect={afterSelect}
146205
/>
147206
) : (
148207
<li

0 commit comments

Comments
 (0)