Skip to content
Open
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
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
124 changes: 84 additions & 40 deletions src/resources/projects/website/listing/quarto-listing.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
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"] = () => {
// Process any existing hash
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"]);
Expand All @@ -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"]);
Expand Down Expand Up @@ -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();
};
}

Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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();
});
}
});

Expand Down
Loading