diff --git a/frontend/app/components/FavoriteButton/FavoriteButton.test.tsx b/frontend/app/components/FavoriteButton/FavoriteButton.test.tsx
index 039faa34..1a31ef90 100644
--- a/frontend/app/components/FavoriteButton/FavoriteButton.test.tsx
+++ b/frontend/app/components/FavoriteButton/FavoriteButton.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
-import { screen } from "@testing-library/react";
+import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { authClient } from "~/api/auth";
import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton";
@@ -71,7 +71,6 @@ describe("FavoriteButton", () => {
data: { addFavoriteBook: { __typename: "Book", id: 1 } },
},
},
- favoriteIds([1]),
],
});
@@ -82,12 +81,11 @@ describe("FavoriteButton", () => {
}),
);
- expect(
- await screen.findByRole("button", {
- name: "Favorite The Blade Itself",
- pressed: true,
- }),
- ).toHaveAttribute("aria-pressed", "true");
+ await waitFor(() =>
+ expect(
+ screen.getByRole("button", { name: "Favorite The Blade Itself" }),
+ ).not.toBeDisabled(),
+ );
});
it("removes a favorite book from the user's favorites", async () => {
diff --git a/frontend/app/components/books/BookFilters/BookFilters.tsx b/frontend/app/components/books/BookFilters/BookFilters.tsx
index 790a7bbc..c1b48ce1 100644
--- a/frontend/app/components/books/BookFilters/BookFilters.tsx
+++ b/frontend/app/components/books/BookFilters/BookFilters.tsx
@@ -1,6 +1,5 @@
-import { Button } from "~/components/ui/Button";
import { useSearchParams } from "react-router";
-import { FILTER_KEYS, useBookSearchParams } from "~/hooks/useBookSearchParams";
+import { useBookSearchParams } from "~/hooks/useBookSearchParams";
import { useBookFilters } from "~/hooks/useBookFilters";
import {
Combobox,
@@ -24,162 +23,129 @@ function BookFilters() {
const { genres, languages, bookFormats } = useBookFilters();
const genresAnchor = useComboboxAnchor();
- const hasActiveFilters = Object.values(filters).some(Boolean);
-
- const clearFilters = () => {
- setSearchParams((searchParams) => {
- FILTER_KEYS.forEach((key) => searchParams.delete(key));
-
- // Reset the page to 1 when clearing filters.
- // A missing "page" search param means page 1
- searchParams.delete("page");
-
- return searchParams;
- });
- };
-
return (
-
-
-
- Filter Books
-
-
-
-
-
- -
-
- {
- setSearchParams((searchParams) => {
- searchParams.delete("genres");
- selected.forEach((genre) =>
- searchParams.append("genres", genre),
- );
- searchParams.delete("page");
- return searchParams;
- });
- }}
- >
-
-
- {(filters.genres ?? []).map((genre) => (
- {genre}
- ))}
-
-
-
-
- No genres found.
-
- {(genre: string) => (
-
- {genre}
-
- )}
-
-
-
-
- -
-
- {
- setSearchParams((searchParams) => {
- if (language) {
- searchParams.set("language", language);
- } else {
- searchParams.delete("language");
- }
- searchParams.delete("page");
- return searchParams;
- });
- }}
- >
-
-
- No languages found.
-
- {(language: string) => (
-
- {language}
-
- )}
-
-
-
-
- -
-
- {
- setSearchParams((searchParams) => {
- if (bookFormat) {
- searchParams.set("bookFormat", bookFormat);
- } else {
- searchParams.delete("bookFormat");
- }
- searchParams.delete("page");
- return searchParams;
- });
- }}
- >
-
+
+ {(filters.genres ?? []).map((genre) => (
+ {genre}
+ ))}
+
+
-
- No book formats found.
-
- {(bookFormat: string) => (
-
- {bookFormat}
-
- )}
-
-
-
-
- -
-
-
- -
-
-
-
-
+
+
+ No genres found.
+
+ {(genre: string) => (
+
+ {genre}
+
+ )}
+
+
+
+
+
+
+ {
+ setSearchParams((searchParams) => {
+ if (language) {
+ searchParams.set("language", language);
+ } else {
+ searchParams.delete("language");
+ }
+ searchParams.delete("page");
+ return searchParams;
+ });
+ }}
+ >
+
+
+ No languages found.
+
+ {(language: string) => (
+
+ {language}
+
+ )}
+
+
+
+
+
+
+ {
+ setSearchParams((searchParams) => {
+ if (bookFormat) {
+ searchParams.set("bookFormat", bookFormat);
+ } else {
+ searchParams.delete("bookFormat");
+ }
+ searchParams.delete("page");
+ return searchParams;
+ });
+ }}
+ >
+
+
+ No book formats found.
+
+ {(bookFormat: string) => (
+
+ {bookFormat}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/app/components/books/SearchBar/SearchBar.tsx b/frontend/app/components/books/SearchBar/SearchBar.tsx
index 6225530e..dfd84fdd 100644
--- a/frontend/app/components/books/SearchBar/SearchBar.tsx
+++ b/frontend/app/components/books/SearchBar/SearchBar.tsx
@@ -11,13 +11,19 @@ import {
import { Button } from "~/components/ui/Button";
import { Badge } from "~/components/ui/Badge";
import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "~/components/ui/Popover";
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+ DrawerDescription,
+ DrawerFooter,
+} from "~/components/ui/Drawer";
import { useSearchParams } from "react-router";
import { useDebounce } from "~/hooks/useDebounce";
-import { useBookSearchParams } from "~/hooks/useBookSearchParams";
+import { FILTER_KEYS, useBookSearchParams } from "~/hooks/useBookSearchParams";
+import { useIsMobile } from "~/hooks/useIsMobile";
const SEARCH_DEBOUNCE_MS = 500;
@@ -33,6 +39,8 @@ function SearchBar({ resultCount }: SearchBarProps) {
const debouncedQuery = useDebounce(inputField, SEARCH_DEBOUNCE_MS);
const newQueryFromInputRef = useRef(false);
+ const isMobile = useIsMobile();
+
useEffect(() => {
if (debouncedQuery !== inputField || debouncedQuery === query) {
return;
@@ -63,6 +71,18 @@ function SearchBar({ resultCount }: SearchBarProps) {
(value) => value !== undefined,
).length;
+ const clearFilters = () => {
+ setSearchParams((searchParams) => {
+ FILTER_KEYS.forEach((key) => searchParams.delete(key));
+
+ // Reset the page to 1 when clearing filters.
+ // A missing "page" search param means page 1
+ searchParams.delete("page");
+
+ return searchParams;
+ });
+ };
+
const filtersDescription = `${filtersCount} ${filtersCount === 1 ? "filter" : "filters"} active`;
return (
@@ -94,8 +114,11 @@ function SearchBar({ resultCount }: SearchBarProps) {
)}
-
-
+
)}
-
-
-
-
-
+
+
+
+
+ Filter books
+
+
+
+
+
+
+
+ {filtersDescription}
+
+
+
+ {resultCount !== undefined
+ ? `Show ${resultCount} ${resultCount === 1 ? "result" : "results"}`
+ : "Show results"}
+
+ }
+ />
+
+
+
);
}
diff --git a/frontend/app/components/ui/Drawer.tsx b/frontend/app/components/ui/Drawer.tsx
new file mode 100644
index 00000000..c777cdb7
--- /dev/null
+++ b/frontend/app/components/ui/Drawer.tsx
@@ -0,0 +1,225 @@
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
+import { cn } from "cn";
+
+interface DrawerContextProps {
+ hasSnapPoints: boolean;
+ modal: DrawerPrimitive.Root.Props["modal"];
+ showSwipeHandle: boolean;
+ swipeDirection: NonNullable;
+}
+
+const DrawerContext = React.createContext(null);
+
+function useDrawer() {
+ const context = React.use(DrawerContext);
+
+ if (!context) {
+ throw new Error("useDrawer must be used within a Drawer.");
+ }
+
+ return context;
+}
+
+function Drawer({
+ modal = true,
+ showSwipeHandle = false,
+ snapPoints,
+ swipeDirection = "down",
+ ...props
+}: DrawerPrimitive.Root.Props & {
+ showSwipeHandle?: boolean;
+}) {
+ const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
+ const contextValue = React.useMemo(
+ () => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
+ [hasSnapPoints, modal, showSwipeHandle, swipeDirection],
+ );
+
+ return (
+
+
+
+ );
+}
+
+function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
+ return ;
+}
+
+function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
+ return ;
+}
+
+function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
+ return ;
+}
+
+function DrawerOverlay({
+ className,
+ ...props
+}: DrawerPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function DrawerSwipeHandle({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DrawerContent({
+ className,
+ children,
+ ...props
+}: DrawerPrimitive.Popup.Props) {
+ const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer();
+ const swipeAxis =
+ swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";
+
+ return (
+
+ {modal === true && (
+
+ )}
+
+
+ {showSwipeHandle && }
+
+ {children}
+
+
+
+
+ );
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
+ return (
+
+ );
+}
+
+function DrawerDescription({
+ className,
+ ...props
+}: DrawerPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerSwipeHandle,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+};
diff --git a/frontend/app/hooks/useIsMobile.ts b/frontend/app/hooks/useIsMobile.ts
new file mode 100644
index 00000000..791191a6
--- /dev/null
+++ b/frontend/app/hooks/useIsMobile.ts
@@ -0,0 +1,26 @@
+import { useSyncExternalStore } from "react";
+
+// Based on a known useSyncExternalStore + matchMedia pattern:
+// https://www.epicreact.dev/use-sync-external-store-demystified-for-practical-react-development-w5ac0#:~:text=computing%20it%20unnecessarily.-,Bonus,-%3A%20Reusable%20useMediaQuery%20Hook
+function makeMediaQueryStore(mediaQuery: string) {
+ function getSnapshot() {
+ return window.matchMedia(mediaQuery).matches;
+ }
+
+ function subscribe(onChange: () => void) {
+ const mediaQueryList = window.matchMedia(mediaQuery);
+ mediaQueryList.addEventListener("change", onChange);
+ return () => {
+ mediaQueryList.removeEventListener("change", onChange);
+ };
+ }
+
+ return function useMediaQuery() {
+ return useSyncExternalStore(subscribe, getSnapshot);
+ };
+}
+
+// Below Tailwinds md (768px) breakpoint
+const useIsMobile = makeMediaQueryStore("(max-width: 767px)");
+
+export { useIsMobile };
diff --git a/frontend/app/test/setup.ts b/frontend/app/test/setup.ts
index b2ed409a..c8d63831 100644
--- a/frontend/app/test/setup.ts
+++ b/frontend/app/test/setup.ts
@@ -13,6 +13,16 @@ class IntersectionObserverStub {
takeRecords = vi.fn(() => []);
}
vi.stubGlobal("IntersectionObserver", IntersectionObserverStub);
+vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+}));
afterEach(() => {
// Unmount everything rendered by the previous test.