From d332f1b2efe9445349b09df7d104e616d4de520a Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 17 Sep 2026 10:19:39 +0200 Subject: [PATCH 1/6] feat: add page/limit pagination to the breeds endpoint --- t29-project-1/server/catApi.js | 19 +++++++++++++++++-- t29-project-1/server/catApi.test.js | 23 ++++++++++++++++++++++- t29-project-1/server/index.js | 7 ++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/t29-project-1/server/catApi.js b/t29-project-1/server/catApi.js index acb17d9..6ca1d4b 100644 --- a/t29-project-1/server/catApi.js +++ b/t29-project-1/server/catApi.js @@ -49,8 +49,23 @@ export async function fetchCatFromApi(breedIds) { return data[0] || {}; } -export async function fetchBreedsFromApi() { +const DEFAULT_BREEDS_PAGE = 0; +const DEFAULT_BREEDS_LIMIT = 10; + +function parseNonNegativeInt(value, fallback) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + +export async function fetchBreedsFromApi({ page, limit } = {}) { const breedsUrl = new URL(CAT_BREEDS_URL); - breedsUrl.searchParams.set('limit', '10'); + breedsUrl.searchParams.set( + 'limit', + String(parseNonNegativeInt(limit, DEFAULT_BREEDS_LIMIT)) + ); + breedsUrl.searchParams.set( + 'page', + String(parseNonNegativeInt(page, DEFAULT_BREEDS_PAGE)) + ); return fetchApiResponse(breedsUrl); } diff --git a/t29-project-1/server/catApi.test.js b/t29-project-1/server/catApi.test.js index 7a6cde7..0413344 100644 --- a/t29-project-1/server/catApi.test.js +++ b/t29-project-1/server/catApi.test.js @@ -70,7 +70,7 @@ describe('fetchCatFromApi', () => { }); describe('fetchBreedsFromApi', () => { - it('requests up to ten breeds', async () => { + it('defaults to the first page of ten breeds', async () => { const breeds = [{ id: 'abys', name: 'Abyssinian' }]; fetchMock.mockResolvedValue(mockResponse(breeds)); @@ -79,5 +79,26 @@ describe('fetchBreedsFromApi', () => { const requestUrl = new URL(String(fetchMock.mock.calls[0][0])); expect(requestUrl.pathname).toBe('/v1/breeds'); expect(requestUrl.searchParams.get('limit')).toBe('10'); + expect(requestUrl.searchParams.get('page')).toBe('0'); + }); + + it('requests the given page and limit', async () => { + fetchMock.mockResolvedValue(mockResponse([])); + + await fetchBreedsFromApi({ page: 2, limit: 5 }); + + const requestUrl = new URL(String(fetchMock.mock.calls[0][0])); + expect(requestUrl.searchParams.get('limit')).toBe('5'); + expect(requestUrl.searchParams.get('page')).toBe('2'); + }); + + it('falls back to the defaults for invalid page or limit', async () => { + fetchMock.mockResolvedValue(mockResponse([])); + + await fetchBreedsFromApi({ page: 'not-a-number', limit: -1 }); + + const requestUrl = new URL(String(fetchMock.mock.calls[0][0])); + expect(requestUrl.searchParams.get('limit')).toBe('10'); + expect(requestUrl.searchParams.get('page')).toBe('0'); }); }); diff --git a/t29-project-1/server/index.js b/t29-project-1/server/index.js index 7064f98..382cb89 100644 --- a/t29-project-1/server/index.js +++ b/t29-project-1/server/index.js @@ -10,7 +10,12 @@ app.use(cors()); app.get('/api/breeds', async (req, res) => { try { - res.json(await fetchBreedsFromApi()); + res.json( + await fetchBreedsFromApi({ + page: req.query.page, + limit: req.query.limit, + }) + ); } catch (err) { console.error(err); res.status(err.statusCode || 500).send(err.message); From fdfe49a41bedd07298edae000f3681d4e09c392e Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 17 Sep 2026 10:19:59 +0200 Subject: [PATCH 2/6] feat: add paginated fetchBreeds to the cat API client --- t29-project-1/src/api/catApi.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/t29-project-1/src/api/catApi.ts b/t29-project-1/src/api/catApi.ts index 72e79bd..796c352 100644 --- a/t29-project-1/src/api/catApi.ts +++ b/t29-project-1/src/api/catApi.ts @@ -9,6 +9,11 @@ export type CatFilters = { breedId?: string; }; +export type Breed = { + id: string; + name: string; +}; + export async function fetchCat({ breedId }: CatFilters = {}): Promise { const API_BASE = (import.meta.env.VITE_API_BASE as string) || 'http://localhost:3001'; @@ -28,3 +33,30 @@ export async function fetchCat({ breedId }: CatFilters = {}): Promise { return response.json() as Promise; } + +export type BreedsPage = { + page?: number; + limit?: number; +}; + +export async function fetchBreeds({ page, limit }: BreedsPage = {}): Promise< + Breed[] +> { + const API_BASE = + (import.meta.env.VITE_API_BASE as string) || 'http://localhost:3001'; + const params = new URLSearchParams(); + + if (page !== undefined) params.set('page', String(page)); + if (limit !== undefined) params.set('limit', String(limit)); + + const query = params.toString(); + const response = await fetch( + `${API_BASE}/api/breeds${query ? `?${query}` : ''}` + ); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + return response.json() as Promise; +} From f54ed28a9ab4c1d0e2745108c9947bee9585919d Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 17 Sep 2026 10:20:42 +0200 Subject: [PATCH 3/6] feat: build custom breed dropdown --- t29-project-1/src/App.tsx | 9 +- .../src/components/BreedDropdown.css | 107 ++++++++++ .../src/components/BreedDropdown.tsx | 199 ++++++++++++++++++ t29-project-1/src/components/CatCard.css | 2 +- t29-project-1/src/index.css | 2 + 5 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 t29-project-1/src/components/BreedDropdown.css create mode 100644 t29-project-1/src/components/BreedDropdown.tsx diff --git a/t29-project-1/src/App.tsx b/t29-project-1/src/App.tsx index 54f1f06..6d40c0e 100644 --- a/t29-project-1/src/App.tsx +++ b/t29-project-1/src/App.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; import { NavPrevious, NavNext } from './components/ActionButtons'; +import { BreedDropdown } from './components/BreedDropdown'; import { CatCard } from './components/CatCard'; import { Favorites } from './components/Favorites'; import './App.css'; export default function App() { const [isViewingFavorites, setIsViewingFavorites] = useState(false); + const [selectedBreedId, setSelectedBreedId] = useState(null); const handlePrevious = () => {}; const handleNext = () => {}; @@ -20,6 +22,11 @@ export default function App() { /> + + {/* 3-Column Navigation Grid */} ); -} \ No newline at end of file +} diff --git a/t29-project-1/src/components/BreedDropdown.css b/t29-project-1/src/components/BreedDropdown.css new file mode 100644 index 0000000..83830ea --- /dev/null +++ b/t29-project-1/src/components/BreedDropdown.css @@ -0,0 +1,107 @@ +/* Breed filter dropdown, sized relative to the CatCard's own max-width so + both scale together and collapse to full-width on small screens */ +.breed-dropdown { + position: relative; + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 100%; + max-width: calc(var(--card-max-width) * 0.75); + margin: 0 auto; +} + +.breed-dropdown-label { + color: var(--text); +} + +.breed-dropdown-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.5rem 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 10px; + background-color: #ffffff; + color: var(--text); + font: inherit; + font-weight: bold; + text-align: left; + cursor: pointer; +} + +.breed-dropdown-trigger:hover { + border-color: #cbd5e1; +} + +.breed-dropdown-trigger:focus-visible { + outline: 3px solid #005fcc; + outline-offset: 2px; +} + +.breed-dropdown-trigger:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.breed-dropdown-arrow { + width: 8px; + height: 8px; + margin-left: 0.5rem; + flex-shrink: 0; + border-right: 2px solid #7d5d75; + border-bottom: 2px solid #7d5d75; + transform: rotate(45deg); +} + +/* Always opens downward, anchored to the trigger, regardless of which + option is selected */ +.breed-dropdown-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + margin: 0.25rem 0 0; + padding: 0.25rem; + list-style: none; + max-height: 260px; + overflow-y: auto; + background-color: #ffffff; + border: 2px solid #e2e8f0; + border-radius: 10px; + box-shadow: var(--shadow); +} + +.breed-dropdown-list:focus-visible { + outline: none; +} + +.breed-dropdown-option { + padding: 0.4rem 0.6rem; + border-radius: 6px; + color: var(--text); + cursor: pointer; +} + +.breed-dropdown-option.is-highlighted { + background-color: #f1f5f9; +} + +.breed-dropdown-option.is-selected { + color: #7d5d75; +} + +.breed-dropdown-option.is-load-more { + margin-top: 0.25rem; + border-top: 1px solid #e2e8f0; + border-radius: 0; + color: #7d5d75; + text-align: center; +} + +.breed-dropdown-error { + margin: 0; + font-size: 0.85rem; + color: red; +} diff --git a/t29-project-1/src/components/BreedDropdown.tsx b/t29-project-1/src/components/BreedDropdown.tsx new file mode 100644 index 0000000..4ffcef5 --- /dev/null +++ b/t29-project-1/src/components/BreedDropdown.tsx @@ -0,0 +1,199 @@ +import type { KeyboardEvent } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { fetchBreeds, type Breed } from '../api/catApi'; +import './BreedDropdown.css'; + +interface BreedDropdownProps { + selectedBreedId: string | null; + onSelectBreed: (breedId: string | null) => void; +} + +const ALL_BREEDS_OPTION: Breed = { id: '', name: 'All breeds' }; +const LOAD_MORE_OPTION_ID = '__load_more__'; +const PAGE_SIZE = 10; + +export const BreedDropdown = ({ + selectedBreedId, + onSelectBreed, +}: BreedDropdownProps) => { + const { + data, + error, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['breeds'], + queryFn: ({ pageParam }) => + fetchBreeds({ page: pageParam, limit: PAGE_SIZE }), + initialPageParam: 0, + // A full page suggests there may be more; a short page means we've + // reached the end. The API doesn't return a total count to check against. + getNextPageParam: (lastPage, allPages) => + lastPage.length === PAGE_SIZE ? allPages.length : undefined, + }); + + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const listRef = useRef(null); + + const fetchedBreeds = data?.pages.flat() ?? []; + + // Shaped like a Breed so it can sit inside `options` and ride along with + // the same arrow-key/Enter/click handling real breeds use, instead of + // needing separate logic to reach and activate it. + const loadMoreOption: Breed = { + id: LOAD_MORE_OPTION_ID, + name: isFetchingNextPage ? 'Loading…' : 'Load more breeds…', + }; + + const options: Breed[] = [ + ALL_BREEDS_OPTION, + ...fetchedBreeds, + ...(hasNextPage ? [loadMoreOption] : []), + ]; + const isSelected = (option: Breed) => option.id === (selectedBreedId ?? ''); + const selectedOption = options.find(isSelected) ?? ALL_BREEDS_OPTION; + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + useEffect(() => { + if (isOpen) listRef.current?.focus(); + }, [isOpen]); + + const openDropdown = () => { + const selectedIndex = options.findIndex(isSelected); + setHighlightedIndex(selectedIndex === -1 ? 0 : selectedIndex); + setIsOpen(true); + }; + + const closeDropdown = () => { + setIsOpen(false); + triggerRef.current?.focus(); + }; + + const selectOption = (option: Breed) => { + if (option.id === LOAD_MORE_OPTION_ID) { + if (!isFetchingNextPage) void fetchNextPage(); + return; + } + + onSelectBreed(option.id || null); + closeDropdown(); + }; + + const handleTriggerKeyDown = (event: KeyboardEvent) => { + if ( + event.key === 'ArrowDown' || + event.key === 'Enter' || + event.key === ' ' + ) { + event.preventDefault(); + openDropdown(); + } + }; + + const handleListKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + setHighlightedIndex((index) => Math.min(index + 1, options.length - 1)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setHighlightedIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + selectOption(options[highlightedIndex]); + } else if (event.key === 'Escape') { + event.preventDefault(); + closeDropdown(); + } else if (event.key === 'Tab') { + setIsOpen(false); + } + }; + + const highlightedOption = options[highlightedIndex]; + + return ( +
+ + Filter by breed + + + + {isOpen && ( + // The list itself holds real DOM focus (tabIndex={-1} + .focus() on + // open); aria-activedescendant tells screen readers which option is + // "virtually" focused, so arrow keys can move the highlight without + // moving actual DOM focus between list items. +
    + {options.map((option, index) => { + const isLoadMore = option.id === LOAD_MORE_OPTION_ID; + + return ( +
  • setHighlightedIndex(index)} + onClick={() => selectOption(option)} + > + {option.name} +
  • + ); + })} +
+ )} + + {error &&

Could not load breeds.

} +
+ ); +}; diff --git a/t29-project-1/src/components/CatCard.css b/t29-project-1/src/components/CatCard.css index 33bd049..334a27c 100644 --- a/t29-project-1/src/components/CatCard.css +++ b/t29-project-1/src/components/CatCard.css @@ -1,7 +1,7 @@ /* Card container using semantic article tag */ article.cat-card { width: 100%; - max-width: 420px; + max-width: var(--card-max-width); min-height: 480px; background-color: #ffffff; border: 2px solid #e2e8f0; diff --git a/t29-project-1/src/index.css b/t29-project-1/src/index.css index 5fb3313..d86a781 100644 --- a/t29-project-1/src/index.css +++ b/t29-project-1/src/index.css @@ -11,6 +11,8 @@ --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --card-max-width: 420px; + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; From 1b1b5a318fdd71967092caa05d9b6317744bb353 Mon Sep 17 00:00:00 2001 From: Anettkva Date: Thu, 17 Sep 2026 10:20:57 +0200 Subject: [PATCH 4/6] docs: document the breed dropdown and paginated breeds endpoint --- t29-project-1/src/docs/BREED_DROPDOWN.md | 75 ++++++++++++++++++++++++ t29-project-1/src/docs/CAT_API.md | 12 +++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 t29-project-1/src/docs/BREED_DROPDOWN.md diff --git a/t29-project-1/src/docs/BREED_DROPDOWN.md b/t29-project-1/src/docs/BREED_DROPDOWN.md new file mode 100644 index 0000000..e436916 --- /dev/null +++ b/t29-project-1/src/docs/BREED_DROPDOWN.md @@ -0,0 +1,75 @@ +# Breed dropdown guide + +This document explains `BreedDropdown`, the component for filtering cats by +breed (US2). + +## What it is + +`BreedDropdown` is a fully custom dropdown, not a native `` elements let the browser/OS +decide where the option list appears, which is inconsistent across browsers. + +## Props + +```ts +interface BreedDropdownProps { + selectedBreedId: string | null; + onSelectBreed: (breedId: string | null) => void; +} +``` + +It is a controlled component: it holds no persisted state itself. The parent +owns the current selection and passes it in, the same way a native `