From 9aa462c2863e5a007c151fa5aad23a8926b8c307 Mon Sep 17 00:00:00 2001 From: KC Ng Date: Sun, 24 Aug 2025 17:58:01 +0900 Subject: [PATCH 1/5] feat: (WIP) add in select drop-down and navigation buttons for pagination --- package-lock.json | 17 ++++++ package.json | 3 +- src/components/CardList.astro | 31 ++++++++++ src/pages/index.astro | 2 + src/styles/global.css | 107 +++++++++++++++++++++++++++------- 5 files changed, 138 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8131e1ec..6acec090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@astrojs/check": "^0.9.2", "astro": "^4.16.18", "bootstrap": "^5.3.3", + "bootstrap-icons": "^1.13.1", "typescript": "^5.5.4" }, "devDependencies": { @@ -3203,6 +3204,22 @@ "@popperjs/core": "^2.11.8" } }, + "node_modules/bootstrap-icons": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", + "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, "node_modules/boxen": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", diff --git a/package.json b/package.json index f7e30227..08d5b73d 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,14 @@ "preview": "astro preview", "prepare": "husky", "test": "vitest --run", - "test:coverage": "vitest run --coverage", - "lint": "prettier --write \"**/*.{js,jsx,ts,tsx,astro}\" && eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\"" }, "dependencies": { "@astrojs/check": "^0.9.2", "astro": "^4.16.18", "bootstrap": "^5.3.3", + "bootstrap-icons": "^1.13.1", "typescript": "^5.5.4" }, "devDependencies": { diff --git a/src/components/CardList.astro b/src/components/CardList.astro index 2aa5d877..7136f0ac 100644 --- a/src/components/CardList.astro +++ b/src/components/CardList.astro @@ -4,6 +4,24 @@ import type { CardProps } from "../types" const { terms: cards } = Astro.props --- +
+
+ +
+ + + + +
+
{ cards.map((card: CardProps) => ( @@ -22,3 +40,16 @@ const { terms: cards } = Astro.props )) }
+ + diff --git a/src/pages/index.astro b/src/pages/index.astro index 73e38718..5249893e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,4 +1,6 @@ --- +import "bootstrap-icons/font/bootstrap-icons.css" + import BaseLayout from "../layouts/BaseLayout.astro" import Hero from "../components/Hero.astro" import CardList from "../components/CardList.astro" diff --git a/src/styles/global.css b/src/styles/global.css index 3816abda..3ec287bb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -15,7 +15,7 @@ select { /* A reset of styles, including removing the default dropdown arrow */ appearance: none; - /* Additional resets for further consistency */ + /* Additional resets for further consistency */ background-color: transparent; border: none; padding: 0 1em 0 0; @@ -54,10 +54,10 @@ select, @font-face { font-display: swap; - font-family: 'JetBrains Mono'; + font-family: "JetBrains Mono"; font-style: normal; font-weight: 400; - src: url('/fonts/jetbrains-mono-v18-latin-regular.woff2') format('woff2'); + src: url("/fonts/jetbrains-mono-v18-latin-regular.woff2") format("woff2"); } img { @@ -68,7 +68,7 @@ img { body { background-color: #fcfcfc; color: #000000; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; margin: 0; padding: 0; } @@ -157,7 +157,7 @@ body { #autocomplete-list { max-height: 200px; /* Ensure there's a maximum height */ - overflow-y: auto; /* Enable vertical scrolling */ + overflow-y: auto; /* Enable vertical scrolling */ scrollbar-width: thin; /* Firefox */ position: absolute; } @@ -231,7 +231,7 @@ body { border-color: black; color: #000000; padding: 15px 30px; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; cursor: pointer; text-align: center; border-radius: 5px; @@ -294,7 +294,9 @@ body.dark-mode .explain-button:hover { box-shadow: 5px 5px rgb(237, 237, 237); } -body.dark-mode .search-bar input, body.dark-mode #category-select { +body.dark-mode .search-bar input, +body.dark-mode #category-select, +body.dark-mode #pagination-select { background-color: #444444; color: #ffffff !important; border-color: #d2d2d2; @@ -304,11 +306,21 @@ body.dark-mode .search-bar i { color: #cccccc; } +body.dark-mode #pagination-select { + background: url("data:image/svg+xml,") + no-repeat; + background-position: calc(100% - 1.2rem) center !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; + appearance: none !important; +} + body.dark-mode #category-select { - background: url("data:image/svg+xml,") no-repeat; + background: url("data:image/svg+xml,") + no-repeat; background-position: calc(100% - 1.2rem) center !important; - -moz-appearance:none !important; - -webkit-appearance: none !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; appearance: none !important; padding-right: 2rem !important; } @@ -340,17 +352,20 @@ body.dark-mode input[type="text"]::placeholder { gap: 20px; } -.search-bar, .category-filter { +.search-bar, +.category-filter, +.pagination-filter { position: relative; display: inline-block; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; } -.search-bar input, #category-select { +.search-bar input, +#category-select { font-size: 1em; border: 1px solid #000000; border-radius: 35px; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; } .search-bar input { @@ -372,10 +387,11 @@ body.dark-mode input[type="text"]::placeholder { text-align: center; color: #555; - background: url("data:image/svg+xml,") no-repeat; + background: url("data:image/svg+xml,") + no-repeat; background-position: calc(100% - 1.2rem) center !important; - -moz-appearance:none !important; - -webkit-appearance: none !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; appearance: none !important; padding-right: 2rem !important; } @@ -422,12 +438,63 @@ body.dark-mode select option { margin-bottom: 20px; } +.pagination-bar { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + align-items: center; + padding: 20px; +} + +#pagination-select { + font-size: 1em; + border: 1px solid #000000; + font-family: "JetBrains Mono", monospace; + padding: 10px; + width: 75px; + border-radius: 35px; + text-align: left; + color: #555; + background: url("data:image/svg+xml,") + no-repeat; + background-position: calc(100% - 1.2rem) center !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; + appearance: none !important; +} + +.navigation-button { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + width: 40px; + height: 40px; + border: none; + background: transparent; + cursor: pointer; +} + +.navigation-button i { + font-size: 2rem; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.navigation-button:hover i { + transform: scale(1.1); + transition: transform 0.2s ease; +} + .explain-button { background-color: #000; color: #fff; padding: 18px; border-radius: 5px; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; cursor: pointer; width: 85%; transition: all 0.3s linear; @@ -462,7 +529,7 @@ body.dark-mode select option { border-radius: 10px; width: 80%; max-width: 600px; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; overflow-y: auto; max-height: 70vh; } @@ -499,7 +566,7 @@ body.modal-open { border-radius: 5px; font-size: 15px; text-align: center; - font-family: 'JetBrains Mono', monospace; + font-family: "JetBrains Mono", monospace; cursor: pointer; } From 229c2a9389ac2df652ce71bf912aed45ca0e8e8d Mon Sep 17 00:00:00 2001 From: KC Ng Date: Mon, 25 Aug 2025 16:07:42 +0900 Subject: [PATCH 2/5] feat: include pagination logic (WIP) --- package-lock.json | 21 +++ package.json | 1 + src/components/CardList.astro | 35 +++-- src/components/CardList.test.ts | 121 +++++++++++++- src/scripts/paginate.test.ts | 271 ++++++++++++++++++++++++++++++++ src/scripts/paginate.ts | 109 +++++++++++++ 6 files changed, 543 insertions(+), 15 deletions(-) create mode 100644 src/scripts/paginate.test.ts create mode 100644 src/scripts/paginate.ts diff --git a/package-lock.json b/package-lock.json index 6acec090..65f93d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@commitlint/config-conventional": "^19.5.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.11.1", + "@types/jsdom": "^21.1.7", "@typescript-eslint/parser": "^8.8.0", "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.11.1", @@ -2309,6 +2310,18 @@ "@types/unist": "*" } }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2345,6 +2358,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6539,6 +6559,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, + "license": "MIT", "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", diff --git a/package.json b/package.json index 08d5b73d..41c67186 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@commitlint/config-conventional": "^19.5.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.11.1", + "@types/jsdom": "^21.1.7", "@typescript-eslint/parser": "^8.8.0", "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.11.1", diff --git a/src/components/CardList.astro b/src/components/CardList.astro index 7136f0ac..9b2896a1 100644 --- a/src/components/CardList.astro +++ b/src/components/CardList.astro @@ -2,14 +2,25 @@ import type { CardProps } from "../types" const { terms: cards } = Astro.props + +const paginateOptions = [ + { label: "6", value: 6 }, + { label: "12", value: 12 }, + { label: "24", value: 24 }, + { label: "48", value: 48 } +] ---
@@ -41,15 +52,11 @@ const { terms: cards } = Astro.props }
- + diff --git a/src/components/CardList.test.ts b/src/components/CardList.test.ts index 82ed2eaf..a32270be 100644 --- a/src/components/CardList.test.ts +++ b/src/components/CardList.test.ts @@ -1,5 +1,5 @@ import { experimental_AstroContainer as AstroContainer } from "astro/container" -import { expect, test } from "vitest" +import { expect, test, describe, beforeEach, vi } from "vitest" import CardList from "./CardList.astro" describe("component UI display", () => { @@ -78,3 +78,122 @@ describe("component UI display", () => { }) }) +describe("pagination functionality", () => { + let container: AstroContainer + + beforeEach(async () => { + container = await AstroContainer.create() + }) + + test("renders pagination controls", async () => { + const mockCards = Array.from({ length: 10 }, (_, i) => ({ + data: { + title: `Term ${i + 1}`, + subtext: `Subtext ${i + 1}`, + categories: ["Test"], + author: "Test Author", + description: { + title: `Term ${i + 1}`, + texts: [`Description for term ${i + 1}`], + image: "", + references: ["https://example.com"] + } + } + })) + + const result = await container.renderToString(CardList, { + props: { terms: mockCards } + }) + + // Check pagination controls are rendered + expect(result).toContain('id="pagination-select"') + expect(result).toContain('id="prev-button"') + expect(result).toContain('id="next-button"') + expect(result).toContain('class="pagination-bar"') + }) + + test("renders pagination dropdown options", async () => { + const mockCards = [{ + data: { + title: "Test Term", + subtext: "Test Subtext", + categories: ["Test"], + author: "Test Author", + description: { + title: "Test Term", + texts: ["Test description"], + image: "", + references: ["https://example.com"] + } + } + }] + + const result = await container.renderToString(CardList, { + props: { terms: mockCards } + }) + + // Check all pagination options are present + expect(result).toContain('value="6"') + expect(result).toContain('value="12"') + expect(result).toContain('value="24"') + expect(result).toContain('value="48"') + expect(result).toContain('id="paginate-6"') + expect(result).toContain('id="paginate-12"') + expect(result).toContain('id="paginate-24"') + expect(result).toContain('id="paginate-48"') + }) + + test("renders all cards in container", async () => { + const mockCards = Array.from({ length: 8 }, (_, i) => ({ + data: { + title: `Card ${i + 1}`, + subtext: `Subtext ${i + 1}`, + categories: ["Test"], + author: "Test Author", + description: { + title: `Card ${i + 1}`, + texts: [`Description ${i + 1}`], + image: "", + references: ["https://example.com"] + } + } + })) + + const result = await container.renderToString(CardList, { + props: { terms: mockCards } + }) + + // Check that all cards are rendered (pagination logic happens in browser) + for (let i = 1; i <= 8; i++) { + expect(result).toContain(`Card ${i}`) + } + expect(result).toContain('id="cardContainer"') + }) + + test("renders navigation buttons with correct icons", async () => { + const mockCards = [{ + data: { + title: "Test Term", + subtext: "Test Subtext", + categories: ["Test"], + author: "Test Author", + description: { + title: "Test Term", + texts: ["Test description"], + image: "", + references: ["https://example.com"] + } + } + }] + + const result = await container.renderToString(CardList, { + props: { terms: mockCards } + }) + + // Check navigation buttons have correct Bootstrap icons + expect(result).toContain('class="bi bi-arrow-left-circle"') + expect(result).toContain('class="bi bi-arrow-right-circle"') + expect(result).toContain('class="button navigation-button"') + }) +}) + diff --git a/src/scripts/paginate.test.ts b/src/scripts/paginate.test.ts new file mode 100644 index 00000000..42468f9d --- /dev/null +++ b/src/scripts/paginate.test.ts @@ -0,0 +1,271 @@ +import { describe, test, expect, beforeEach, vi } from "vitest" +import { JSDOM } from "jsdom" + +// Global DOM variable +let dom: JSDOM + +// Mock DOM environment +const setupDOM = (cardCount: number = 10) => { + dom = new JSDOM(` + + + +
+ + + +
+
+ ${Array.from({ length: cardCount }, (_, i) => + `
Card ${i + 1}
` + ).join('')} +
+ + + `) + + global.document = dom.window.document + global.window = dom.window as any + global.Event = dom.window.Event +} + +// Simple PaginationManager class for testing (extracted from paginate.ts) +class PaginationManager { + private cards: HTMLElement[] + private currentPage: number = 1 + private itemsPerPage: number = 6 + private totalPages: number = 1 + + constructor() { + this.cards = Array.from(document.querySelectorAll(".card")) + this.init() + } + + private init() { + this.calculateTotalPages() + this.setupEventListeners() + this.showCurrentPage() + this.updateButtonStates() + } + + private setupEventListeners() { + const paginateSelect = document.getElementById("pagination-select") as HTMLSelectElement + const prevButton = document.getElementById("prev-button") + const nextButton = document.getElementById("next-button") + + paginateSelect?.addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement + this.itemsPerPage = parseInt(target.value) + this.currentPage = 1 + this.calculateTotalPages() + this.showCurrentPage() + this.updateButtonStates() + }) + + prevButton?.addEventListener("click", () => { + if (this.currentPage > 1) { + this.currentPage-- + this.showCurrentPage() + this.updateButtonStates() + } + }) + + nextButton?.addEventListener("click", () => { + if (this.currentPage < this.totalPages) { + this.currentPage++ + this.showCurrentPage() + this.updateButtonStates() + } + }) + } + + private calculateTotalPages() { + this.totalPages = Math.ceil(this.cards.length / this.itemsPerPage) + } + + private showCurrentPage() { + const startIndex = (this.currentPage - 1) * this.itemsPerPage + const endIndex = startIndex + this.itemsPerPage + + this.cards.forEach((card) => { + card.style.display = "none" + }) + + this.cards.slice(startIndex, endIndex).forEach((card) => { + card.style.display = "block" + }) + } + + private updateButtonStates() { + const prevButton = document.getElementById("prev-button") as HTMLButtonElement + const nextButton = document.getElementById("next-button") as HTMLButtonElement + + if (prevButton) { + prevButton.disabled = this.currentPage === 1 + prevButton.style.opacity = this.currentPage === 1 ? "0.5" : "1" + } + + if (nextButton) { + nextButton.disabled = this.currentPage === this.totalPages + nextButton.style.opacity = this.currentPage === this.totalPages ? "0.5" : "1" + } + } + + // Public methods for testing + public getCurrentPage(): number { + return this.currentPage + } + + public getTotalPages(): number { + return this.totalPages + } + + public getItemsPerPage(): number { + return this.itemsPerPage + } + + public getVisibleCards(): HTMLElement[] { + return this.cards.filter(card => card.style.display !== "none") + } +} + +describe("PaginationManager", () => { + let paginationManager: PaginationManager + + beforeEach(() => { + vi.clearAllMocks() + }) + + test("initializes with correct default values", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + expect(paginationManager.getCurrentPage()).toBe(1) + expect(paginationManager.getItemsPerPage()).toBe(6) + expect(paginationManager.getTotalPages()).toBe(2) // 10 cards / 6 per page = 2 pages + }) + + test("shows correct number of cards on first page", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + const visibleCards = paginationManager.getVisibleCards() + expect(visibleCards).toHaveLength(6) + }) + + test("navigates to next page correctly", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + const nextButton = document.getElementById("next-button") as HTMLButtonElement + nextButton.click() + + expect(paginationManager.getCurrentPage()).toBe(2) + const visibleCards = paginationManager.getVisibleCards() + expect(visibleCards).toHaveLength(4) // Remaining 4 cards on page 2 + }) + + test("navigates to previous page correctly", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + // Go to page 2 first + const nextButton = document.getElementById("next-button") as HTMLButtonElement + nextButton.click() + + // Then go back to page 1 + const prevButton = document.getElementById("prev-button") as HTMLButtonElement + prevButton.click() + + expect(paginationManager.getCurrentPage()).toBe(1) + const visibleCards = paginationManager.getVisibleCards() + expect(visibleCards).toHaveLength(6) + }) + + test("changes items per page when dropdown changes", () => { + setupDOM(15) + paginationManager = new PaginationManager() + + const select = document.getElementById("pagination-select") as HTMLSelectElement + select.value = "12" + select.dispatchEvent(new Event("change")) + + expect(paginationManager.getItemsPerPage()).toBe(12) + expect(paginationManager.getTotalPages()).toBe(2) // 15 cards / 12 per page = 2 pages + expect(paginationManager.getCurrentPage()).toBe(1) // Should reset to page 1 + }) + + test("disables previous button on first page", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + const prevButton = document.getElementById("prev-button") as HTMLButtonElement + expect(prevButton.disabled).toBe(true) + expect(prevButton.style.opacity).toBe("0.5") + }) + + test("disables next button on last page", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + // Navigate to last page + const nextButton = document.getElementById("next-button") as HTMLButtonElement + nextButton.click() + + expect(nextButton.disabled).toBe(true) + expect(nextButton.style.opacity).toBe("0.5") + }) + + test("handles single page correctly", () => { + setupDOM(3) // Only 3 cards, so 1 page with 6 items per page + paginationManager = new PaginationManager() + + expect(paginationManager.getTotalPages()).toBe(1) + + const prevButton = document.getElementById("prev-button") as HTMLButtonElement + const nextButton = document.getElementById("next-button") as HTMLButtonElement + + expect(prevButton.disabled).toBe(true) + expect(nextButton.disabled).toBe(true) + }) + + test("calculates total pages correctly with different page sizes", () => { + setupDOM(25) + paginationManager = new PaginationManager() + + // Test with 6 items per page + expect(paginationManager.getTotalPages()).toBe(5) // 25 / 6 = 4.17 -> 5 pages + + // Change to 12 items per page + const select = document.getElementById("pagination-select") as HTMLSelectElement + select.value = "12" + select.dispatchEvent(new Event("change")) + expect(paginationManager.getTotalPages()).toBe(3) // 25 / 12 = 2.08 -> 3 pages + + // Change to 48 items per page + select.value = "48" + select.dispatchEvent(new Event("change")) + expect(paginationManager.getTotalPages()).toBe(1) // 25 / 48 = 0.52 -> 1 page + }) + + test("prevents navigation beyond boundaries", () => { + setupDOM(6) // Exactly 1 page + paginationManager = new PaginationManager() + + const prevButton = document.getElementById("prev-button") as HTMLButtonElement + const nextButton = document.getElementById("next-button") as HTMLButtonElement + + // Try to go to previous page (should stay at 1) + prevButton.click() + expect(paginationManager.getCurrentPage()).toBe(1) + + // Try to go to next page (should stay at 1) + nextButton.click() + expect(paginationManager.getCurrentPage()).toBe(1) + }) +}) diff --git a/src/scripts/paginate.ts b/src/scripts/paginate.ts new file mode 100644 index 00000000..9f2466a5 --- /dev/null +++ b/src/scripts/paginate.ts @@ -0,0 +1,109 @@ + class PaginationManager { + private cards: HTMLElement[] + private currentPage: number = 1 + private itemsPerPage: number = 6 + private totalPages: number = 1 + + constructor() { + this.cards = Array.from(document.querySelectorAll(".card")) + this.init() + } + + private init() { + this.calculateTotalPages() + this.setupEventListeners() + this.showCurrentPage() + this.updateButtonStates() + } + + private setupEventListeners() { + const paginateSelect = document.getElementById( + "pagination-select" + ) as HTMLSelectElement + const prevButton = document.getElementById("prev-button") + const nextButton = document.getElementById("next-button") + + // Handle dropdown change + paginateSelect?.addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement + this.itemsPerPage = parseInt(target.value) + this.currentPage = 1 // Reset to first page + this.calculateTotalPages() + this.showCurrentPage() + this.updateButtonStates() + }) + + // Handle previous button click + prevButton?.addEventListener("click", () => { + if (this.currentPage > 1) { + this.currentPage-- + this.showCurrentPage() + this.updateButtonStates() + } + }) + + // Handle next button click + nextButton?.addEventListener("click", () => { + if (this.currentPage < this.totalPages) { + this.currentPage++ + this.showCurrentPage() + this.updateButtonStates() + } + }) + } + + private calculateTotalPages() { + this.totalPages = Math.ceil(this.cards.length / this.itemsPerPage) + } + + private showCurrentPage() { + const startIndex = (this.currentPage - 1) * this.itemsPerPage + const endIndex = startIndex + this.itemsPerPage + + // Hide all cards + this.cards.forEach((card) => { + card.style.display = "none" + }) + + // Show cards for current page + this.cards.slice(startIndex, endIndex).forEach((card) => { + card.style.display = "block" + }) + } + + private updateButtonStates() { + const prevButton = document.getElementById( + "prev-button" + ) as HTMLButtonElement + const nextButton = document.getElementById( + "next-button" + ) as HTMLButtonElement + + // Disable/enable previous button + if (prevButton) { + prevButton.disabled = this.currentPage === 1 + prevButton.style.opacity = this.currentPage === 1 ? "0.5" : "1" + } + + // Disable/enable next button + if (nextButton) { + nextButton.disabled = this.currentPage === this.totalPages + nextButton.style.opacity = + this.currentPage === this.totalPages ? "0.5" : "1" + } + } + } + + // Initialize pagination when DOM is loaded + document.addEventListener("DOMContentLoaded", () => { + new PaginationManager() + }) + + // Also initialize immediately if DOM is already loaded + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + new PaginationManager() + }) + } else { + new PaginationManager() + } \ No newline at end of file From 46ab7fc21915335d3b0832daf288410e0268871e Mon Sep 17 00:00:00 2001 From: KC Ng Date: Mon, 25 Aug 2025 16:21:27 +0900 Subject: [PATCH 3/5] feat: update pagination logic on filtered cards as well --- src/components/CardList.astro | 2 +- src/pages/index.astro | 1 + src/scripts/hero-action.ts | 17 +++++- src/scripts/paginate.test.ts | 107 ++++++++++++++++++++++++++++++---- src/scripts/paginate.ts | 47 ++++++++++++--- 5 files changed, 151 insertions(+), 23 deletions(-) diff --git a/src/components/CardList.astro b/src/components/CardList.astro index 9b2896a1..5aa13ab0 100644 --- a/src/components/CardList.astro +++ b/src/components/CardList.astro @@ -55,7 +55,7 @@ const paginateOptions = [ diff --git a/src/pages/index.astro b/src/pages/index.astro index 5249893e..ec5f33da 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -48,3 +48,4 @@ const terms = await getCollection("terms") + diff --git a/src/scripts/hero-action.ts b/src/scripts/hero-action.ts index abf8c029..7e7ef27c 100644 --- a/src/scripts/hero-action.ts +++ b/src/scripts/hero-action.ts @@ -1,5 +1,5 @@ class Hero extends HTMLElement { - + constructor() { super() @@ -29,7 +29,12 @@ class Hero extends HTMLElement { searchInput.value = "" if (selectedCategory === "") { - cards.forEach((card: HTMLElement) => (card.style.display = "")) + cards.forEach((card: HTMLElement) => { + card.style.display = "" + card.removeAttribute('data-hidden-by-filter') + }) + // Trigger pagination refresh + window.dispatchEvent(new CustomEvent('cardsFiltered')) return } for (let i = 0; i < cards.length; i++) { @@ -37,10 +42,14 @@ class Hero extends HTMLElement { cards[i]!.getAttribute("data-categories")!.split(", ") if (cardCategories.includes(selectedCategory)) { ;(cards[i] as HTMLElement).style.display = "" + ;(cards[i] as HTMLElement).removeAttribute('data-hidden-by-filter') } else { ;(cards[i] as HTMLElement).style.display = "none" + ;(cards[i] as HTMLElement).setAttribute('data-hidden-by-filter', 'true') } } + // Trigger pagination refresh + window.dispatchEvent(new CustomEvent('cardsFiltered')) }) let currentFocus = -1 // Track the currently focused item in the autocomplete list @@ -168,10 +177,14 @@ class Hero extends HTMLElement { filterByCategory(selectedCategory, cardCategories) ) { ;(cards[i] as HTMLElement).style.display = "" + ;(cards[i] as HTMLElement).removeAttribute('data-hidden-by-filter') } else { ;(cards[i] as HTMLElement).style.display = "none" + ;(cards[i] as HTMLElement).setAttribute('data-hidden-by-filter', 'true') } } + // Trigger pagination refresh + window.dispatchEvent(new CustomEvent('cardsFiltered')) } } } diff --git a/src/scripts/paginate.test.ts b/src/scripts/paginate.test.ts index 42468f9d..62a3b140 100644 --- a/src/scripts/paginate.test.ts +++ b/src/scripts/paginate.test.ts @@ -21,28 +21,44 @@ const setupDOM = (cardCount: number = 10) => {
- ${Array.from({ length: cardCount }, (_, i) => + ${Array.from({ length: cardCount }, (_, i) => `
Card ${i + 1}
` ).join('')}
- `) + `, { + url: "http://localhost", // Provide a URL to avoid localStorage issues + }) global.document = dom.window.document global.window = dom.window as any global.Event = dom.window.Event -} - -// Simple PaginationManager class for testing (extracted from paginate.ts) + global.CustomEvent = dom.window.CustomEvent + + // Mock localStorage + const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn() + } + Object.defineProperty(dom.window, 'localStorage', { + value: localStorageMock, + writable: true + }) + global.localStorage = localStorageMock +}// Simple PaginationManager class for testing (extracted from paginate.ts) class PaginationManager { - private cards: HTMLElement[] + private allCards: HTMLElement[] private currentPage: number = 1 private itemsPerPage: number = 6 private totalPages: number = 1 constructor() { - this.cards = Array.from(document.querySelectorAll(".card")) + this.allCards = Array.from(document.querySelectorAll(".card")) this.init() } @@ -58,6 +74,11 @@ class PaginationManager { const prevButton = document.getElementById("prev-button") const nextButton = document.getElementById("next-button") + // Listen for filter changes + window.addEventListener('cardsFiltered', () => { + setTimeout(() => this.refreshPagination(), 10) + }) + paginateSelect?.addEventListener("change", (e) => { const target = e.target as HTMLSelectElement this.itemsPerPage = parseInt(target.value) @@ -84,19 +105,33 @@ class PaginationManager { }) } + private refreshPagination() { + this.currentPage = 1 + this.calculateTotalPages() + this.showCurrentPage() + this.updateButtonStates() + } + private calculateTotalPages() { - this.totalPages = Math.ceil(this.cards.length / this.itemsPerPage) + const filteredCards = this.getFilteredCards() + this.totalPages = Math.ceil(filteredCards.length / this.itemsPerPage) + if (this.totalPages === 0) this.totalPages = 1 } private showCurrentPage() { + const filteredCards = this.getFilteredCards() const startIndex = (this.currentPage - 1) * this.itemsPerPage const endIndex = startIndex + this.itemsPerPage - this.cards.forEach((card) => { - card.style.display = "none" + this.allCards.forEach((card) => { + if (card.getAttribute('data-hidden-by-filter') === 'true') { + card.style.display = "none" + } else { + card.style.display = "none" + } }) - this.cards.slice(startIndex, endIndex).forEach((card) => { + filteredCards.slice(startIndex, endIndex).forEach((card) => { card.style.display = "block" }) } @@ -130,7 +165,13 @@ class PaginationManager { } public getVisibleCards(): HTMLElement[] { - return this.cards.filter(card => card.style.display !== "none") + return this.allCards.filter(card => card.style.display !== "none") + } + + public getFilteredCards(): HTMLElement[] { + return this.allCards.filter(card => { + return card.getAttribute('data-hidden-by-filter') !== 'true' + }) } } @@ -268,4 +309,46 @@ describe("PaginationManager", () => { nextButton.click() expect(paginationManager.getCurrentPage()).toBe(1) }) + + test("works with filtered cards", () => { + setupDOM(10) + paginationManager = new PaginationManager() + + // Simulate filtering by marking some cards as hidden by filter + const cards = Array.from(document.querySelectorAll('.card')) + if (cards.length >= 3) { + cards[0]!.setAttribute('data-hidden-by-filter', 'true') + cards[1]!.setAttribute('data-hidden-by-filter', 'true') + cards[2]!.setAttribute('data-hidden-by-filter', 'true') + } + + // Trigger pagination refresh with custom event + window.dispatchEvent(new CustomEvent('cardsFiltered')) + + // Should now work with 7 visible cards instead of 10 + expect(paginationManager.getTotalPages()).toBe(2) // 7 cards / 6 per page = 2 pages + + // First page should show 6 cards + const visibleCards = paginationManager.getVisibleCards() + expect(visibleCards).toHaveLength(6) + }) + + test("resets to page 1 when filters change", async () => { + setupDOM(15) + paginationManager = new PaginationManager() + + // Go to page 2 + const nextButton = document.getElementById("next-button") as HTMLButtonElement + nextButton.click() + expect(paginationManager.getCurrentPage()).toBe(2) + + // Simulate filter change + window.dispatchEvent(new CustomEvent('cardsFiltered')) + + // Wait for the setTimeout in the event handler + await new Promise(resolve => setTimeout(resolve, 20)) + + // Should reset to page 1 + expect(paginationManager.getCurrentPage()).toBe(1) + }) }) diff --git a/src/scripts/paginate.ts b/src/scripts/paginate.ts index 9f2466a5..dbf4bbfa 100644 --- a/src/scripts/paginate.ts +++ b/src/scripts/paginate.ts @@ -1,12 +1,13 @@ class PaginationManager { - private cards: HTMLElement[] + private allCards: HTMLElement[] private currentPage: number = 1 private itemsPerPage: number = 6 private totalPages: number = 1 constructor() { - this.cards = Array.from(document.querySelectorAll(".card")) + this.allCards = Array.from(document.querySelectorAll(".card")) this.init() + this.observeFilterChanges() } private init() { @@ -16,6 +17,27 @@ this.updateButtonStates() } + private observeFilterChanges() { + // Listen for the custom event dispatched by hero-action.ts + window.addEventListener('cardsFiltered', () => { + setTimeout(() => this.refreshPagination(), 10) + }) + } + + private getFilteredCards(): HTMLElement[] { + // Get cards that are not hidden by filtering + return this.allCards.filter(card => { + return card.getAttribute('data-hidden-by-filter') !== 'true' + }) + } + + private refreshPagination() { + this.currentPage = 1 // Reset to first page when filters change + this.calculateTotalPages() + this.showCurrentPage() + this.updateButtonStates() + } + private setupEventListeners() { const paginateSelect = document.getElementById( "pagination-select" @@ -53,20 +75,29 @@ } private calculateTotalPages() { - this.totalPages = Math.ceil(this.cards.length / this.itemsPerPage) + const filteredCards = this.getFilteredCards() + this.totalPages = Math.ceil(filteredCards.length / this.itemsPerPage) + if (this.totalPages === 0) this.totalPages = 1 } private showCurrentPage() { + const filteredCards = this.getFilteredCards() const startIndex = (this.currentPage - 1) * this.itemsPerPage const endIndex = startIndex + this.itemsPerPage - // Hide all cards - this.cards.forEach((card) => { - card.style.display = "none" + // First, hide all cards + this.allCards.forEach((card) => { + // If card is hidden by filter, keep it hidden + if (card.getAttribute('data-hidden-by-filter') === 'true') { + card.style.display = "none" + } else { + // For filtered cards, hide by default (pagination will show the correct ones) + card.style.display = "none" + } }) - // Show cards for current page - this.cards.slice(startIndex, endIndex).forEach((card) => { + // Show only the cards for current page from filtered results + filteredCards.slice(startIndex, endIndex).forEach((card) => { card.style.display = "block" }) } From 9f38e4f8db3936b7f9b8ffb09aca2151b0d93dca Mon Sep 17 00:00:00 2001 From: KC Ng Date: Mon, 25 Aug 2025 16:22:11 +0900 Subject: [PATCH 4/5] feat: remove comments and migrate import scripts to index.astro --- src/components/CardList.astro | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/components/CardList.astro b/src/components/CardList.astro index 5aa13ab0..7884de9c 100644 --- a/src/components/CardList.astro +++ b/src/components/CardList.astro @@ -51,12 +51,3 @@ const paginateOptions = [ )) } - - - - - From 9f02d970d4c3726680b0b2e24f3c663d4ebc83a1 Mon Sep 17 00:00:00 2001 From: KC Ng Date: Tue, 26 Aug 2025 12:31:31 +0900 Subject: [PATCH 5/5] feat: remove duplicate event listening initialization for DOMContentLoaded --- src/scripts/paginate.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/scripts/paginate.ts b/src/scripts/paginate.ts index dbf4bbfa..34cdcf28 100644 --- a/src/scripts/paginate.ts +++ b/src/scripts/paginate.ts @@ -125,16 +125,12 @@ } } - // Initialize pagination when DOM is loaded - document.addEventListener("DOMContentLoaded", () => { - new PaginationManager() - }) - // Also initialize immediately if DOM is already loaded if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { new PaginationManager() }) } else { + // Initialize pagination when DOM is loaded new PaginationManager() } \ No newline at end of file