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` diff --git a/src/resources/projects/website/listing/quarto-listing.js b/src/resources/projects/website/listing/quarto-listing.js index e9a07b2ea39..7e16de0b93b 100644 --- a/src/resources/projects/website/listing/quarto-listing.js +++ b/src/resources/projects/website/listing/quarto-listing.js @@ -1,13 +1,15 @@ 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); - } + selectedCategories.clear(); + selectedCategories.add(category); + + updateCategory(); + setCategoryHash(); }; window["quarto-listing-loaded"] = () => { @@ -15,11 +17,17 @@ window["quarto-listing-loaded"] = () => { const hash = getHash(); if (hash) { - // If there is a category, switch to that + // If there are categories, switch to those 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)); + const cats = hash.category.split(","); + for (const cat of cats) { + if (cat) selectedCategories.add(decodeURIComponent(cat)); + } + updateCategory(); + } else { + // No categories in hash, use default + selectedCategories.add(kDefaultCategory); + updateCategory(); } // Paginate a specific listing const listingIds = Object.keys(window["quarto-listings"]); @@ -29,6 +37,10 @@ window["quarto-listing-loaded"] = () => { showPage(listingId, page); } } + } else { + // No hash at all, use default category + selectedCategories.add(kDefaultCategory); + updateCategory(); } const listingIds = Object.keys(window["quarto-listings"]); @@ -66,9 +78,25 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { const category = decodeURIComponent( atob(categoryEl.getAttribute("data-category")) ); - categoryEl.onclick = () => { - activateCategory(category); - setCategoryHash(category); + categoryEl.onclick = (e) => { + // 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(); + } + + // If this would deselect the last category, ensure default category remains selected + if (selectedCategories.has(category)) { + selectedCategories.delete(category); + if (selectedCategories.size === 0) { + selectedCategories.add(kDefaultCategory); + } + } else { + selectedCategories.add(category); + } + + updateCategory(); + setCategoryHash(); }; } @@ -79,12 +107,11 @@ window.document.addEventListener("DOMContentLoaded", function (_event) { ); for (const categoryTitleEl of categoryTitleEls) { categoryTitleEl.onclick = () => { - activateCategory(""); - setCategoryHash(""); + selectedCategories.clear(); + updateCategory(); + setCategoryHash(); }; } - - categoriesLoaded = true; }); function toggleNoMatchingMessage(list) { @@ -101,8 +128,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) { @@ -204,46 +238,56 @@ 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"); - } +function updateCategory() { + updateCategoryUI(); + filterListingCategory(); +} - // Activate this category - const categoryEl = window.document.querySelector( - `.quarto-listing-category .category[data-category='${btoa( - encodeURIComponent(category) - )}']` +function updateCategoryUI() { + // Deactivate all categories first + const categoryEls = window.document.querySelectorAll( + ".quarto-listing-category .category" ); - if (categoryEl) { - categoryEl.classList.add("active"); + for (const categoryEl of categoryEls) { + categoryEl.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 filterListingCategory() { 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.has(kDefaultCategory)) { + effectiveCategories.delete(kDefaultCategory); + } + list.filter(function (item) { const itemValues = item.values(); if (itemValues.categories !== null) { const categories = decodeURIComponent( atob(itemValues.categories) ).split(","); - return categories.includes(category); + return categories.some(category => effectiveCategories.has(category)); } else { return false; } 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(); + }); } });