Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/filters-collapse-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Polish FiltersSidebar with Collapse Persistence

## Summary

This PR implements collapsible filter sections in `FiltersSidebar` with localStorage persistence to remember collapsed states across page visits. Users can now collapse/expand each filter group (Categories, Price range, Popularity) independently, and their preferences are saved for future sessions.

## Changes

### `src/components/FiltersSidebar.tsx`
- Refactored filter sections into a reusable `FilterGroup` component with collapsible behavior
- Each filter group now has an expandable/collapsible header with a chevron indicator
- Uses `usePersistedState` hook to persist collapsed state in localStorage
- Implements proper ARIA attributes (`aria-expanded`, `aria-controls`) for accessibility
- Uses `hidden` attribute to hide collapsed content from screen readers

### `src/components/icons/ChevronIcon.tsx` (new)
- New icon component for the expand/collapse chevron indicator
- Follows the existing icon pattern in the codebase (size variants, accessibility)

### `src/components/icons/index.tsx`
- Added export for the new `ChevronIcon` component

### `src/index.css`
- Added styles for `.filter-group__header`, `.filter-group__chevron`, and `.filter-group__panel`
- Includes hover and focus-visible states matching design-token patterns
- Reduced motion support via `prefers-reduced-motion` media query
- Responsive adjustments for smaller viewports (max-width: 640px)

### `src/state/uiPrefs.ts`
- Added `isSectionCollapsed()`, `toggleSectionCollapsed()`, and `setSectionCollapsed()` functions for collapsed section state management

### Test Files
- `src/components/FiltersSidebar.test.tsx` - New comprehensive tests for collapse functionality
- `src/state/uiPrefs.test.ts` - Tests for the uiPrefs collapse state functions

## Accessibility (WCAG 2.1 AA)

- `aria-expanded` indicates the current state of each filter group
- `aria-controls` associates headers with their content panels
- `hidden` attribute removes collapsed content from assistive technology
- Focus styles use `var(--accent)` with proper contrast against both light and dark backgrounds
- `prefers-reduced-motion` support for users who request reduced motion

## Responsive Design

The filter headers adapt to smaller screens:
- Reduced padding (10px 14px) on screens under 640px
- Smaller font size (0.9rem) on mobile viewports
- All breakpoints maintain proper touch target sizes (minimum 44px height)

## API/Visible Changes

### Component API (no breaking changes)
`FiltersSidebar` maintains the same external props interface - this is a purely visual enhancement.

### localStorage Keys
- `callora.filters.categories.collapsed` - Categories section state (boolean)
- `callora.filters.price.collapsed` - Price range section state (boolean)
- `callora.filters.popularity.collapsed` - Popularity section state (boolean)

## Test Output

```
✓ src/components/FiltersSidebar.test.tsx (16 tests) 966ms
✓ src/state/uiPrefs.test.ts (9 tests) 11ms
```

All tests pass including:
- Default expanded state verification
- localStorage persistence on toggle
- State restoration on re-render
- aria-expanded and aria-controls attribute correctness
- Independent collapse/expand of each section
- Chevron rotation animation
- Price validation error handling

## Notes

The existing tests in the repository have pre-existing issues unrelated to this change:
- `FiltersBottomSheet.test.tsx` - missing `window.matchMedia` mock in jsdom
- `Tabs.test.tsx` - same matchMedia issue
- `ApiDetailPage.tsx`, `ApiUsage.tsx`, `ApiCard.tsx` - JSX syntax errors in existing code

These do not affect the functionality of the changes in this PR.

PR: https://github.com/CalloraOrg/Callora-Frontend/pull/337

closes #254
168 changes: 168 additions & 0 deletions src/components/FiltersSidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(),
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(<FiltersSidebar {...baseProps} />);
expect(screen.getByText("Categories")).toBeTruthy();
expect(screen.getByText("Price range")).toBeTruthy();
expect(screen.getByText("Popularity")).toBeTruthy();
});

it("renders category checkboxes", () => {
render(<FiltersSidebar {...baseProps} />);
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(<FiltersSidebar {...baseProps} toggleCategory={toggleCategory} />);
fireEvent.click(screen.getByLabelText("Data & Analytics"));
expect(toggleCategory).toHaveBeenCalledWith("Data & Analytics");
});

describe("collapse functionality", () => {
it("expands panel by default when no persisted state", () => {
render(<FiltersSidebar {...baseProps} />);
const categoriesPanel = screen.getByTestId("filter-panel-categories");
expect(categoriesPanel.hasAttribute("hidden")).toBe(false);
});

it("persists collapsed state on toggle", () => {
const { unmount } = render(<FiltersSidebar {...baseProps} />);

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(<FiltersSidebar {...baseProps} />);
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(<FiltersSidebar {...baseProps} />);
const popularityPanel = screen.getByTestId("filter-panel-popularity");
expect(popularityPanel.hasAttribute("hidden")).toBe(false);
});

it("sets aria-expanded to true when panel is expanded", () => {
render(<FiltersSidebar {...baseProps} />);
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(<FiltersSidebar {...baseProps} />);
const header = screen.getByRole("button", { name: "Categories" });
expect(header.getAttribute("aria-expanded")).toBe("false");
});

it("has correct aria-controls attribute on header", () => {
render(<FiltersSidebar {...baseProps} />);
const header = screen.getByRole("button", { name: "Categories" });
expect(header.getAttribute("aria-controls")).toBe("filter-panel-categories");
});

it("collapses all sections independently", () => {
render(<FiltersSidebar {...baseProps} />);

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(<FiltersSidebar {...baseProps} />);
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(<FiltersSidebar {...baseProps} clearFilters={clearFilters} />);
fireEvent.click(screen.getByText("Clear filters"));
expect(clearFilters).toHaveBeenCalledTimes(1);
});
});

describe("price validation", () => {
it("shows error when min price exceeds max price", () => {
render(
<FiltersSidebar
{...baseProps}
minPrice={100}
maxPrice={50}
/>
);
expect(screen.getByText(/Min price cannot exceed max price/i)).toBeTruthy();
});

it("does not show error when prices are valid", () => {
render(
<FiltersSidebar
{...baseProps}
minPrice={50}
maxPrice={100}
/>
);
expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull();
});

it("does not show error when both prices are null", () => {
render(<FiltersSidebar {...baseProps} />);
expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull();
});
});
});
Loading