Skip to content

Commit 92f0df0

Browse files
committed
Import hooks, some components & simplified context
1 parent c945b7c commit 92f0df0

14 files changed

+702
-12
lines changed

src/app/layout.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from "next";
22
import { Source_Sans_3 } from "next/font/google";
33
import "@/styles/globals.css";
4+
import { AppProvider } from "@/contexts/AppContext";
45

56
const sourceSans3 = Source_Sans_3({
67
subsets: ["latin"],
@@ -18,8 +19,10 @@ export default function RootLayout({
1819
children: React.ReactNode;
1920
}>) {
2021
return (
21-
<html lang="en">
22-
<body className={`${sourceSans3.className}`}>{children}</body>
23-
</html>
22+
<AppProvider>
23+
<html lang="en">
24+
<body className={`${sourceSans3.className}`}>{children}</body>
25+
</html>
26+
</AppProvider>
2427
);
2528
}

src/components/CategoryList.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import { FC } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { useAppContext } from "@/contexts/AppContext";
6+
import { useCategories } from "@/hooks/useCategories";
7+
import { defaultCategoryName } from "@/utils/consts";
8+
import { slugify } from "@/utils/slugify";
9+
10+
interface CategoryListItemProps {
11+
name: string;
12+
}
13+
14+
const CategoryListItem: FC<CategoryListItemProps> = ({ name }) => {
15+
const router = useRouter();
16+
17+
const { selectedLanguage, selectedCategory } = useAppContext();
18+
19+
const handleSelect = () => {
20+
router.push(
21+
`/${slugify(selectedLanguage.name)}/${slugify(
22+
selectedCategory.categoryName
23+
)}`
24+
);
25+
};
26+
27+
return (
28+
<li className="category">
29+
<button
30+
className={`category__btn ${
31+
slugify(name) === slugify(selectedCategory.categoryName)
32+
? "category__btn--active"
33+
: ""
34+
}`}
35+
onClick={handleSelect}
36+
>
37+
{name}
38+
</button>
39+
</li>
40+
);
41+
};
42+
43+
const CategoryList: FC = () => {
44+
const { fetchedCategories, loading, error } = useCategories();
45+
46+
if (loading) return <div>Loading...</div>;
47+
if (error) return <div>Error occurred: {error}</div>;
48+
49+
return (
50+
<ul role="list" className="categories">
51+
<CategoryListItem name={defaultCategoryName} />
52+
{fetchedCategories.map((name, idx) => (
53+
<CategoryListItem key={idx} name={name} />
54+
))}
55+
</ul>
56+
);
57+
};
58+
59+
export default CategoryList;

src/components/LanguageSelector.tsx

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
3+
*/
4+
5+
import { useRef, useEffect, useState, useMemo } from "react";
6+
7+
import { useAppContext } from "@/contexts/AppContext";
8+
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
9+
import { useLanguages } from "@/hooks/useLanguages";
10+
import { LanguageType } from "@/types";
11+
// import { configureUserSelection } from "@utils/configureUserSelection";
12+
// import {
13+
// getLanguageDisplayLogo,
14+
// getLanguageDisplayName,
15+
// } from "@utils/languageUtils";
16+
import { slugify } from "@/utils/slugify";
17+
18+
// import SubLanguageSelector from "./SubLanguageSelector";
19+
import { useRouter } from "next/router";
20+
21+
const LanguageSelector = () => {
22+
const router = useRouter();
23+
24+
const { selectedLanguage, selectedCategory, setSearchText } = useAppContext();
25+
const { fetchedLanguages, loading, error } = useLanguages();
26+
27+
const dropdownRef = useRef<HTMLDivElement>(null);
28+
const [isOpen, setIsOpen] = useState<boolean>(false);
29+
const [openedLanguages, setOpenedLanguages] = useState<LanguageType[]>([]);
30+
31+
const keyboardItems = useMemo(() => {
32+
return fetchedLanguages.flatMap((lang) =>
33+
openedLanguages.map((ol) => ol.name).includes(lang.languageName)
34+
? [
35+
{ languageName: lang.languageName },
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(
58+
(lang) => lang.languageName === name
59+
);
60+
if (openedLang === undefined || openedLang.subLanguages.length === 0) {
61+
return;
62+
}
63+
64+
if (!isAlreadyOpened) {
65+
setOpenedLanguages((prev) => [...prev, openedLang]);
66+
} else {
67+
setOpenedLanguages((prev) =>
68+
prev.filter((lang) => lang.name !== openedLang.name)
69+
);
70+
}
71+
};
72+
73+
/**
74+
* When setting a new language we need to ensure that a category
75+
* has been set given this new language.
76+
* Ensure that the search text is cleared.
77+
*/
78+
const handleSelect = async (selected: LanguageType) => {
79+
const {
80+
language: newLanguage,
81+
subLanguage: newSubLanguage,
82+
category: newCategory,
83+
} = await configureUserSelection({
84+
languageName: selected.name,
85+
});
86+
87+
setSearchText("");
88+
router.push(`/${slugify(newLanguage.name)}/${slugify(newCategory)}`);
89+
setIsOpen(false);
90+
setOpenedLanguages([]);
91+
};
92+
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+
router.push(`/${slugify(newLanguage.name)}/${slugify(newCategory)}`);
114+
afterSelect();
115+
};
116+
117+
const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
118+
useKeyboardNavigation({
119+
items: keyboardItems,
120+
isOpen,
121+
toggleDropdown: (l) => handleToggleSubLanguage(l),
122+
onSelect: (l, sl) => handleSubLanguageSelect(l, sl),
123+
onClose: () => setIsOpen(false),
124+
});
125+
126+
const handleBlur = () => {
127+
setTimeout(() => {
128+
if (
129+
dropdownRef.current &&
130+
!dropdownRef.current.contains(document.activeElement)
131+
) {
132+
setIsOpen(false);
133+
}
134+
}, 0);
135+
};
136+
137+
const toggleDropdown = () => {
138+
setIsOpen((prev) => {
139+
if (!prev) setTimeout(focusFirst, 0);
140+
return !prev;
141+
});
142+
};
143+
144+
useEffect(() => {
145+
if (!isOpen) {
146+
resetFocus();
147+
}
148+
// eslint-disable-next-line react-hooks/exhaustive-deps
149+
}, [isOpen]);
150+
151+
useEffect(() => {
152+
if (isOpen && focusedIndex >= 0) {
153+
const elements = Array.from(
154+
document.querySelectorAll(".selector__item")
155+
) as HTMLElement[];
156+
const focusableElements = elements.filter(
157+
(el) => el.getAttribute("tabIndex") !== "-1"
158+
);
159+
const element = focusableElements[focusedIndex];
160+
element?.focus();
161+
}
162+
}, [isOpen, focusedIndex]);
163+
164+
if (loading) {
165+
return <p>Loading languages...</p>;
166+
}
167+
168+
if (error) {
169+
return <p>Error fetching languages: {error}</p>;
170+
}
171+
172+
return (
173+
<div
174+
className={`selector ${isOpen ? "selector--open" : ""}`}
175+
ref={dropdownRef}
176+
onBlur={handleBlur}
177+
>
178+
<button
179+
className="selector__button"
180+
aria-label="select button"
181+
aria-haspopup="listbox"
182+
aria-expanded={isOpen}
183+
onClick={toggleDropdown}
184+
>
185+
<div className="selector__value">
186+
<img src={displayLogo} alt="" />
187+
<span>{displayName}</span>
188+
</div>
189+
<span className="selector__arrow" />
190+
</button>
191+
<ul
192+
className={`selector__dropdown ${isOpen ? "" : " hidden"}`}
193+
role="listbox"
194+
onKeyDown={handleKeyDown}
195+
tabIndex={0}
196+
>
197+
{fetchedLanguages.map((lang, index) =>
198+
lang.subLanguages.length > 0 ? (
199+
<SubLanguageSelector
200+
key={lang.name}
201+
opened={openedLanguages.includes(lang)}
202+
parentLanguage={lang}
203+
onDropdownToggle={handleToggleSubLanguage}
204+
handleParentSelect={handleSelect}
205+
afterSelect={afterSelect}
206+
/>
207+
) : (
208+
<li
209+
key={lang.name}
210+
role="option"
211+
tabIndex={0}
212+
onClick={() => handleSelect(lang)}
213+
className={`selector__item ${
214+
language.name === lang.name ? "selected" : ""
215+
} ${focusedIndex === index ? "focused" : ""}`}
216+
aria-selected={language.name === lang.name}
217+
>
218+
<label>
219+
<img src={lang.icon} alt="" />
220+
<span>{lang.name}</span>
221+
</label>
222+
</li>
223+
)
224+
)}
225+
</ul>
226+
</div>
227+
);
228+
};
229+
230+
export default LanguageSelector;

src/components/Sidebar.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
// import CategoryList from "@components/CategoryList";
1+
import CategoryList from "@/components/CategoryList";
22
// import LanguageSelector from "@components/LanguageSelector";
33

44
const Sidebar = () => {
55
return (
66
<aside className="sidebar flow">
7-
{/* <LanguageSelector />
8-
<CategoryList /> */}
7+
{/* <LanguageSelector /> */}
8+
<CategoryList />
99
</aside>
1010
);
1111
};

0 commit comments

Comments
 (0)