// 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;