From 2a18c826a314e1a03073a0f72991d035e357a124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Fri, 2 May 2025 19:04:39 +0200 Subject: [PATCH 01/10] feat: enhance category selection and filtering with multiple selection --- .../website/listing/quarto-listing.js | 126 +++++++++++++----- 1 file changed, 93 insertions(+), 33 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index e9a07b2ea39..5dd8b77e3bc 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -1,12 +1,14 @@ const kProgressiveAttr = "data-src"; let categoriesLoaded = false; +let selectedCategories = new Set(); +const kDefaultCategory = ""; // Default category "" means all posts selected window.quartoListingCategory = (category) => { // category is URI encoded in EJS template for UTF-8 support category = decodeURIComponent(atob(category)); if (categoriesLoaded) { activateCategory(category); - setCategoryHash(category); + setCategoryHash(); } }; @@ -15,11 +17,19 @@ window["quarto-listing-loaded"] = () => { const hash = getHash(); if (hash) { - // If there is a category, switch to that - if (hash.category) { - // category hash are URI encoded so we need to decode it before processing - // so that we can match it with the category element processed in JS - activateCategory(decodeURIComponent(hash.category)); + // If there are categories, switch to those + if (hash.categories) { + const cats = hash.categories.split(","); + for (const cat of cats) { + if (cat) selectedCategories.add(decodeURIComponent(cat)); + } + updateCategoryUI(); + filterListingCategories(); + } else { + // No categories in hash, use default + selectedCategories.add(kDefaultCategory); + updateCategoryUI(); + filterListingCategories(); } // Paginate a specific listing const listingIds = Object.keys(window["quarto-listings"]); @@ -29,6 +39,11 @@ window["quarto-listing-loaded"] = () => { showPage(listingId, page); } } + } else { + // No hash at all, use default category + selectedCategories.add(kDefaultCategory); + updateCategoryUI(); + filterListingCategories(); } const listingIds = Object.keys(window["quarto-listings"]); @@ -66,9 +81,14 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { const category = decodeURIComponent( atob(categoryEl.getAttribute("data-category")) ); - categoryEl.onclick = () => { + categoryEl.onclick = (e) => { + // Allow holding Ctrl/Cmd key for multiple selection + // Clear other selections if not using Ctrl/Cmd + if (!e.ctrlKey && !e.metaKey) { + selectedCategories.clear(); + } activateCategory(category); - setCategoryHash(category); + setCategoryHash(); }; } @@ -79,11 +99,29 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { ); for (const categoryTitleEl of categoryTitleEls) { categoryTitleEl.onclick = () => { - activateCategory(""); - setCategoryHash(""); + selectedCategories.clear(); + updateCategoryUI(); + setCategoryHash(); + filterListingCategories(); }; } + // Process any existing hash for multiple categories + const hash = getHash(); + if (hash && hash.categories) { + const cats = hash.categories.split(","); + for (const cat of cats) { + if (cat) selectedCategories.add(decodeURIComponent(cat)); + } + updateCategoryUI(); + filterListingCategories(); + } else { + // No hash at all, use default category + selectedCategories.add(kDefaultCategory); + updateCategoryUI(); + filterListingCategories(); + } + categoriesLoaded = true; }); @@ -101,8 +139,15 @@ function toggleNoMatchingMessage(list) { } } -function setCategoryHash(category) { - setHash({ category }); +function setCategoryHash() { + if (selectedCategories.size === 0) { + setHash({}); + } else { + const categoriesStr = Array.from(selectedCategories) + .map((cat) => encodeURIComponent(cat)) + .join(","); + setHash({ category: categoriesStr }); + } } function setPageHash(listingId, page) { @@ -205,45 +250,60 @@ function showPage(listingId, page) { } function activateCategory(category) { - // Deactivate existing categories - const activeEls = window.document.querySelectorAll( - ".quarto-listing-category .category.active" - ); - for (const activeEl of activeEls) { - activeEl.classList.remove("active"); + if (selectedCategories.has(category)) { + selectedCategories.delete(category); + } else { + selectedCategories.add(category); } + updateCategoryUI(); + filterListingCategories(); +} - // Activate this category - const categoryEl = window.document.querySelector( - `.quarto-listing-category .category[data-category='${btoa( - encodeURIComponent(category) - )}']` +function updateCategoryUI() { + // Deactivate all categories first + const activeEls = window.document.querySelectorAll( + ".quarto-listing-category .category" ); - if (categoryEl) { - categoryEl.classList.add("active"); + for (const activeEls of activeEls) { + activeEls.classList.remove("active"); } - // Filter the listings to this category - filterListingCategory(category); + // Activate selected categories + for (const category of selectedCategories) { + const categoryEl = window.document.querySelector( + `.quarto-listing-category .category[data-category='${btoa( + encodeURIComponent(category) + )}']` + ); + if (categoryEl) { + categoryEl.classList.add("active"); + } + } } -function filterListingCategory(category) { +function filterListingCategories() { const listingIds = Object.keys(window["quarto-listings"]); for (const listingId of listingIds) { const list = window["quarto-listings"][listingId]; if (list) { - if (category === "") { - // resets the filter + if (selectedCategories.size === 0 || + (selectedCategories.size === 1 && selectedCategories.has(kDefaultCategory))) { + // Reset the filter when no categories selected or only default category list.filter(); } else { - // filter to this category + // Filter to selected categories, but ignore kDefaultCategory if other categories selected + const effectiveCategories = new Set(selectedCategories); + if (effectiveCategories.size > 1) { + effectiveCategories.delete(kDefaultCategory); + } + list.filter(function (item) { const itemValues = item.values(); if (itemValues.categories !== null) { - const categories = decodeURIComponent( + const itemCategories = decodeURIComponent( atob(itemValues.categories) ).split(","); - return categories.includes(category); + return itemCategories.some(category => effectiveCategories.has(category)); } else { return false; } From 08d00e068ed7f3faf445cd5939a8eba70fa37922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Fri, 2 May 2025 19:20:24 +0200 Subject: [PATCH 02/10] revert: keep original names --- .../website/listing/quarto-listing.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index 5dd8b77e3bc..4ce84abe0fe 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -24,12 +24,12 @@ window["quarto-listing-loaded"] = () => { if (cat) selectedCategories.add(decodeURIComponent(cat)); } updateCategoryUI(); - filterListingCategories(); + filterListingCategory(); } else { // No categories in hash, use default selectedCategories.add(kDefaultCategory); updateCategoryUI(); - filterListingCategories(); + filterListingCategory(); } // Paginate a specific listing const listingIds = Object.keys(window["quarto-listings"]); @@ -43,7 +43,7 @@ window["quarto-listing-loaded"] = () => { // No hash at all, use default category selectedCategories.add(kDefaultCategory); updateCategoryUI(); - filterListingCategories(); + filterListingCategory(); } const listingIds = Object.keys(window["quarto-listings"]); @@ -102,7 +102,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { selectedCategories.clear(); updateCategoryUI(); setCategoryHash(); - filterListingCategories(); + filterListingCategory(); }; } @@ -114,12 +114,12 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { if (cat) selectedCategories.add(decodeURIComponent(cat)); } updateCategoryUI(); - filterListingCategories(); + filterListingCategory(); } else { // No hash at all, use default category selectedCategories.add(kDefaultCategory); updateCategoryUI(); - filterListingCategories(); + filterListingCategory(); } categoriesLoaded = true; @@ -256,7 +256,7 @@ function activateCategory(category) { selectedCategories.add(category); } updateCategoryUI(); - filterListingCategories(); + filterListingCategory(); } function updateCategoryUI() { @@ -281,7 +281,7 @@ function updateCategoryUI() { } } -function filterListingCategories() { +function filterListingCategory() { const listingIds = Object.keys(window["quarto-listings"]); for (const listingId of listingIds) { const list = window["quarto-listings"][listingId]; @@ -300,10 +300,10 @@ function filterListingCategories() { list.filter(function (item) { const itemValues = item.values(); if (itemValues.categories !== null) { - const itemCategories = decodeURIComponent( + const categories = decodeURIComponent( atob(itemValues.categories) ).split(","); - return itemCategories.some(category => effectiveCategories.has(category)); + return categories.some(category => effectiveCategories.has(category)); } else { return false; } From 87248446a6236696b488eb25b9a328e9f42a35a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Fri, 2 May 2025 19:54:09 +0200 Subject: [PATCH 03/10] fix: logic for default category and keep "category" as URL hash key --- .../website/listing/quarto-listing.js | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index 4ce84abe0fe..1c7f34071c7 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -18,8 +18,8 @@ window["quarto-listing-loaded"] = () => { if (hash) { // If there are categories, switch to those - if (hash.categories) { - const cats = hash.categories.split(","); + if (hash.category) { + const cats = hash.category.split(","); for (const cat of cats) { if (cat) selectedCategories.add(decodeURIComponent(cat)); } @@ -87,6 +87,12 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { if (!e.ctrlKey && !e.metaKey) { selectedCategories.clear(); } + + // If this would deselect the last category, ensure default category remains selected + if (selectedCategories.has(category) && selectedCategories.size === 1) { + selectedCategories.add(kDefaultCategory); + } + activateCategory(category); setCategoryHash(); }; @@ -108,8 +114,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { // Process any existing hash for multiple categories const hash = getHash(); - if (hash && hash.categories) { - const cats = hash.categories.split(","); + if (hash && hash.category) { + const cats = hash.category.split(","); for (const cat of cats) { if (cat) selectedCategories.add(decodeURIComponent(cat)); } @@ -261,11 +267,11 @@ function activateCategory(category) { function updateCategoryUI() { // Deactivate all categories first - const activeEls = window.document.querySelectorAll( + const categoryEls = window.document.querySelectorAll( ".quarto-listing-category .category" ); - for (const activeEls of activeEls) { - activeEls.classList.remove("active"); + for (const categoryEl of categoryEls) { + categoryEl.classList.remove("active"); } // Activate selected categories @@ -293,7 +299,7 @@ function filterListingCategory() { } else { // Filter to selected categories, but ignore kDefaultCategory if other categories selected const effectiveCategories = new Set(selectedCategories); - if (effectiveCategories.size > 1) { + if (effectiveCategories.size > 1 && effectiveCategories.has(kDefaultCategory)) { effectiveCategories.delete(kDefaultCategory); } From 94fbe36bf36fb7f5823d4fd10c902c845225e66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Fri, 2 May 2025 20:32:47 +0200 Subject: [PATCH 04/10] fix: clear and add post category to hash --- src/resources/projects/website/listing/quarto-listing.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index 1c7f34071c7..ca06d55a54b 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -6,10 +6,11 @@ const kDefaultCategory = ""; // Default category "" means all posts selected window.quartoListingCategory = (category) => { // category is URI encoded in EJS template for UTF-8 support category = decodeURIComponent(atob(category)); - if (categoriesLoaded) { - activateCategory(category); - setCategoryHash(); - } + selectedCategories.clear(); + selectedCategories.add(category); + updateCategoryUI(); + filterListingCategory(); + setCategoryHash(); }; window["quarto-listing-loaded"] = () => { From a42adc83164339a741b021ad429ddd86aa73f3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Wed, 7 May 2025 21:18:20 +0200 Subject: [PATCH 05/10] refactor: improve logic and dry code a bit --- .../website/listing/quarto-listing.js | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index ca06d55a54b..c46a4b1c13d 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -8,9 +8,9 @@ window.quartoListingCategory = (category) => { category = decodeURIComponent(atob(category)); selectedCategories.clear(); selectedCategories.add(category); - updateCategoryUI(); - filterListingCategory(); + setCategoryHash(); + updateCategory(); }; window["quarto-listing-loaded"] = () => { @@ -24,13 +24,11 @@ window["quarto-listing-loaded"] = () => { for (const cat of cats) { if (cat) selectedCategories.add(decodeURIComponent(cat)); } - updateCategoryUI(); - filterListingCategory(); + updateCategory(); } else { // No categories in hash, use default selectedCategories.add(kDefaultCategory); - updateCategoryUI(); - filterListingCategory(); + updateCategory(); } // Paginate a specific listing const listingIds = Object.keys(window["quarto-listings"]); @@ -43,8 +41,7 @@ window["quarto-listing-loaded"] = () => { } else { // No hash at all, use default category selectedCategories.add(kDefaultCategory); - updateCategoryUI(); - filterListingCategory(); + updateCategory(); } const listingIds = Object.keys(window["quarto-listings"]); @@ -90,12 +87,17 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { } // If this would deselect the last category, ensure default category remains selected - if (selectedCategories.has(category) && selectedCategories.size === 1) { - selectedCategories.add(kDefaultCategory); + if (selectedCategories.has(category)) { + selectedCategories.delete(category); + if (selectedCategories.size === 1) { + selectedCategories.add(kDefaultCategory); + } + } else { + selectedCategories.add(category); } - activateCategory(category); setCategoryHash(); + updateCategory(); }; } @@ -107,9 +109,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { for (const categoryTitleEl of categoryTitleEls) { categoryTitleEl.onclick = () => { selectedCategories.clear(); - updateCategoryUI(); setCategoryHash(); - filterListingCategory(); + updateCategory(); }; } @@ -120,13 +121,11 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { for (const cat of cats) { if (cat) selectedCategories.add(decodeURIComponent(cat)); } - updateCategoryUI(); - filterListingCategory(); + updateCategory(); } else { // No hash at all, use default category selectedCategories.add(kDefaultCategory); - updateCategoryUI(); - filterListingCategory(); + updateCategory(); } categoriesLoaded = true; @@ -256,12 +255,7 @@ function showPage(listingId, page) { } } -function activateCategory(category) { - if (selectedCategories.has(category)) { - selectedCategories.delete(category); - } else { - selectedCategories.add(category); - } +function updateCategory() { updateCategoryUI(); filterListingCategory(); } From 150bee68d6b295899737dcc51e84f7cd45d2b39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Wed, 7 May 2025 21:19:50 +0200 Subject: [PATCH 06/10] refactor: hash after update --- src/resources/projects/website/listing/quarto-listing.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index c46a4b1c13d..ebd71fb6610 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -9,8 +9,8 @@ window.quartoListingCategory = (category) => { selectedCategories.clear(); selectedCategories.add(category); - setCategoryHash(); updateCategory(); + setCategoryHash(); }; window["quarto-listing-loaded"] = () => { @@ -96,8 +96,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { selectedCategories.add(category); } - setCategoryHash(); updateCategory(); + setCategoryHash(); }; } @@ -109,8 +109,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { for (const categoryTitleEl of categoryTitleEls) { categoryTitleEl.onclick = () => { selectedCategories.clear(); - setCategoryHash(); updateCategory(); + setCategoryHash(); }; } From 0cb3c76cc689b0d4c519b393efc9dc31c7c9a7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 8 May 2025 11:28:15 +0200 Subject: [PATCH 07/10] chore: remove no longer used variable --- src/resources/projects/website/listing/quarto-listing.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index ebd71fb6610..5c826814d57 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -1,5 +1,4 @@ const kProgressiveAttr = "data-src"; -let categoriesLoaded = false; let selectedCategories = new Set(); const kDefaultCategory = ""; // Default category "" means all posts selected @@ -127,8 +126,6 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { selectedCategories.add(kDefaultCategory); updateCategory(); } - - categoriesLoaded = true; }); function toggleNoMatchingMessage(list) { From 8741974df54a86ec83bf26e6d303606823b7af39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:54:59 +0100 Subject: [PATCH 08/10] fix: remove duplicate hash parsing quarto-listing-loaded already handles this. --- .../website/listing/quarto-listing.js | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index 5c826814d57..7e16de0b93b 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -79,8 +79,8 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { atob(categoryEl.getAttribute("data-category")) ); categoryEl.onclick = (e) => { - // Allow holding Ctrl/Cmd key for multiple selection - // Clear other selections if not using Ctrl/Cmd + // Allow holding Ctrl (Windows/Linux) or Cmd (macOS) for multiple selection + // Clear other selections if not using modifier key if (!e.ctrlKey && !e.metaKey) { selectedCategories.clear(); } @@ -88,7 +88,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { // If this would deselect the last category, ensure default category remains selected if (selectedCategories.has(category)) { selectedCategories.delete(category); - if (selectedCategories.size === 1) { + if (selectedCategories.size === 0) { selectedCategories.add(kDefaultCategory); } } else { @@ -112,20 +112,6 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { setCategoryHash(); }; } - - // Process any existing hash for multiple categories - const hash = getHash(); - if (hash && hash.category) { - const cats = hash.category.split(","); - for (const cat of cats) { - if (cat) selectedCategories.add(decodeURIComponent(cat)); - } - updateCategory(); - } else { - // No hash at all, use default category - selectedCategories.add(kDefaultCategory); - updateCategory(); - } }); function toggleNoMatchingMessage(list) { From daa36c8f23f0c1d44b7e51e5d8a76933195d7084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:55:20 +0100 Subject: [PATCH 09/10] chore: add changelog entry --- news/changelog-1.9.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 9c327bc5e1d..0245ff5346f 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -79,6 +79,7 @@ All changes included in 1.9: - ([#13570](https://github.com/quarto-dev/quarto-cli/pull/13570)): Replace Twitter with Bluesky in default blog template and documentation examples. New blog projects now include Bluesky social links instead of Twitter. - ([#13716](https://github.com/quarto-dev/quarto-cli/issues/13716)): Fix draft pages showing blank during preview when pre-render scripts are configured. - ([#13847](https://github.com/quarto-dev/quarto-cli/pull/13847)): Open graph title with markdown is now processed correctly. (author: @mcanouil) +- ([#12667](https://github.com/quarto-dev/quarto-cli/pull/12667)): Add support for multiple category selection in listings using Ctrl/Cmd+click. Selected categories are persisted in the URL hash as comma-separated values. (author: @mcanouil) ### `book` From 48ecb01cc0e54d96d050c8a5097995bb87ce70e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Thu, 15 Jan 2026 00:18:33 +0100 Subject: [PATCH 10/10] test: add tests for multiple category selection in blog --- ...c.ts => blog-categories-selection.spec.ts} | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) rename tests/integration/playwright/tests/{blog-simple-blog.spec.ts => blog-categories-selection.spec.ts} (56%) diff --git a/tests/integration/playwright/tests/blog-simple-blog.spec.ts b/tests/integration/playwright/tests/blog-categories-selection.spec.ts similarity index 56% rename from tests/integration/playwright/tests/blog-simple-blog.spec.ts rename to tests/integration/playwright/tests/blog-categories-selection.spec.ts index ed6151acc5b..c9f95ea7650 100644 --- a/tests/integration/playwright/tests/blog-simple-blog.spec.ts +++ b/tests/integration/playwright/tests/blog-categories-selection.spec.ts @@ -1,6 +1,8 @@ import { expect, Page, test } from "@playwright/test"; import { getUrl } from "../src/utils"; +declare const process: { platform: string }; + const testPages = { 'posts': 'table.html', 'posts2': 'default.html', @@ -64,6 +66,63 @@ Object.entries(testPages).forEach(([postDir, pageName]) => { await page.goto(`./blog/simple-blog/_site/${pageName}`); await checkCategoryLink(page, '免疫', pageName, 'Welcome To My Blog'); }); + + test(`Multiple category selection with Ctrl+click works for ${pageName}`, async ({ page }) => { + await page.goto(`./blog/simple-blog/_site/${pageName}`); + + // Target category sidebar specifically (not post card categories) + const categorySidebar = page.locator('.quarto-listing-category'); + const codeCategory = categorySidebar.locator(`div.category[data-category="${btoa(encodeURIComponent('code'))}"]`); + const eurosCategory = categorySidebar.locator(`div.category[data-category="${btoa(encodeURIComponent('euros (€)'))}"]`); + + // Click on "code" category (only Post With Code has this category) + await codeCategory.click(); + await expect(codeCategory).toHaveClass(/active/); + await expect(page.getByRole('link', { name: 'Post With Code' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Welcome To My Blog' })).toBeHidden(); + + // Ctrl+click on "euros (€)" category to add it to selection (only Welcome has this category) + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + await page.keyboard.down(modifier); + await eurosCategory.click(); + await page.keyboard.up(modifier); + + // Both categories should be active + await expect(codeCategory).toHaveClass(/active/); + await expect(eurosCategory).toHaveClass(/active/); + + // Both posts should be visible (OR logic: posts matching any selected category) + await expect(page.getByRole('link', { name: 'Post With Code' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Welcome To My Blog' })).toBeVisible(); + + // URL hash should contain both categories (comma-separated) + const url = page.url(); + expect(url).toContain('category='); + expect(url).toContain(encodeURIComponent('code')); + expect(url).toContain(encodeURIComponent('euros (€)')); + }); + + test(`Deselecting last category with Ctrl+click shows all posts for ${pageName}`, async ({ page }) => { + await page.goto(`./blog/simple-blog/_site/${pageName}`); + + // Target category sidebar specifically + const categorySidebar = page.locator('.quarto-listing-category'); + const codeCategory = categorySidebar.locator(`div.category[data-category="${btoa(encodeURIComponent('code'))}"]`); + + // Click on "code" category + await codeCategory.click(); + await expect(codeCategory).toHaveClass(/active/); + + // Ctrl+click on "code" again to deselect it + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + await page.keyboard.down(modifier); + await codeCategory.click(); + await page.keyboard.up(modifier); + + // No category should be active (default state), all posts visible + await expect(page.getByRole('link', { name: 'Post With Code' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Welcome To My Blog' })).toBeVisible(); + }); } });