Skip to content

Commit 44d2637

Browse files
Add multi-tag filtering on Resources page (#238)
* Trying out multi tag filtering as separate macro * Fix button deselect issue and cleanup js file * Update text
1 parent 3159295 commit 44d2637

File tree

5 files changed

+285
-2
lines changed

5 files changed

+285
-2
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use strict';
2+
3+
const resources = require('../../_data/resources.json');
4+
5+
/** @param {import("@11ty/eleventy/src/TemplateCollection")} api */
6+
function resourcesCollection(api) {
7+
return resources;
8+
}
9+
10+
module.exports = resourcesCollection;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{# Creates a section containing tags from the given categories that can be #}
2+
{# used to click to filter the items on a page based on those categories #}
3+
{# This version supports multi-tag filtering with client-side JavaScript #}
4+
{% macro create_multi_tag_filter(categories) %}
5+
<section class="container-xxl my-5">
6+
<h3 class="fw-bold">Categories</h3>
7+
<p>Click categories to filter results. Hold <kbd>Shift</kbd> to select multiple categories and show items that have all selected tags.</p>
8+
<div class="d-flex flex-row flex-wrap gap-3 mb-1" id="tag-filter-container">
9+
{% for category in categories %}
10+
{% if category !== "all" %}
11+
<button type="button"
12+
class="btn mg-outline-button d-flex justify-content-center text-capitalize fw-bold tag-filter-btn"
13+
data-tag="{{ category }}">
14+
{{ category }}
15+
</button>
16+
{% endif %}
17+
{% endfor %}
18+
</div>
19+
<div class="mt-3">
20+
<button type="button"
21+
class="btn btn-outline-secondary fw-bold px-4 py-2"
22+
id="clear-filters">
23+
Clear All Filters
24+
</button>
25+
</div>
26+
<div class="mt-3 text-muted" id="filter-status">
27+
<span id="results-count">{{ "All results" }}</span> •
28+
<span id="active-filters">No filters active</span>
29+
</div>
30+
</section>
31+
{% endmacro %}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
(() => {
2+
'use strict';
3+
4+
class ResourceTagFilter {
5+
constructor() {
6+
this.container = document.getElementById('resource-gallery');
7+
this.items = this.container ? Array.from(this.container.children) : [];
8+
this.selectedTags = new Set();
9+
10+
this.init();
11+
}
12+
13+
init() {
14+
if (!this.container) {
15+
console.warn('Resource gallery not found');
16+
return;
17+
}
18+
19+
this.bindEvents();
20+
21+
this.updateDisplay();
22+
this.updateFilterStatus();
23+
}
24+
25+
getItemTags(item) {
26+
// Resources use data-tags attribute
27+
if (item.dataset.tags) {
28+
return item.dataset.tags.split(',').map(tag => tag.trim().toLowerCase()).filter(tag => tag);
29+
}
30+
return [];
31+
}
32+
33+
bindEvents() {
34+
// Tag filter buttons
35+
const tagButtons = document.querySelectorAll('.tag-filter-btn');
36+
tagButtons.forEach(button => {
37+
button.addEventListener('click', (e) => this.handleTagClick(e));
38+
});
39+
40+
// Clear all button
41+
const clearButton = document.getElementById('clear-filters');
42+
if (clearButton) {
43+
clearButton.addEventListener('click', () => this.clearAllFilters());
44+
}
45+
46+
// Keyboard shortcuts
47+
document.addEventListener('keydown', (e) => {
48+
if (e.target.classList.contains('tag-filter-btn')) {
49+
if (e.key === 'Enter' || e.key === ' ') {
50+
e.preventDefault();
51+
this.handleTagClick(e);
52+
}
53+
}
54+
});
55+
}
56+
57+
handleTagClick(event) {
58+
const button = event.target;
59+
const tag = button.dataset.tag.toLowerCase();
60+
61+
// Handle multi-select with Shift key
62+
if (event.shiftKey) {
63+
this.toggleTag(tag, button);
64+
} else {
65+
this.clearAllFilters();
66+
this.toggleTag(tag, button);
67+
}
68+
69+
this.updateDisplay();
70+
this.updateFilterStatus();
71+
}
72+
73+
toggleTag(tag, button) {
74+
if (this.selectedTags.has(tag)) {
75+
this.selectedTags.delete(tag);
76+
button.classList.remove('active');
77+
button.setAttribute('aria-pressed', 'false');
78+
} else {
79+
this.selectedTags.add(tag);
80+
button.classList.add('active');
81+
button.setAttribute('aria-pressed', 'true');
82+
}
83+
84+
// Remove focus to prevent button from appearing highlighted after click
85+
button.blur();
86+
}
87+
88+
clearAllFilters() {
89+
this.selectedTags.clear();
90+
const tagButtons = document.querySelectorAll('.tag-filter-btn');
91+
tagButtons.forEach(button => {
92+
button.classList.remove('active');
93+
button.setAttribute('aria-pressed', 'false');
94+
});
95+
this.updateDisplay();
96+
this.updateFilterStatus();
97+
}
98+
99+
updateDisplay() {
100+
let visibleCount = 0;
101+
102+
this.items.forEach(item => {
103+
const itemTags = this.getItemTags(item);
104+
const shouldShow = this.shouldShowItem(itemTags);
105+
106+
if (shouldShow) {
107+
item.style.display = '';
108+
item.classList.remove('d-none');
109+
visibleCount++;
110+
} else {
111+
item.style.display = 'none';
112+
item.classList.add('d-none');
113+
}
114+
});
115+
}
116+
117+
shouldShowItem(itemTags) {
118+
// If no tags selected show all
119+
if (this.selectedTags.size === 0) {
120+
return true;
121+
}
122+
123+
// Check if item has ALL of the selected tags
124+
return Array.from(this.selectedTags).every(selectedTag =>
125+
itemTags.some(itemTag => itemTag === selectedTag)
126+
);
127+
}
128+
129+
updateFilterStatus() {
130+
const resultsCountElement = document.getElementById('results-count');
131+
const activeFiltersElement = document.getElementById('active-filters');
132+
133+
if (resultsCountElement) {
134+
const visibleCount = this.items.filter(item =>
135+
!item.classList.contains('d-none') && item.style.display !== 'none'
136+
).length;
137+
138+
resultsCountElement.textContent =
139+
visibleCount === this.items.length
140+
? 'All results'
141+
: `${visibleCount} of ${this.items.length} results`;
142+
}
143+
144+
if (activeFiltersElement) {
145+
if (this.selectedTags.size === 0) {
146+
activeFiltersElement.textContent = 'No filters active';
147+
} else if (this.selectedTags.size === 1) {
148+
const tagList = Array.from(this.selectedTags).join(', ');
149+
activeFiltersElement.textContent = `Showing: ${tagList}`;
150+
} else {
151+
const tagList = Array.from(this.selectedTags).join(' + ');
152+
activeFiltersElement.textContent = `Showing items with: ${tagList}`;
153+
}
154+
}
155+
}
156+
}
157+
158+
document.addEventListener('DOMContentLoaded', function() {
159+
const resourceGallery = document.getElementById('resource-gallery');
160+
if (resourceGallery) {
161+
new ResourceTagFilter();
162+
}
163+
});
164+
})();
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
title: Resources
3+
---
4+
{% extends "layouts/base.layout.njk" %}
5+
{% from 'macros/create_multi_tag_filter.njk' import create_multi_tag_filter %}
6+
7+
{% block content %}
8+
<section class="container-xxl my-5">
9+
<h1 id="monogame-resource" class="fw-bold"><a href="#monogame-resource">MonoGame Resources</a></h1>
10+
<p>
11+
Here's a list of MonoGame resources that can help you on your journey.
12+
If you have a resource you'd like to share, please <a href="mailto:[email protected]?subject=New%20Resource&body=Hi,%20Please%20add%20this%20resource%20to%20your%20list:%0A%0ATitle:%20%0AAuthor:%20%0AResource%20URL:%20%0AImage%20URL:%20%0ATags:%20">
13+
Email Us
14+
</a>.
15+
</p>
16+
</section>
17+
18+
{{ create_multi_tag_filter(collections.resourceTags, "resource-gallery") }}
19+
20+
<section class="container-xxl mb-5">
21+
<div id="resource-gallery" class="mg-item-grid mg-grid-2">
22+
{% for resource in resources %}
23+
<a class="mg-no-link hide-external-icon"
24+
href="{{ resource.url }}"
25+
data-tags="{{ resource.tags | join(',') | lower }}">
26+
<div class="mg-resource-container mg-box-shadow"
27+
style="background-image: url('{{ resource.cover }}');">
28+
<div class="transparent-overlay"></div>
29+
<div class="mg-resource-tags">
30+
{% for tag in resource.tags %}
31+
{% if loop.index <= 3 %}
32+
<div>{{ tag }}</div>
33+
{% endif %}
34+
{% endfor %}
35+
36+
{% if resource.tags.length > 3 %}
37+
{% set additionalTags = '' %}
38+
{% for tag in resource.tags %}
39+
{% if loop.index0 >= 3 %}
40+
{% set additionalTags = additionalTags + tag + '<br/>' %}
41+
{% endif %}
42+
{% endfor %}
43+
<div data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" data-bs-title="{{ additionalTags }}">
44+
<div>+{{ resource.tags.length - 3 }}</div>
45+
</div>
46+
{% endif %}
47+
48+
</div>
49+
<div class="mg-resource-footer">
50+
<div class="mg-resource-title">{{ resource.title }}</div>
51+
<div class="mg-resource-author">by {{ resource.author }}</div>
52+
</div>
53+
</div>
54+
</a>
55+
{% endfor %}
56+
</div>
57+
</section>
58+
{% endblock %}
59+
60+
{% block scripts %}
61+
<script type="text/javascript" src="/js/multiTagFilter.js"></script>
62+
<script type="text/javascript">
63+
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
64+
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
65+
</script>
66+
{% endblock %}

website/content/resources.njk

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ permalink: resources/{{ (category | slugify) if category !== "all" }}/
88
---
99
{% extends "layouts/base.layout.njk" %}
1010
{% from 'macros/create_category_filter.njk' import create_category_filter %}
11+
{% from 'macros/create_multi_tag_filter.njk' import create_multi_tag_filter %}
1112

1213
{% block content %}
1314
<section class="container-xxl my-5">
@@ -20,13 +21,21 @@ permalink: resources/{{ (category | slugify) if category !== "all" }}/
2021
</p>
2122
</section>
2223

23-
{{ create_category_filter(collections.resourceTags, "/resources/", page.url) }}
24+
{% if category === "all" %}
25+
{# Show multi-tag filter only on the main resources page #}
26+
{{ create_multi_tag_filter(collections.resourceTags) }}
27+
{% else %}
28+
{# Show single-tag filter on category pages #}
29+
{{ create_category_filter(collections.resourceTags, "/resources/", page.url) }}
30+
{% endif %}
2431

2532
<section class="container-xxl mb-5">
2633
<div id="resource-gallery" class="mg-item-grid mg-grid-2">
2734
{% for resource in resources %}
2835
{% if category in resource.tags or category === "all" %}
29-
<a class="mg-no-link hide-external-icon" href="{{ resource.url }}">
36+
<a class="mg-no-link hide-external-icon"
37+
href="{{ resource.url }}"
38+
data-tags="{{ resource.tags | join(',') | lower }}">
3039
<div class="mg-resource-container mg-box-shadow"
3140
style="background-image: url('{{ resource.cover }}');">
3241
<div class="transparent-overlay"></div>
@@ -63,6 +72,9 @@ permalink: resources/{{ (category | slugify) if category !== "all" }}/
6372
{% endblock %}
6473

6574
{% block scripts %}
75+
{% if category === "all" %}
76+
<script type="text/javascript" src="/js/multiTagFilter.js"></script>
77+
{% endif %}
6678
<script type="text/javascript">
6779
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
6880
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));

0 commit comments

Comments
 (0)