Skip to content

feat: enhance category selection and filtering with multiple selection #12667

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
136 changes: 97 additions & 39 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/Cmd key for multiple selection
// Clear other selections if not using Ctrl/Cmd
if (!e.ctrlKey && !e.metaKey) {
selectedCategories.clear();
}
Comment on lines +82 to +86
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if that is a good idea as it makes the multiple selection feature "hidden".
I thought this was less disruptive than checkboxes everywhere.
This was the only idea I came up with to keep previous visual appearance.


// If this would deselect the last category, ensure default category remains selected
if (selectedCategories.has(category)) {
selectedCategories.delete(category);
if (selectedCategories.size === 1) {
selectedCategories.add(kDefaultCategory);
}
} else {
selectedCategories.add(category);
}

updateCategory();
setCategoryHash();
};
}

Expand All @@ -79,12 +107,25 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
);
for (const categoryTitleEl of categoryTitleEls) {
categoryTitleEl.onclick = () => {
activateCategory("");
setCategoryHash("");
selectedCategories.clear();
updateCategory();
setCategoryHash();
};
}

categoriesLoaded = true;
// 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) {
Expand All @@ -101,8 +142,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 +252,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
Loading