💥 feat!: add tag filtering for projects (#431)
- Card (project) images no longer require manual top/bottom margin adjustments for proper spacing. Action needed: Review existing card images as previous manual margin adjustments may now be unnecessary/excessive. - Sites using `cards.html` with tags will now load JavaScript by default when tags are present. To maintain no-JS behaviour, explicitly set `enable_cards_tag_filtering = false` in either `config.toml` or the `_index.md` file where `cards.html` is used.main
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 96 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 141 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 110 KiB |
After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 9.9 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 62 KiB |
@ -0,0 +1,99 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const cards = document.querySelectorAll('.card');
|
||||||
|
const filterLinks = document.querySelectorAll('.filter-controls a');
|
||||||
|
const allProjectsFilter = document.querySelector('#all-projects-filter');
|
||||||
|
if (!cards.length || !filterLinks.length) return;
|
||||||
|
allProjectsFilter.style.display = 'block';
|
||||||
|
|
||||||
|
// Create a Map for O(1) lookups of links by filter value.
|
||||||
|
const linkMap = new Map(
|
||||||
|
Array.from(filterLinks).map(link => [link.dataset.filter, link])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-process cards data for faster filtering.
|
||||||
|
const cardData = Array.from(cards).map(card => ({
|
||||||
|
element: card,
|
||||||
|
tags: card.dataset.tags?.toLowerCase().split(',').filter(Boolean) ?? []
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getTagSlugFromUrl(url) {
|
||||||
|
return url.split('/').filter(Boolean).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterFromHash() {
|
||||||
|
if (!window.location.hash) return 'all';
|
||||||
|
const hash = decodeURIComponent(window.location.hash.slice(1));
|
||||||
|
const matchingLink = Array.from(filterLinks).find(link =>
|
||||||
|
getTagSlugFromUrl(link.getAttribute('href')) === hash
|
||||||
|
);
|
||||||
|
return matchingLink?.dataset.filter ?? 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveFilter(filterValue, updateHash = true) {
|
||||||
|
if (updateHash) {
|
||||||
|
if (filterValue === 'all') {
|
||||||
|
history.pushState(null, '', window.location.pathname);
|
||||||
|
} else {
|
||||||
|
const activeLink = linkMap.get(filterValue);
|
||||||
|
if (activeLink) {
|
||||||
|
const tagSlug = getTagSlugFromUrl(activeLink.getAttribute('href'));
|
||||||
|
history.pushState(null, '', `#${tagSlug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isAll = filterValue === 'all';
|
||||||
|
const display = isAll ? '' : 'none';
|
||||||
|
const ariaHidden = isAll ? 'false' : 'true';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
filterLinks.forEach(link => {
|
||||||
|
const isActive = link.dataset.filter === filterValue;
|
||||||
|
link.classList.toggle('active', isActive);
|
||||||
|
link.setAttribute('aria-pressed', isActive);
|
||||||
|
});
|
||||||
|
if (isAll) {
|
||||||
|
cardData.forEach(({ element }) => {
|
||||||
|
element.style.display = display;
|
||||||
|
element.setAttribute('aria-hidden', ariaHidden);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cardData.forEach(({ element, tags }) => {
|
||||||
|
const shouldShow = tags.includes(filterValue);
|
||||||
|
element.style.display = shouldShow ? '' : 'none';
|
||||||
|
element.setAttribute('aria-hidden', !shouldShow);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterContainer = filterLinks[0].parentElement.parentElement;
|
||||||
|
filterContainer.addEventListener('click', e => {
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (!link) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const filterValue = link.dataset.filter;
|
||||||
|
if (filterValue) setActiveFilter(filterValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterContainer.addEventListener('keydown', e => {
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (!link) return;
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
filterLinks.forEach(link => {
|
||||||
|
link.setAttribute('role', 'button');
|
||||||
|
link.setAttribute('aria-pressed', link.classList.contains('active'));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
setActiveFilter(getFilterFromHash(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialFilter = getFilterFromHash();
|
||||||
|
if (initialFilter !== 'all') {
|
||||||
|
setActiveFilter(initialFilter, false);
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded",()=>{var t=document.querySelectorAll(".card");const l=document.querySelectorAll(".filter-controls a");var e=document.querySelector("#all-projects-filter");if(t.length&&l.length){e.style.display="block";const s=new Map(Array.from(l).map(t=>[t.dataset.filter,t])),i=Array.from(t).map(t=>({element:t,tags:t.dataset.tags?.toLowerCase().split(",").filter(Boolean)??[]}));function o(t){return t.split("/").filter(Boolean).pop()}function a(){if(!window.location.hash)return"all";const e=decodeURIComponent(window.location.hash.slice(1));return Array.from(l).find(t=>o(t.getAttribute("href"))===e)?.dataset.filter??"all"}function r(a,t=!0){t&&("all"===a?history.pushState(null,"",window.location.pathname):(t=s.get(a))&&(t=o(t.getAttribute("href")),history.pushState(null,"","#"+t)));const e="all"===a,r=e?"":"none",n=e?"false":"true";requestAnimationFrame(()=>{l.forEach(t=>{var e=t.dataset.filter===a;t.classList.toggle("active",e),t.setAttribute("aria-pressed",e)}),e?i.forEach(({element:t})=>{t.style.display=r,t.setAttribute("aria-hidden",n)}):i.forEach(({element:t,tags:e})=>{e=e.includes(a),t.style.display=e?"":"none",t.setAttribute("aria-hidden",!e)})})}(e=l[0].parentElement.parentElement).addEventListener("click",t=>{var e=t.target.closest("a");e&&(t.preventDefault(),t=e.dataset.filter)&&r(t)}),e.addEventListener("keydown",t=>{var e=t.target.closest("a");!e||" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),e.click())}),l.forEach(t=>{t.setAttribute("role","button"),t.setAttribute("aria-pressed",t.classList.contains("active"))}),window.addEventListener("popstate",()=>{r(a(),!1)}),"all"!==(t=a())&&r(t,!1)}});
|
@ -0,0 +1,34 @@
|
|||||||
|
{#- Collect all terms. -#}
|
||||||
|
{#- We don't use `get_taxonomy` so users aren't forced to use 'tags' -#}
|
||||||
|
{% set all_terms = [] %}
|
||||||
|
{% for page in show_pages %}
|
||||||
|
{% if page.taxonomies %}
|
||||||
|
{% for tax_name, terms in page.taxonomies %}
|
||||||
|
{% for term in terms %}
|
||||||
|
{% set_global all_terms = all_terms | concat(with=term) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{#- Display unique terms -#}
|
||||||
|
{%- if all_terms -%}
|
||||||
|
<ul class="filter-controls" role="group" aria-label="{{ macros_translate::translate(key='project_filters', default='Project filters', language_strings=language_strings) }}">
|
||||||
|
<li class="taxonomy-item no-hover-padding">
|
||||||
|
<a id="all-projects-filter" class="no-hover-padding active"
|
||||||
|
href="{{ get_url(path="projects", lang=lang) }}"
|
||||||
|
data-filter="all">
|
||||||
|
{{- macros_translate::translate(key="all_projects", default="All projects", language_strings=language_strings) -}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for term in all_terms | unique | sort %}
|
||||||
|
<li class="taxonomy-item no-hover-padding">
|
||||||
|
<a class="no-hover-padding"
|
||||||
|
href="{{ get_taxonomy_url(kind="tags", name=term, lang=lang) }}"
|
||||||
|
data-filter="{{ term | lower }}">{{ term }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{#- Load the script -#}
|
||||||
|
<script src="{{ get_url(path='js/filterCards.min.js', trailing_slash=false, cachebust=true) | safe }}" defer></script>
|
||||||
|
{% endif %}
|