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
20 changes: 7 additions & 13 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ img {
user-drag: none;
}

#root {
.app-layout {
display: flex;
flex-direction: row;
align-items: stretch;
Expand All @@ -27,26 +27,26 @@ img {
box-sizing: border-box;
}

#root > div {
.app-layout > div {
border: 1px solid #ccc;
padding: 10px;
box-sizing: border-box;
display: flex;
min-height: 0;
}

#root > #pokemonList {
.app-layout > #pokemonList {
order: 1;
flex: 1;
}

#root > #pokemonCard {
.app-layout > #pokemonCard {
order: 2;
flex: 2;
flex-direction: column;
}

#root > #favoritePokemon {
.app-layout > #favoritePokemon {
order: 3;
flex: 1;
}
Expand Down Expand Up @@ -77,27 +77,21 @@ input {
justify-content: center;
}

#sorting {
}

#favorite {
}

@media (max-width: 1000px) {
body {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox, Safari 18.2+, Chromium 121+ */
overflow: scroll;
}

#root {
.app-layout {
flex-direction: column;
width: 100%;
height: auto;
align-items: stretch;
}

#root > div {
.app-layout > div {
flex: 0 0 auto !important;
width: 100% !important;
height: auto !important;
Expand Down
9 changes: 6 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { getPokemonByID } from "./api/pokemon";
import { PokemonCard } from "./components/PokemonCard/PokemonCard";
import { Favorite } from "./components/Favorite/Favorite";
import { useFavorites } from "./controllers/useFavorites";
import { useFilters } from "./controllers/useFilters";
import { PokemonList } from "./components/PokemonList/PokemonList";
import { FavoritePokemon } from "./components/FavoritePokemon/FavoritePokemon";

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

const query = useQuery({
queryKey: ["pokemon", currentId],
Expand All @@ -34,7 +36,7 @@ export default function App() {
};

return (
<div id="root">
<div className="app-layout">
<div id="pokemonCard">
<div>
<div className="row">
Expand Down Expand Up @@ -63,15 +65,16 @@ export default function App() {
</div>
</div>

{query.isPending && <h1>Loading...</h1>}
{query.isError && <h1>Failed to load Pokemon</h1>}
{query.isPending && <p role="status">Loading...</p>}
{query.isError && <p role="alert">Failed to load Pokemon</p>}
{query.data && <PokemonCard current={query.data} />}
</div>
<div id="pokemonList">
{query.data && (
<PokemonList
current={query.data}
sizeOfList={11}
filters={filters}
onSelectPokemon={setCurrentId}
/>
)}
Expand Down
14 changes: 14 additions & 0 deletions src/components/FavoritePokemon/FavoritePokemon.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

/* Rendered as a <button> so the list is reachable by keyboard */
.favorite-item {
display: flex;
align-items: center;
Expand All @@ -39,13 +40,26 @@
transition: all 0.2s ease;
text-align: left;
font-family: sans-serif;
width: 100%;
box-sizing: border-box;
font-size: inherit;
}

.favorite-item:hover {
background: #f0f0f0;
transform: translateY(-1px);
}

.favorite-item:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}

.favorite-empty {
color: #888;
font-size: 14px;
}

.favorite-sprite {
width: 40px;
height: 40px;
Expand Down
56 changes: 35 additions & 21 deletions src/components/FavoritePokemon/FavoritePokemon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,54 @@ import "./FavoritePokemon.css";
import type { PokemonData } from "../../controllers/pokemonController";

type FavoritePokemonProps = {
pokemonList?: PokemonData[];
currentId?: number;
onSelectPokemon?: (id: number) => void;
pokemonList: PokemonData[];
currentId: number;
onSelectPokemon: (id: number) => void;
};

export function FavoritePokemon({
pokemonList,
currentId,
onSelectPokemon,
}: FavoritePokemonProps) {
pokemonList = pokemonList.sort((a, b) => a.id - b.id);
// Copy before sorting so the caller's array is left alone
const sorted = [...pokemonList].sort((a, b) => a.id - b.id);

if (sorted.length === 0) {
return (
<div className="favorite-container">
<h2>Favorites</h2>
<p className="favorite-empty">No favorites yet.</p>
</div>
);
}

return (
<div className="favorite-container">
<h2>Favorites</h2>
<ul className="favorite-list">
{pokemonList.map((pokemon) => {
{sorted.map((pokemon) => {
const isSelected = pokemon.id === currentId;
return (
<li
onClick={() => onSelectPokemon(pokemon.id)}
key={pokemon.id}
className={`favorite-item ${isSelected ? "selected" : ""}`}
>
<img
src={pokemon.sprites.front_default}
alt={pokemon.name}
className="favorite-sprite"
/>
<div className="favorite-info">
<span className="dex-number">
#{pokemon.id.toString().padStart(3, "0")}
</span>
<span className="pokemon-name">{pokemon.name}</span>
</div>
<li key={pokemon.id}>
<button
type="button"
onClick={() => onSelectPokemon(pokemon.id)}
aria-current={isSelected ? "true" : undefined}
className={`favorite-item ${isSelected ? "selected" : ""}`}
>
<img
src={pokemon.sprites.front_default}
alt={pokemon.name}
className="favorite-sprite"
/>
<div className="favorite-info">
<span className="dex-number">
#{pokemon.id.toString().padStart(3, "0")}
</span>
<span className="pokemon-name">{pokemon.name}</span>
</div>
</button>
</li>
);
})}
Expand Down
13 changes: 12 additions & 1 deletion src/components/PokemonCard/PokemonCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,18 @@
display: block;
}

.abilities strong {
/* Wraps the sprite so it can be cycled with a keyboard */
.sprite-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: inline-block;
}

.sprite-button:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}

.type-badges {
Expand Down
24 changes: 4 additions & 20 deletions src/components/PokemonCard/PokemonCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { PokemonCard } from "./PokemonCard";
import { DUMMY_DATA } from "../../utils/testData";

const mockPokemon = {
name: "pikachu",
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
},
height: 4,
weight: 60,
types: [{ type: { name: "electric" } }],
abilities: [{ ability: { name: "static" }, is_hidden: false }],
stats: [
{ base_stat: 35, stat: { name: "hp" } },
{ base_stat: 55, stat: { name: "attack" } },
{ base_stat: 40, stat: { name: "defense" } },
{ base_stat: 50, stat: { name: "special-attack" } },
{ base_stat: 50, stat: { name: "special-defense" } },
{ base_stat: 90, stat: { name: "speed" } },
],
};
const mockPokemon = DUMMY_DATA[2];

describe("PokemonCard Component", () => {
test("renders pokemon name and general information", () => {
render(<PokemonCard current={mockPokemon} />);

expect(screen.getByText("Pikachu")).toBeDefined();
expect(screen.getByText("electric")).toBeDefined();
expect(screen.getByText("static")).toBeDefined();
// Abilities render as one block of text, so match on a substring
expect(screen.getByText(/static/)).toBeDefined();
expect(screen.getByText("0.4 m")).toBeDefined();
expect(screen.getByText("6 kg")).toBeDefined();
});
Expand Down
43 changes: 23 additions & 20 deletions src/components/PokemonCard/PokemonCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type PokemonCardProps = {
current: PokemonData;
};

const typeBackgroundColor = {
const typeBackgroundColor: Record<string, string> = {
normal: "#9fa19e",
fighting: "#ff7f00",
flying: "#83b8eb",
Expand All @@ -29,7 +29,16 @@ const typeBackgroundColor = {
};

export function PokemonCard({ current: pokemon }: PokemonCardProps) {
const [sprite, setSprite] = useState(nextSprite("", pokemon));
const sprites = spriteList(pokemon);

const [spriteIndex, setSpriteIndex] = useState(0);
const [shownId, setShownId] = useState(pokemon.id);

// Start over at the default sprite when a different pokemon is shown
if (shownId !== pokemon.id) {
setShownId(pokemon.id);
setSpriteIndex(0);
}

const displayName =
pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1);
Expand All @@ -43,18 +52,19 @@ export function PokemonCard({ current: pokemon }: PokemonCardProps) {
speed: "Speed",
};

let total = 0;
const total = pokemon.stats.reduce((sum, stat) => sum + stat.base_stat, 0);

return (
<div className="pokemon-card">
<h1>{displayName}</h1>
<div
onClick={() => {
setSprite(nextSprite(sprite, pokemon));
}}
<button
type="button"
className="sprite-button"
aria-label={`Show next sprite for ${displayName}`}
onClick={() => setSpriteIndex((prev) => (prev + 1) % sprites.length)}
>
<img src={sprite} alt={`${displayName} sprite`} />
</div>
<img src={sprites[spriteIndex]} alt={`${displayName} sprite`} />
</button>

<div className="general-info">
<div className="info-row">
Expand Down Expand Up @@ -93,7 +103,6 @@ export function PokemonCard({ current: pokemon }: PokemonCardProps) {
<tbody>
{pokemon.stats.map((stat) => {
const base = stat.base_stat;
total += base;

const min =
stat.stat.name === "hp"
Expand Down Expand Up @@ -164,20 +173,14 @@ function calcHighMaxStat(base: number): number {
return Math.floor((2 * base + 31 + 252 / 4 + 5) * 1.1);
}

function nextSprite(cur: string, pokemon: PokemonData): string {
const sprites = [
// The sprites to cycle through, skipping the ones the api left empty
function spriteList(pokemon: PokemonData): string[] {
return [
pokemon.sprites.front_default,
pokemon.sprites.back_default,
pokemon.sprites.front_shiny,
pokemon.sprites.back_shiny,
];

if (!cur) {
return sprites[0];
}

// sprite = next in sprites, then wrap if at end
return sprites[(sprites.indexOf(cur) + 1) % sprites.length];
].filter(Boolean);
}

// Gereate a width and color based on how high a base stat value is (scale inspired by https://pokemondb.net/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ exports[`PokemonCard Component > matches snapshot 1`] = `
<h1>
Pikachu
</h1>
<div>
<button
aria-label="Show next sprite for Pikachu"
class="sprite-button"
type="button"
>
<img
alt="Pikachu sprite"
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png"
/>
</div>
</button>
<div
class="general-info"
>
Expand Down Expand Up @@ -67,6 +71,7 @@ exports[`PokemonCard Component > matches snapshot 1`] = `
class="abilities"
>
static
lightning-rod (Hidden)
</span>
</div>
</div>
Expand Down
Loading