diff --git a/docs/code-dark-theme-description.md b/docs/code-dark-theme-description.md new file mode 100644 index 0000000..629c143 --- /dev/null +++ b/docs/code-dark-theme-description.md @@ -0,0 +1,60 @@ +# Add code-sample dark theme variant + +## Summary + +This PR adds dark theme CSS variants for the CodeExample component. Code samples now respect CSS custom properties with proper light/dark theme overrides, ensuring visual parity across both modes. + +## Changes + +### `src/styles/code.css` (new) +- CSS custom properties for code sample styling (`--bg-subtle`, `--bg-highlight`, `--border-subtle`, `--text-main`, `--font-mono`) +- Light theme defaults for all properties +- `[data-theme="dark"]` overrides for dark mode parity +- `.code-sample__tab` styles with active state +- `.code-sample__pre` styles for code display +- `.sr-only` utility class for screen reader announcements +- Reduced motion support via `prefers-reduced-motion` + +### `src/components/CodeExample.tsx` +- Refactored from inline styles to CSS classes +- Added import for `../styles/code.css` +- Replaced `preview-card` with `code-sample` wrapper class +- Uses semantic class names: `code-sample__header`, `code-sample__tabs`, `code-sample__tab`, `code-sample__tab--active`, `code-sample__panel`, `code-sample__pre` +- Removed embedded ` ); } \ No newline at end of file diff --git a/src/components/FiltersSidebar.test.tsx b/src/components/FiltersSidebar.test.tsx new file mode 100644 index 0000000..fddf3c2 --- /dev/null +++ b/src/components/FiltersSidebar.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import FiltersSidebar from "./FiltersSidebar"; + +const baseProps = { + selectedCategories: new Set(), + toggleCategory: vi.fn(), + minPrice: null, + maxPrice: null, + setMinPrice: vi.fn(), + setMaxPrice: vi.fn(), + popularity: "any", + setPopularity: vi.fn(), + clearFilters: vi.fn(), +}; + +describe("FiltersSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it("renders all filter sections", () => { + render(); + expect(screen.getByText("Categories")).toBeTruthy(); + expect(screen.getByText("Price range")).toBeTruthy(); + expect(screen.getByText("Popularity")).toBeTruthy(); + }); + + it("renders category checkboxes", () => { + render(); + expect(screen.getByLabelText("Data & Analytics")).toBeTruthy(); + expect(screen.getByLabelText("Payment Processing")).toBeTruthy(); + expect(screen.getByLabelText("Communication")).toBeTruthy(); + expect(screen.getByLabelText("AI/ML")).toBeTruthy(); + expect(screen.getByLabelText("Other")).toBeTruthy(); + }); + + it("calls toggleCategory when checkbox is clicked", () => { + const toggleCategory = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Data & Analytics")); + expect(toggleCategory).toHaveBeenCalledWith("Data & Analytics"); + }); + + describe("collapse functionality", () => { + it("expands panel by default when no persisted state", () => { + render(); + const categoriesPanel = screen.getByTestId("filter-panel-categories"); + expect(categoriesPanel.hasAttribute("hidden")).toBe(false); + }); + + it("persists collapsed state on toggle", () => { + const { unmount } = render(); + + const header = screen.getByRole("button", { name: "Categories" }); + fireEvent.click(header); + + expect(localStorage.getItem("callora.filters.categories.collapsed")).toBe("true"); + unmount(); + }); + + it("restores collapsed state from localStorage on re-render", () => { + localStorage.setItem("callora.filters.categories.collapsed", "true"); + + render(); + const categoriesPanel = screen.getByTestId("filter-panel-categories"); + expect(categoriesPanel.hasAttribute("hidden")).toBe(true); + }); + + it("restores expanded state from localStorage when set to false", () => { + localStorage.setItem("callora.filters.popularity.collapsed", "false"); + + render(); + const popularityPanel = screen.getByTestId("filter-panel-popularity"); + expect(popularityPanel.hasAttribute("hidden")).toBe(false); + }); + + it("sets aria-expanded to true when panel is expanded", () => { + render(); + const header = screen.getByRole("button", { name: "Categories" }); + expect(header.getAttribute("aria-expanded")).toBe("true"); + }); + + it("sets aria-expanded to false when panel is collapsed", () => { + localStorage.setItem("callora.filters.categories.collapsed", "true"); + + render(); + const header = screen.getByRole("button", { name: "Categories" }); + expect(header.getAttribute("aria-expanded")).toBe("false"); + }); + + it("has correct aria-controls attribute on header", () => { + render(); + const header = screen.getByRole("button", { name: "Categories" }); + expect(header.getAttribute("aria-controls")).toBe("filter-panel-categories"); + }); + + it("collapses all sections independently", () => { + render(); + + const priceHeader = screen.getByRole("button", { name: "Price range" }); + fireEvent.click(priceHeader); + + const categoriesPanel = screen.getByTestId("filter-panel-categories"); + const pricePanel = screen.getByTestId("filter-panel-price"); + const popularityPanel = screen.getByTestId("filter-panel-popularity"); + + expect(categoriesPanel.hasAttribute("hidden")).toBe(false); + expect(pricePanel.hasAttribute("hidden")).toBe(true); + expect(popularityPanel.hasAttribute("hidden")).toBe(false); + }); + + it("rotates chevron when collapsed", () => { + localStorage.setItem("callora.filters.categories.collapsed", "true"); + + render(); + const header = screen.getByRole("button", { name: "Categories" }); + const chevron = header.querySelector(".filter-group__chevron"); + expect(chevron?.classList.contains("filter-group__chevron--collapsed")).toBe(true); + }); + }); + + describe("clear filters", () => { + it("calls clearFilters when Clear filters button is clicked", () => { + const clearFilters = vi.fn(); + render(); + fireEvent.click(screen.getByText("Clear filters")); + expect(clearFilters).toHaveBeenCalledTimes(1); + }); + }); + + describe("price validation", () => { + it("shows error when min price exceeds max price", () => { + render( + + ); + expect(screen.getByText(/Min price cannot exceed max price/i)).toBeTruthy(); + }); + + it("does not show error when prices are valid", () => { + render( + + ); + expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull(); + }); + + it("does not show error when both prices are null", () => { + render(); + expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/FiltersSidebar.tsx b/src/components/FiltersSidebar.tsx index b4d6b02..0e5751f 100644 --- a/src/components/FiltersSidebar.tsx +++ b/src/components/FiltersSidebar.tsx @@ -1,4 +1,6 @@ -import { WarningIcon } from "./icons"; +import React from "react"; +import { WarningIcon, ChevronIcon } from "./icons"; +import { usePersistedState } from "../hooks/usePersistedState"; export const ALL_CATEGORIES = [ "Data & Analytics", @@ -8,6 +10,51 @@ export const ALL_CATEGORIES = [ "Other", ]; +interface FilterGroupProps { + title: string; + storageKey: "categories" | "price" | "popularity"; + children: React.ReactNode; +} + +function FilterGroup({ title, storageKey, children }: FilterGroupProps) { + const [collapsed, setCollapsed] = usePersistedState( + `callora.filters.${storageKey}.collapsed`, + false, + ); + + const handleToggle = () => setCollapsed(!collapsed); + + return ( + + + {title} + + + + {children} + + + ); +} + export default function FiltersSidebar({ selectedCategories, toggleCategory, @@ -31,99 +78,88 @@ export default function FiltersSidebar({ }) { return (