Skip to content

Commit

Permalink
Merge pull request #159 from barrymun/feature/search
Browse files Browse the repository at this point in the history
Feature - Implement basic search functionality v2
  • Loading branch information
technoph1le authored Jan 29, 2025
2 parents df7dcbf + c0b3bff commit 94cf03c
Show file tree
Hide file tree
Showing 23 changed files with 1,079 additions and 321 deletions.
226 changes: 125 additions & 101 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"react-router-dom": "^7.1.1",
"react-syntax-highlighter": "^15.6.1"
},
"devDependencies": {
Expand Down
21 changes: 21 additions & 0 deletions src/AppRouter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Route, Routes } from "react-router-dom";

import App from "@components/App";
import SnippetList from "@components/SnippetList";

const AppRouter = () => {
return (
<Routes>
<Route element={<App />}>
<Route path="/" element={<SnippetList />} />
<Route path="/:languageName" element={<SnippetList />} />
<Route
path="/:languageName/:subLanguageName/:categoryName"
element={<SnippetList />}
/>
</Route>
</Routes>
);
};

export default AppRouter;
17 changes: 17 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FC } from "react";

import { AppProvider } from "@contexts/AppContext";

import Container from "./Container";

interface AppProps {}

const App: FC<AppProps> = () => {
return (
<AppProvider>
<Container />
</AppProvider>
);
};

export default App;
61 changes: 43 additions & 18 deletions src/components/CategoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,58 @@
import { useEffect } from "react";
import { FC } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";

import { useAppContext } from "@contexts/AppContext";
import { useCategories } from "@hooks/useCategories";
import { defaultCategoryName } from "@utils/consts";
import { slugify } from "@utils/slugify";

interface CategoryListItemProps {
name: string;
}

const CategoryListItem: FC<CategoryListItemProps> = ({ name }) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();

const { language, subLanguage, category } = useAppContext();

const handleSelect = () => {
navigate({
pathname: `/${slugify(language.name)}/${slugify(subLanguage)}/${slugify(name)}`,
search: searchParams.toString(),
});
};

return (
<li className="category">
<button
className={`category__btn ${
slugify(name) === slugify(category) ? "category__btn--active" : ""
}`}
onClick={handleSelect}
>
{name}
</button>
</li>
);
};

const CategoryList = () => {
const { category, setCategory } = useAppContext();
const { fetchedCategories, loading, error } = useCategories();

useEffect(() => {
setCategory(fetchedCategories[0]);
}, [setCategory, fetchedCategories]);

if (loading) return <div>Loading...</div>;
if (loading) {
return <div>Loading...</div>;
}

if (error) return <div>Error occurred: {error}</div>;
if (error) {
return <div>Error occurred: {error}</div>;
}

return (
<ul role="list" className="categories">
<CategoryListItem name={defaultCategoryName} />
{fetchedCategories.map((name, idx) => (
<li key={idx} className="category">
<button
className={`category__btn ${
name === category ? "category__btn--active" : ""
}`}
onClick={() => setCategory(name)}
>
{name}
</button>
</li>
<CategoryListItem key={idx} name={name} />
))}
</ul>
);
Expand Down
12 changes: 8 additions & 4 deletions src/App.tsx → src/components/Container.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import SnippetList from "@components/SnippetList";
import { FC } from "react";
import { Outlet } from "react-router-dom";

import { useAppContext } from "@contexts/AppContext";
import Banner from "@layouts/Banner";
import Footer from "@layouts/Footer";
import Header from "@layouts/Header";
import Sidebar from "@layouts/Sidebar";

const App = () => {
interface ContainerProps {}

const Container: FC<ContainerProps> = () => {
const { category } = useAppContext();

return (
Expand All @@ -18,12 +22,12 @@ const App = () => {
<h2 className="section-title">
{category ? category : "Select a category"}
</h2>
<SnippetList />
<Outlet />
</section>
</main>
<Footer />
</div>
);
};

export default App;
export default Container;
173 changes: 116 additions & 57 deletions src/components/LanguageSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,127 @@
/**
* Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/
*/

import { useRef, useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";

import { useAppContext } from "@contexts/AppContext";
import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation";
import { useLanguages } from "@hooks/useLanguages";
import { LanguageType } from "@types";
import { configureUserSelection } from "@utils/configureUserSelection";
import {
getLanguageDisplayLogo,
getLanguageDisplayName,
} from "@utils/languageUtils";
import { slugify } from "@utils/slugify";

import SubLanguageSelector from "./SubLanguageSelector";

// Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/

const LanguageSelector = () => {
const { language, setLanguage } = useAppContext();
const navigate = useNavigate();

const { language, subLanguage, setSearchText } = useAppContext();
const { fetchedLanguages, loading, error } = useLanguages();
const allLanguages = useMemo(
() =>
fetchedLanguages.flatMap((lang) =>
lang.subLanguages.length > 0
? [
lang,
...lang.subLanguages.map((subLang) => ({
...subLang,
mainLanguage: lang,
subLanguages: [],
})),
]
: [lang]
),
[fetchedLanguages]
);

const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [openedLanguages, setOpenedLanguages] = useState<LanguageType[]>([]);

const handleSelect = (selected: LanguageType) => {
setLanguage(selected);
const keyboardItems = useMemo(() => {
return fetchedLanguages.flatMap((lang) =>
openedLanguages.map((ol) => ol.name).includes(lang.name)
? [
{ languageName: lang.name },
...lang.subLanguages.map((sl) => ({
languageName: lang.name,
subLanguageName: sl.name,
})),
]
: [{ languageName: lang.name }]
);
}, [fetchedLanguages, openedLanguages]);

const displayName = useMemo(
() => getLanguageDisplayName(language.name, subLanguage),
[language.name, subLanguage]
);

const displayLogo = useMemo(
() => getLanguageDisplayLogo(language.name, subLanguage),
[language.name, subLanguage]
);

const handleToggleSubLanguage = (name: LanguageType["name"]) => {
const isAlreadyOpened = openedLanguages.some((lang) => lang.name === name);
const openedLang = fetchedLanguages.find((lang) => lang.name === name);
if (openedLang === undefined || openedLang.subLanguages.length === 0) {
return;
}

if (!isAlreadyOpened) {
setOpenedLanguages((prev) => [...prev, openedLang]);
} else {
setOpenedLanguages((prev) =>
prev.filter((lang) => lang.name !== openedLang.name)
);
}
};

/**
* When setting a new language we need to ensure that a category
* has been set given this new language.
* Ensure that the search text is cleared.
*/
const handleSelect = async (selected: LanguageType) => {
const {
language: newLanguage,
subLanguage: newSubLanguage,
category: newCategory,
} = await configureUserSelection({
languageName: selected.name,
});

setSearchText("");
navigate(
`/${slugify(newLanguage.name)}/${slugify(newSubLanguage)}/${slugify(newCategory)}`
);
setIsOpen(false);
setOpenedLanguages([]);
};

const afterSelect = () => {
setIsOpen(false);
};

const handleSubLanguageSelect = async (
selectedLanguageName: LanguageType["name"],
selectedSubLanguageName:
| LanguageType["subLanguages"][number]["name"]
| undefined
) => {
const {
language: newLanguage,
subLanguage: newSubLanguage,
category: newCategory,
} = await configureUserSelection({
languageName: selectedLanguageName,
subLanguageName: selectedSubLanguageName,
});

setSearchText("");
navigate(
`/${slugify(newLanguage.name)}/${slugify(newSubLanguage)}/${slugify(newCategory)}`
);
afterSelect();
};

const { focusedIndex, handleKeyDown, resetFocus, focusFirst } =
useKeyboardNavigation({
items: allLanguages,
items: keyboardItems,
isOpen,
openedLanguages,
toggleDropdown: (openedLang) => handleToggleSublanguage(openedLang),
onSelect: handleSelect,
toggleDropdown: (l) => handleToggleSubLanguage(l),
onSelect: (l, sl) => handleSubLanguageSelect(l, sl),
onClose: () => setIsOpen(false),
});

Expand All @@ -60,20 +136,6 @@ const LanguageSelector = () => {
}, 0);
};

const handleToggleSublanguage = (openedLang: LanguageType) => {
const isAlreadyOpened = openedLanguages.some(
(lang) => lang.name === openedLang.name
);

if (!isAlreadyOpened) {
setOpenedLanguages((prev) => [...prev, openedLang]);
} else {
setOpenedLanguages((prev) =>
prev.filter((lang) => lang.name !== openedLang.name)
);
}
};

const toggleDropdown = () => {
setIsOpen((prev) => {
if (!prev) setTimeout(focusFirst, 0);
Expand All @@ -88,13 +150,6 @@ const LanguageSelector = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);

useEffect(() => {
if (language.mainLanguage) {
handleToggleSublanguage(language.mainLanguage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language]);

useEffect(() => {
if (isOpen && focusedIndex >= 0) {
const element = document.querySelector(
Expand All @@ -104,8 +159,13 @@ const LanguageSelector = () => {
}
}, [isOpen, focusedIndex]);

if (loading) return <p>Loading languages...</p>;
if (error) return <p>Error fetching languages: {error}</p>;
if (loading) {
return <p>Loading languages...</p>;
}

if (error) {
return <p>Error fetching languages: {error}</p>;
}

return (
<div
Expand All @@ -121,8 +181,8 @@ const LanguageSelector = () => {
onClick={toggleDropdown}
>
<div className="selector__value">
<img src={language.icon} alt="" />
<span>{language.name || "Select a language"}</span>
<img src={displayLogo} alt="" />
<span>{displayName}</span>
</div>
<span className="selector__arrow" />
</button>
Expand All @@ -136,13 +196,12 @@ const LanguageSelector = () => {
{fetchedLanguages.map((lang, index) =>
lang.subLanguages.length > 0 ? (
<SubLanguageSelector
key={index}
mainLanguage={lang}
afterSelect={() => {
setIsOpen(false);
}}
key={lang.name}
opened={openedLanguages.includes(lang)}
onDropdownToggle={handleToggleSublanguage}
parentLanguage={lang}
onDropdownToggle={handleToggleSubLanguage}
handleParentSelect={handleSelect}
afterSelect={afterSelect}
/>
) : (
<li
Expand Down
Loading

0 comments on commit 94cf03c

Please sign in to comment.