// FilterPanel.js - Handles UI for filtering feed content
import cache from '../utils/cache';
import { debounce } from '../utils/formatters';
class FilterPanel {
constructor(container, options = {}) {
this.container = container;
this.options = {
taxonomies: [],
contentTypes: [],
onChange: null,
taxonomyFor: {},
...options
};
// Store references to key elements
this.selected = {};
this.selectedFiltersContainer = container.querySelector('.selected-items');
this.matchAll = container.querySelector('.toggle-text');
this.filterToggles = container.querySelectorAll('.filter-toggle');
this.filtersContainer = container.querySelector('.filters');
this.filterDropdowns = container.querySelectorAll('.filter-dropdown');
this.clearFiltersButton = container.querySelector('.clear-filters');
this.contentTypeInputs = container.querySelectorAll('input[name="content_type"]');
this.orderInputs = container.querySelectorAll('input[name="order"]');
// Local cache for terms
this.termCache = {
terms: new Map(),
timestamp: Date.now()
};
// Initialize
this.bindEvents();
}
/**
* Initialize filter dropdowns
*/
initSelectors() {
this.selectors = this.container.querySelectorAll('.jvb-selector');
this.selectorInstances = {};
let type = this.getCurrentContentType();
this.selectors.forEach(selector => {
if(selector.dataset.for.includes(type)){
const taxonomy = selector.dataset.taxonomy;
const options = selector.querySelector('.selected-items');
this.selectorInstances[taxonomy] = new TaxonomySelector(selector, {
multiple: true,
feed: true,
onSuccess: () => this.updateSelected(taxonomy)
});
}
});
}
updateSelected(taxonomy){
// selected = this.selectorInstances[taxonomy].selected;
let selected = this.selectorInstances[taxonomy].selectedItems;
}
/**
* New method to bind content type events
*/
bindContentTypeEvents() {
const contentTypeInputs = this.container.querySelectorAll('input[name="content_type"]');
contentTypeInputs.forEach(input => {
// Remove existing event listeners to prevent duplicates
input.removeEventListener('change', this._handleContentTypeChange);
// Store event handler as property so we can remove it later
this._handleContentTypeChange = (e) => {
if (input.checked) {
const contentType = input.value;
// First, directly update taxonomy and order filters UI
this.updateTaxonomyFilters(contentType);
this.updateOrderFilters(contentType);
this.handleFormChange();
}
};
// Add the event listener
input.addEventListener('change', this._handleContentTypeChange);
});
}
/**
* Replace the existing bindEvents method in FilterPanel
*/
bindEvents() {
// Form change events
this.container.addEventListener('submit', e => e.preventDefault());
this.container.addEventListener('change', this.handleFormChanges.bind(this));
this.container.addEventListener('click', this.handleToggleClick.bind(this));
// Add global keydown listener for Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeAllDropdowns();
}
});
// Dialog close events
this.filterDropdowns.forEach(dialog => {
dialog.addEventListener('close', this.handleDialogClose.bind(this));
dialog.addEventListener('click', (e) => {
// Check if click was directly on the dialog element (backdrop)
// and not on any of its children
if (e.target === dialog) {
dialog.close();
}
});
// Set up search inputs
const search = dialog.querySelector('input[type="search"]');
if (search) {
search.addEventListener('input', debounce(e => {
const taxonomy = dialog.dataset.taxonomy;
const query = e.target.value;
const container = dialog.querySelector('.options-container');
this.handleSearch(taxonomy, query, container);
}, 300));
}
});
// Clear filters button
if (this.clearFiltersButton) {
this.clearFiltersButton.addEventListener('click', this.clearFilters.bind(this));
}
}
/**
* Handle form change events
*/
handleFormChanges(e) {
const target = e.target;
// Skip changes in dialogs (handled separately)
if (target.closest('dialog')) return;
// Handle content type changes
if (target.name === 'content_type' && target.checked) {
const contentType = target.value;
this.updateTaxonomyFilters(contentType);
this.updateOrderFilters(contentType);
}
// Handle order type changes
if (target.name === 'order') {
this.handleOrderChange(target);
}
// For all changes, trigger form update
this.handleFormChange();
}
handleToggleClick(e) {
const toggle = e.target.closest('.filter-toggle');
if (!toggle) return;
e.preventDefault();
const wasExpanded = toggle.getAttribute('aria-expanded') === 'true';
this.closeAllDropdowns();
const taxonomyFilter = toggle.closest('[data-taxonomy]');
if (!taxonomyFilter) return;
const taxonomy = taxonomyFilter.dataset.taxonomy;
const dropdown = this.container.querySelector(
`.filter-dropdown[data-taxonomy="${taxonomy}"]`
);
if (!dropdown) return;
toggle.setAttribute('aria-expanded', !wasExpanded);
dropdown.showModal();
if (!wasExpanded) {
const search = dropdown.querySelector('input[type="search"]');
if (search) search.focus();
const options = dropdown.querySelector('.options-container');
if (options && options.children.length === 1) {
this.loadTaxonomyOptions(taxonomy, options);
}
}
}
/**
* Handle dialog close events
*/
handleDialogClose(e) {
const dialog = e.target;
const taxonomy = dialog.dataset.taxonomy;
const selected = this.selectorInstances[taxonomy].selectedItems;
if (Object.keys(selected).length > 0) {
this.updateFilters(taxonomy, selected);
}
}
/**
* Handle order type changes
*/
handleOrderChange(target) {
const orderDirection = this.container.querySelector('.order-direction');
if (target.value === 'random') {
orderDirection.hidden = true;
// Reset direction to default
this.container.querySelector('#order-desc').checked = true;
} else if (target.value === 'title') {
this.container.querySelector('#order-asc').checked = true;
orderDirection.hidden = false;
} else {
orderDirection.hidden = false;
}
}
/**
* Handle search input in taxonomy dialogs
*/
handleSearch(taxonomy, query, container) {
if (!container) return;
// Clear existing options
container.innerHTML = '';
container.setAttribute('data-loading', 'true');
// Debounced search function
if (this.searchDebounce) {
clearTimeout(this.searchDebounce);
}
this.searchDebounce = setTimeout(async () => {
try {
// Get terms - either from cache or API
let terms;
if (query) {
// Create cache key for search
const cacheKey = `terms_${taxonomy}_search_${query}`;
terms = cache.get(cacheKey);
if (!terms) {
// For search, always fetch from API if not cached
const response = await fetch(
`${window.feedSettings.apiUrl}terms/${taxonomy}?search=${encodeURIComponent(query)}`,
{
headers: {
'X-WP-Nonce': window.feedSettings.nonce
}
}
);
if (!response.ok) {
throw new Error('Failed to fetch terms');
}
const data = await response.json();
terms = data.terms;
// Cache search results for 5 minutes
cache.set(cacheKey, terms, 300000);
}
} else if (this.termCache.terms.has(taxonomy)) {
// Use cached terms if available
terms = this.termCache.terms.get(taxonomy);
} else {
// Fetch from API
const response = await fetch(
`${window.feedSettings.apiUrl}terms/${taxonomy}`,
{
headers: {
'X-WP-Nonce': window.feedSettings.nonce
}
}
);
if (!response.ok) {
throw new Error('Failed to fetch terms');
}
const data = await response.json();
terms = data.terms;
// Update cache
this.termCache.terms.set(taxonomy, terms);
this.termCache.timestamp = Date.now();
}
// Render terms
this.renderTermOptions(container, terms);
} catch (error) {
console.error('Error fetching terms:', error);
container.innerHTML = '
Failed to load options
';
} finally {
container.removeAttribute('data-loading');
}
}, 300);
}
/**
* Handle form change events
*/
handleFormChange() {
// Get current form data
const formData = this.getFormData();
// Update URL
this.updateURL(formData);
// Call change handler if available
if (typeof this.options.onChange === 'function') {
this.options.onChange(formData);
}
}
/**
* Get current form data
*/
getFormData() {
if (!this.container) return {};
const formData = new FormData(this.container);
// Get content type - explicitly check the checked radio button
const contentTypeInput = this.container.querySelector('input[name="content_type"]:checked');
const contentType = contentTypeInput ? contentTypeInput.value : this.options.defaultContent;
const filters = {
content: contentType,
order: formData.get('order') || 'date',
direction: formData.get('direction') || 'desc',
match: formData.get('match') || 'any',
};
for (const [taxonomy, selector] of Object.entries(this.selectorInstances)) {
if(Object.keys(selector.selectedItems).length > 0){
filters[taxonomy] = Object.keys(selector.selectedItems);
}
}
// Add favourites flag if present
if (formData.get('favourites_only')) {
filters.favouritesOnly = true;
}
return filters;
}
/**
* Update taxonomy filters based on content type
*/
updateTaxonomyFilters(contentType) {
if (!contentType) {
contentType = this.options.defaultContent || 'tattoo';
}
// Show/hide taxonomy filters based on content type
const filters = this.container.querySelectorAll('.taxonomy-filter');
filters.forEach(filter => {
const applicableTypes = filter.dataset.for.split(',');
const isVisible = applicableTypes.includes(contentType);
filter.hidden = !isVisible;
// Update content type for visible selectors
if (isVisible) {
const taxonomy = filter.dataset.taxonomy;
if (this.taxonomySelectors && this.taxonomySelectors[taxonomy]) {
this.taxonomySelectors[taxonomy].setContentType(contentType);
}
}
});
// Remove selected filters for hidden taxonomies
const hiddenFilters = this.container.querySelectorAll('.taxonomy-filter[hidden]');
hiddenFilters.forEach(filter => {
const taxonomy = filter.dataset.taxonomy;
const selectedItems = this.selectedFiltersContainer.querySelectorAll(
`.selected-item[data-taxonomy="${taxonomy}"]`
);
if (selectedItems.length > 0) {
selectedItems.forEach(item => item.remove());
}
});
this.initSelectors();
// Update clear filters button visibility
this.updateClearFiltersButton();
}
/**
* Get current content type
*/
getCurrentContentType() {
const contentTypeInput = this.container.querySelector('input[name="content_type"]:checked');
return contentTypeInput ? contentTypeInput.value : this.options.defaultContent;
}
/**
* Update order filters based on content type
*/
updateOrderFilters(contentType) {
if (!contentType) return;
const orderFilters = this.container.querySelectorAll('input[name="order"]');
orderFilters.forEach(filter => {
const applicableTypes = filter.dataset.for;
if (applicableTypes) {
const types = applicableTypes.split(',');
filter.hidden = !types.includes(contentType);
if (filter.hidden && filter.checked) {
// Reset to default
this.container.querySelector('input#order-date').checked = true;
this.container.querySelector('input[name="direction"][value="desc"]').checked = true;
}
}
});
}
/**
* Close all open dropdowns
*/
closeAllDropdowns() {
this.filterToggles.forEach(toggle => {
toggle.setAttribute('aria-expanded', 'false');
});
this.filterDropdowns.forEach(dialog => {
if (dialog.open) {
dialog.close();
}
});
}
/**
* Update URL parameters based on filters
*/
updateURL(filters) {
const params = new URLSearchParams();
// Add content type
if (filters.content && filters.content !== 'all') {
params.set('f_content', filters.content);
}
// Add order and direction
if (filters.order) {
params.set('f_order', filters.order);
if (filters.direction) {
params.set('f_direction', filters.direction);
}
}
// Add taxonomy filters
for (const [key, value] of Object.entries(filters)) {
if (Array.isArray(value) && value.length > 0) {
value.forEach(v => params.append('f_'+key, v));
}
}
// Add favourites filter
if (filters.favouritesOnly) {
params.set('f_favourites', '1');
}
// if(filters.match){
// params.set('f_match', filters.match);
// }
// Update URL without reloading page
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
history.pushState({ filters }, '', newUrl);
}
/**
* Update filters for a taxonomy
*/
updateFilters(taxonomy, values) {
this.selected[taxonomy] = Object.keys(values);
// Remove existing selected items for this taxonomy
const existingItems = this.selectedFiltersContainer.querySelectorAll(
`.selected-item[data-taxonomy="${taxonomy}"]`
);
existingItems.forEach(item => item.remove());
for (const [id, name] of Object.entries(values)) {
const filterTag = this.createFilterTag(taxonomy, id, name);
this.selectedFiltersContainer.appendChild(filterTag);
}
// Update clear filters button visibility
this.updateClearFiltersButton();
// Trigger form change
this.handleFormChange();
}
/**
* Create a filter tag element
*/
createFilterTag(taxonomy, id, name) {
const tag = document.createElement('span');
tag.className = 'selected-item';
tag.dataset.taxonomy = taxonomy;
tag.dataset.id = id;
const icon = feedSettings?.icons?.[taxonomy] || '';
tag.innerHTML = `
${icon}
${this.escapeHtml(name)}
`;
// Add click handler for remove button
tag.querySelector('.remove-item').addEventListener('click', () => {
// Uncheck checkbox if it exists
let taxonomy = tag.dataset.taxonomy;
delete this.selectorInstances[taxonomy].selectedItems[id];
// Remove tag
tag.remove();
// Update clear filters button visibility
this.updateClearFiltersButton();
// Trigger form change
this.handleFormChange();
});
return tag;
}
/**
* Update clear filters button visibility
*/
updateClearFiltersButton() {
if (!this.clearFiltersButton) return;
let filters = this.selectedFiltersContainer.children.length;
const hasFilters = filters > 0;
const hasMultiple = filters > 1;
this.clearFiltersButton.hidden = !hasFilters;
this.filtersContainer.classList.toggle('has-filters', hasFilters);
this.matchAll.hidden = !hasMultiple;
}
/**
* Clear all filters
*/
clearFilters() {
// Reset form
if (this.container) {
this.container.reset();
}
// Clear selected items
if (this.selectedFiltersContainer) {
this.selectedFiltersContainer.innerHTML = '';
}
// Uncheck all checkboxes in dropdowns
this.filterDropdowns.forEach(dialog => {
dialog.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.checked = false;
});
});
// Update clear filters button visibility
this.updateClearFiltersButton();
// Trigger form change
this.handleFormChange();
}
/**
* Load taxonomy options
*/
async loadTaxonomyOptions(taxonomy, container, page = 1) {
if (!container) return;
container.setAttribute('data-loading', 'true');
try {
// Check cache first (if first page and no search)
if (page === 1 && this.termCache.terms.has(taxonomy)) {
this.renderTermOptions(container, this.termCache.terms.get(taxonomy));
return;
}
// Create cache key
const cacheKey = `terms_${taxonomy}_page_${page}`;
let terms = cache.get(cacheKey);
if (!terms) {
// Fetch from API
const response = await fetch(
`${window.feedSettings.apiUrl}terms/${taxonomy}?page=${page}`,
{
headers: {
'X-WP-Nonce': window.feedSettings.nonce
}
}
);
if (!response.ok) {
throw new Error('Failed to fetch terms');
}
const data = await response.json();
terms = data.terms;
// Cache for 5 minutes
cache.set(cacheKey, terms, 300000);
// Update local cache if first page
if (page === 1) {
this.termCache.terms.set(taxonomy, terms);
this.termCache.timestamp = Date.now();
}
}
// Render terms
this.renderTermOptions(container, terms, page > 1);
} catch (error) {
console.error('Error fetching terms:', error);
if (container.children.length === 0) {
container.innerHTML = 'Failed to load options
';
}
} finally {
container.removeAttribute('data-loading');
}
}
/**
* Render term options in a dropdown
*/
renderTermOptions(container, terms = [], append = false) {
if (!container) return;
const termsArray = Array.isArray(terms) ? terms : Object.values(terms);
// Get the relevant taxonomy
const taxonomy = container.closest('.filter-dropdown')?.dataset.taxonomy;
if (!taxonomy) return;
// Get selected values
const selectedValues = this.getSelectedValues(taxonomy);
// Create HTML
const html = termsArray.map(term => {
const isChecked = selectedValues.includes(term.id.toString());
const displayName = term.display_name || term.name;
const inputId = `${taxonomy}-${term.id}`;
return `
`;
}).join('');
// Insert HTML
if (append) {
container.insertAdjacentHTML('beforeend', html);
} else {
container.innerHTML = html;
}
}
/**
* Get selected values for a taxonomy
*/
getSelectedValues(taxonomy) {
const selectedItems = this.selectedFiltersContainer.querySelectorAll(
`.selected-item[data-taxonomy="${taxonomy}"]`
);
return Array.from(selectedItems).map(item => item.dataset.id);
}
/**
* Get term details from cache
*/
getTermDetails(taxonomy, id) {
if (!this.termCache.terms.has(taxonomy)) return null;
const terms = this.termCache.terms.get(taxonomy);
return terms[id] || null;
}
/**
* Load form state from URL parameters
*/
loadFromURL() {
const params = new URLSearchParams(window.location.search);
// Reset form state
if (this.container) {
this.container.reset();
}
// Clear selected filters
if (this.selectedFiltersContainer) {
this.selectedFiltersContainer.innerHTML = '';
}
// Set content type
const contentType = params.get('f_content');
if (contentType) {
const contentTypeInput = this.container.querySelector(
`input[name="content_type"][value="${contentType}"]`
);
if (contentTypeInput) {
contentTypeInput.checked = true;
// Update visible taxonomies based on content type
this.updateTaxonomyFilters(contentType);
}
}
// Set order and direction
const order = params.get('f_order');
if (order) {
const orderInput = this.container.querySelector(`input[name="order"][value="${order}"]`);
if (orderInput) {
orderInput.checked = true;
this.updateOrderFilters(contentType || this.options.defaultContent);
}
}
const direction = params.get('f_direction');
if (direction) {
const directionInput = this.container.querySelector(`input[name="direction"][value="${direction}"]`);
if (directionInput) {
directionInput.checked = true;
}
}
// Set favourites filter
if (params.get('f_favourites') === '1') {
const favouritesCheckbox = this.container.querySelector('input[name="favourites_only"]');
if (favouritesCheckbox) {
favouritesCheckbox.checked = true;
}
}
// Find and set taxonomy filters
const taxonomyPromises = {};
// Process all potential taxonomy parameters
for (const [key, value] of params.entries()) {
if (key.startsWith('f_') && key !== 'f_content' &&
key !== 'f_order' && key !== 'f_direction' &&
key !== 'f_favourites' && key !== 'f_match') {
const taxName = key.replace('f_', '');
const termId = value;
// Create a promise to fetch term details and create a filter tag
if(!Object.hasOwnProperty(taxName)){
taxonomyPromises[taxName] = [];
}
taxonomyPromises[taxName].push(termId);
}
}
//Check all terms at once
this.fetchTermDetails(taxonomyPromises).then(()=>{
this.updateClearFiltersButton();
});
}
async fetchTermDetails(terms) {
for(const [taxonomy, termIds] of Object.entries(terms)){
termIds.forEach(termId => {
if(this.termCache.terms.has(taxonomy) && this.termCache.terms.get(taxonomy)[termId]){
//add to Selected from cache.
let name = this.termCache.terms.get(taxonomy)[termId].name;
const filterTag = this.createFilterTag(taxonomy, termId, name);
this.selectedFiltersContainer.appendChild(filterTag);
//remove any terms we already have
terms[taxonomy] = terms[taxonomy].filter(function(item) {
return item !== termId;
});
}
});
}
let params = new URLSearchParams(terms);
// Otherwise fetch the term details
try {
// Format the URL - might need to adjust based on your API structure
const url = `${window.feedSettings?.apiUrl}terms/check?`+params.toString();
const response = await fetch(url, {
headers: {
'X-WP-Nonce': window.feedSettings?.nonce || ''
}
});
if (!response.ok) {
throw new Error('Failed to fetch term details');
}
const termData = await response.json();
for(const [taxonomy, termIds] of Object.entries(termData.terms)){
if(!this.termCache.terms.has(taxonomy)){
this.termCache.terms.set(taxonomy, new Map());
}
for(const [termId, termData] of Object.entries(termIds)){
const filterTag = this.createFilterTag(taxonomy, termId, termData.name);
this.selectedFiltersContainer.appendChild(filterTag);
this.termCache.terms.get(taxonomy).set(parseInt(termId), termData);
}
}
return true;
} catch (error) {
console.error(`Error fetching term details for ${taxonomy}/${termId}:`, error);
return null;
}
}
/**
* Safe HTML escaping
*/
escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Set up focus traps for modals
*/
setupFocusTraps() {
this.filterDropdowns.forEach(dialog => {
dialog.addEventListener('show', () => {
// Store current focus to restore later
this._previouslyFocused = document.activeElement;
// Focus first focusable element
const focusable = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length) {
focusable[0].focus();
}
// Trap focus in modal
this.trapFocus(dialog);
});
dialog.addEventListener('close', () => {
// Restore focus
if (this._previouslyFocused) {
this._previouslyFocused.focus();
}
});
});
}
/**
* Trap focus within an element
*/
trapFocus(element) {
const focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements.length) return;
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
const trapHandler = function(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstFocusable) {
lastFocusable.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
firstFocusable.focus();
e.preventDefault();
}
};
// Store the handler to remove it later
element._trapHandler = trapHandler;
element.addEventListener('keydown', trapHandler);
}
}
export default FilterPanel;