Skip to content
Closed
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
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { useFavorites } from "./controllers/useFavorites";
import { useFilters } from "./controllers/useFilters";
import { PokemonList } from "./components/PokemonList/PokemonList";
import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon";
import { Filter } from "./components/Filter/Filter";

export default function App() {
const [currentId, setCurrentId] = useState(1);
const { favoritePokemons, isFavorite, toggleFavorite, isLoadingFavorites } =
useFavorites();
const { filters } = useFilters();
const { filters, setFilter, resetFilters } = useFilters();

const query = useQuery({
queryKey: ["pokemon", currentId],
Expand All @@ -40,7 +41,13 @@ export default function App() {
<div id="pokemonCard">
<div>
<div className="row">
<div id="sort">sorting</div>
<div id="sort">
<Filter
filters={filters}
onChange={setFilter}
onReset={resetFilters}
/>
</div>
<div className="nav">
<button onClick={handlePrevious} disabled={currentId <= 1}>
&larr; Prev
Expand Down Expand Up @@ -75,6 +82,7 @@ export default function App() {
current={query.data}
sizeOfList={11}
filters={filters}
favoritePokemons={favoritePokemons}
onSelectPokemon={setCurrentId}
/>
)}
Expand Down
97 changes: 97 additions & 0 deletions src/components/Filter/Filter.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/* Collapsed by default so the panel does not crowd the header row */
.filter {
position: relative;
text-align: left;
font-family: sans-serif;
font-size: 14px;
}

.filter summary {
cursor: pointer;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
white-space: nowrap;
}

/* Opens on top of the card so the layout below does not shift */
.filter-panel {
position: absolute;
z-index: 2;
top: 100%;
left: 0;
margin-top: 6px;
width: 260px;
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.filter-group {
border: 1px solid #ddd;
border-radius: 6px;
padding: 8px 10px;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}

.filter-group legend {
font-weight: bold;
padding: 0 4px;
}

.filter-group label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}

/* Wraps into as many columns as the panel has room for */
.filter-types {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 2px 6px;
}

.filter-type {
text-transform: capitalize;
}

.filter input {
width: auto;
margin: 0;
cursor: pointer;
}

.filter-reset {
align-self: flex-start;
cursor: pointer;
padding: 4px 8px;
}

.filter summary:focus-visible,
.filter input:focus-visible,
.filter-reset:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}

/* On a narrow screen the panel fills the width instead of floating */
@media (max-width: 1000px) {
.filter-panel {
position: static;
width: auto;
max-height: none;
}
}
89 changes: 89 additions & 0 deletions src/components/Filter/Filter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { Filter } from "./Filter";
import type { FilterRules } from "../../models/filters";

function setup(filters: FilterRules = {}) {
const onChange = vi.fn();
const onReset = vi.fn();

render(<Filter filters={filters} onChange={onChange} onReset={onReset} />);

return { onChange, onReset };
}

describe("Filter Component", () => {
test("shows the rules it is given as props", () => {
setup({ sort: "id-desc", type: ["fire"], onlyFavorites: true });

expect(screen.getByLabelText("Highest id first")).toBeChecked();
expect(screen.getByLabelText("fire")).toBeChecked();
expect(screen.getByLabelText("Only show favorites")).toBeChecked();
expect(screen.getByLabelText("water")).not.toBeChecked();
});

test("defaults to lowest id first when no sort is stored", () => {
setup();

expect(screen.getByLabelText("Lowest id first")).toBeChecked();
expect(screen.getByLabelText("Highest id first")).not.toBeChecked();
});

test("counts the active rules in the summary", () => {
setup({ sort: "id-desc", type: ["fire", "water"], onlyFavorites: true });

expect(screen.getByText("Filters (4)")).toBeInTheDocument();
});

test("reports a picked type without dropping the ones already picked", () => {
const { onChange } = setup({ type: ["fire"] });

fireEvent.click(screen.getByLabelText("water"));

expect(onChange).toHaveBeenCalledWith({ type: ["fire", "water"] });
});

test("removes a type that is picked again", () => {
const { onChange } = setup({ type: ["fire", "water"] });

fireEvent.click(screen.getByLabelText("fire"));

expect(onChange).toHaveBeenCalledWith({ type: ["water"] });
});

test("reports a change of sort order", () => {
const { onChange } = setup();

fireEvent.click(screen.getByLabelText("Highest id first"));

expect(onChange).toHaveBeenCalledWith({ sort: "id-desc" });
});

test("reports the favorites toggle", () => {
const { onChange } = setup();

fireEvent.click(screen.getByLabelText("Only show favorites"));

expect(onChange).toHaveBeenCalledWith({ onlyFavorites: true });
});

test("calls onReset from the reset button", () => {
const { onReset } = setup({ type: ["fire"] });

fireEvent.click(screen.getByRole("button", { name: "Reset filters" }));

expect(onReset).toHaveBeenCalledTimes(1);
});

test("matches snapshot", () => {
const { asFragment } = render(
<Filter
filters={{ type: ["fire"] }}
onChange={vi.fn()}
onReset={vi.fn()}
/>,
);

expect(asFragment()).toMatchSnapshot();
});
});
90 changes: 90 additions & 0 deletions src/components/Filter/Filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import "./Filter.css";
import { POKEMON_TYPES } from "../../models/filters";
import type { FilterRules } from "../../models/filters";

type FilterProps = {
filters: FilterRules;
onChange: (changes: Partial<FilterRules>) => void;
onReset: () => void;
};

export function Filter({ filters, onChange, onReset }: FilterProps) {
const selectedTypes = filters.type ?? [];

// Adds the type if it is not picked yet, removes it if it is
const toggleType = (type: string) => {
const next = selectedTypes.includes(type)
? selectedTypes.filter((selected) => selected !== type)
: [...selectedTypes, type];

onChange({ type: next });
};

const activeCount =
selectedTypes.length +
(filters.onlyFavorites ? 1 : 0) +
(filters.sort === "id-desc" ? 1 : 0);

return (
<details className="filter">
<summary>Filters{activeCount > 0 ? ` (${activeCount})` : ""}</summary>
<div className="filter-panel">
<fieldset className="filter-group">
<legend>Sort</legend>
<label>
<input
type="radio"
name="sort"
value="id-asc"
checked={filters.sort !== "id-desc"}
onChange={() => onChange({ sort: "id-asc" })}
/>
Lowest id first
</label>
<label>
<input
type="radio"
name="sort"
value="id-desc"
checked={filters.sort === "id-desc"}
onChange={() => onChange({ sort: "id-desc" })}
/>
Highest id first
</label>
</fieldset>

<fieldset className="filter-group">
<legend>Type</legend>
<div className="filter-types">
{POKEMON_TYPES.map((type) => (
<label key={type} className="filter-type">
<input
type="checkbox"
checked={selectedTypes.includes(type)}
onChange={() => toggleType(type)}
/>
{type}
</label>
))}
</div>
</fieldset>

<fieldset className="filter-group">
<legend>Favorites</legend>
<label>
<input
type="checkbox"
checked={filters.onlyFavorites === true}
onChange={(e) => onChange({ onlyFavorites: e.target.checked })}
/>
Only show favorites
</label>
</fieldset>

<button type="button" className="filter-reset" onClick={onReset}>
Reset filters
</button>
</div>
</details>
);
}
Loading