I used the accepted answer here as a starting-off point, and I came up with this solution. I know this looks a little convoluted, but it's actually quite minimal given the the size of the site I'm currently working on. This uses no jquery, so quite basic, vanilla JS. This appeals to me for this project, but this is easily jquery'fied if you want to.
A few notes:
- This uses radio button
<input>
's in order to filter the entries as they are also used to feed the search feature with a category.
- I'm using "pretty" permalinks all over, so no ugly
?category=
parameters in the URLs. In other words, you do NOT need the main listing page, category listing page or the entry itself to even be related by template.
/articles/
points to "articles/listing.twig"
/articles/animal/{slug}
points to "articles/animal-category.twig"
/articles/{entry slug}
points to "article.twig"
The main entry listing page (/articles/
) as well as the category entry listing page (/articles/animal/{slug}
) both use essentially the same template, apart from a few minor details. These, however, differ in everyone's site so I'm going to disregard them here.
articles/listing.twig
and articles/animal-category.twig
(differences noted in comments at the end of the lines):
{% extends "_layouts/base" %}
{% block content %}
{% set categoryQuery = craft.app.request.getParam('category') %} {# LISTING #}
{% set categoryQuery = category %} {# ANIMAL-CATEGORY #}
{% set searchQuery = craft.app.request.getParam('search') %}
{% set pageQuery = craft.app.request.getParam('page') %}
{% set search = '?search='~ searchQuery ~ '&category=' ~ categoryQuery %}
{% set categories = craft.categories.group('animal').all %}
<section class="relative">
<header class="relative z-10">
{% if categoryQuery is not null and categoryQuery != '' %}
{% set categoryObject = craft.categories.id(categoryQuery).one() %}
<div id="articleTitleContainer"><h1>{{ categoryObject.title }}</h1></div>
{% else %}
<div id="articleTitleContainer">{{ entry.redactorSimple }}</div>
{% endif %}
</header>
</section>
{% include "articles/articles-search-bar" %} {# LISTING #}
{% include "articles/articles-search-bar" with { categoryQuery: categoryQuery } %} {# ANIMAL-CATEGORY #}
{% set articleEntries = craft.entries
.section('articles')
.search(searchQuery)
.relatedTo(categoryQuery)
.limit(21)
%}
{% paginate articleEntries as articleInfo, articles %}
<section class="relative">
<div id="article-loading-spinner"></div>
<div class="container">
{% if articles|length == 0 %}
<div class="articles articlesContainer" id="articleContainer">
<p>{{ 'No results found'|t }}</p>
</div>
{% else %}
<div class="articles articlesContainer" id="articleContainer">
{% for article in articles %}
<a href="{{ article.url }}" class="article-card">
<h2 class="article-card__title text-lg">{{ article.title }}</h2>
<p class="article-card__intro">{{ article.articleIntro|slice(0, 200) ~ '...' }}</p>
</a>
{% endfor %}
</div>
{% endif %}
<section class="mb-4 mt-4">
<div class="max-w-2xl mx-auto px-3" id="articlePagination">
{% if articles|length > 0 %}
{% include "pagination" with {
currentPage: articleInfo.currentPage,
lastPage: articleInfo.totalPages,
searchTerm: search,
} %}
{% endif %}
</div>
</section>
</div>
</section>
{% endblock %}
{% block footer %}
{{ parent() }}
{% include "articles/article-js-inline.js.twig" %}
{% endblock %}
articles-search-bar.twig
(the important bits, all my layout is removed):
<form>
{# Static "all" selector. #}
<div class="item category-all">
<label>
<input onClick="onFilterClick('all', '')" type="radio" id="c_all" name="category" value="" checked>
<div class="icon category-all"></div>
<span>{{ 'All'|t }}</span>
</label>
</div>
{% for categoryOption in categories %}
{% if categoryOption.id is not null %}
{% set categoryUrl = siteUrl ~ categoryOption.uri %}
{% set catID = categoryOption.id %}
<div class="item category-{{ catID }}">
<label>
{% set entryCount = craft.entries.relatedTo(categoryOption).count() %}
{% if entryCount > 0 %}
<input onClick="onFilterClick({{ catID }}, '{{ categoryOption.title }}', '{{ categoryUrl }}')" class="filter-button" type="radio" id="c_{{ catID }}" name="category"
{% if categoryQuery is not null %}
value="{{ catID }}" {{ categoryQuery.id == catID ? 'checked' }}
{% endif %}
>
<div class="icon category-{{ categoryOption.id }}"></div>
<span>{{ categoryOption.title }}</span>
{% endif %}
</label>
</div><!-- .item END -->
{% endif %}
{% endfor %}
{# Dynamically change placeholder based on category ticked in. #}
{% set placeholder = 'Search for...'|t %}
{% if categoryQuery is not null and categoryQuery != '' %}
{% set categoryObject = craft.categories.id(categoryQuery).one() %}
{% set placeholder = 'Search within'|t ~ ' ' ~ categoryObject.title %}
{% endif %}
<input
class=""
placeholder="{{ placeholder }}"
aria-label="{{ 'Search articles'|t }}"
value="{{ searchQuery ? craft.app.request.getParam('search') : '' }}"
name="search"
id="article-search-field"
>
<button type="submit">
<span class="font-bold">{{ 'Search'|t }}</span>
</button>
</form>
What makes this work at all. article-js-inline.js.twig
:
Note: There are some twig variables in here, which is why this is loaded inline and not in a separate .js file. I want it inline anyways, since it's only used in essentially one template.
{% js %}
var historySupport = !!(window.history && history.pushState); // HTML5 history
function onFilterClick (categoryId, categoryName, categoryUrl) {
{% set section = craft.entries().section('articlesListing').one() %}
{% set sectionUrl = section.getUrl() %}
let targetUrl;
if (categoryId == 'all') {
targetUrl = '{{ sectionUrl }}';
} else {
targetUrl = categoryUrl;
}
showLoading();
fetch(targetUrl)
.then(response => response.text())
.then(data => {
refreshArticles(data, categoryName, targetUrl)
})
.catch(error => {
console.log(error)
});
}
function showLoading() {
let loading = document.getElementById('article-loading-spinner');
loading.innerHTML = '<img src="/assets/images/pulse.svg" class="" />';
loading.classList.remove('hidden');
loading.classList.add('block');
}
function hideLoading() {
let loading = document.getElementById('article-loading-spinner');
loading.innerHTML = '';
loading.classList.remove('block');
loading.classList.add('hidden');
}
function changePlaceholderAndResetValue(categoryName) {
let searchField = document.getElementById('article-search-field');
searchField.value = '';
if (categoryName) {
searchField.placeholder = '{{ 'Search within'|t }} ' + categoryName + '...';
} else {
searchField.placeholder = '{{ 'Search for...'|t }}';
}
}
function actualRefresh(data, categoryName = '') {
let newTitle = '{{ 'Articles and Guides'|t }}: ' + categoryName;
document.title = newTitle;
let dom = new DOMParser().parseFromString(data, 'text/html'); // Temp DOM.
let newHTML = dom.getElementById('articleContainer');
let newHTMLpagination = dom.getElementById('articlePagination');
// Target HTML
let htmlTarget = document.getElementById('articleContainer');
let htmlTargetTitle = document.getElementById('articleTitleContainer');
let htmlTargetPagination = document.getElementById('articlePagination');
if (!newHTML) {
htmlTarget.innerHTML = '<p>{{ 'No results found'|t }}</p>';
} else {
htmlTarget.innerHTML = newHTML.innerHTML;
}
if (!newHTMLpagination) {
htmlTargetPagination.innerHTML = '';
} else {
htmlTargetPagination.innerHTML = newHTMLpagination.innerHTML;
}
if (categoryName) {
htmlTargetTitle.innerHTML = '<div class="c-redactor"><h1>' + categoryName + '</h1></div>';
} else {
htmlTargetTitle.innerHTML = '<div class="c-redactor"><h1>{{ 'Articles and Guides'|t }}</h1></div>';
}
}
function refreshArticles (data, categoryName, targetUrl) {
actualRefresh(data, categoryName)
hideLoading();
if (historySupport) {
// Update browser address bar.
history.pushState(null, null, targetUrl);
}
changePlaceholderAndResetValue(categoryName)
}
{% endjs %}
I'm not gonna bother posting the CSS unless someone really needs it since it's all Tailwind + some icons for the radio buttons.
I hope this is useful to someone.