Full Modular Solution @slawek @Support-Luis @aleksandr.hlahola
With the help of AI, I’ve managed to create a robust, modular solution. I’ve included various enhancements to improve the overall handling of separate tags in the Core Filter System.
For example, the price range slider detects the most valuable item in a collection to set the max. value, and the tag only shows if a range is set to something other than the default. The tag displays as one, with a min. to max. value. e.g. $0–$100 (see Notes below)
Element structure (update your existing Finsweet Attributes V2 elements to include these two ID’s)
<div id="filter-tags" class="filter-tags" role="list" aria-label="Filter tags"></div>
<div class="tag-wrapper"> <!--Set to display: none; so you don't see placeholder-->
<div id="tag" class="filter-tag">
<div fs-list-element="tag-field" class="tag-field"></div>
<div fs-list-element="tag-operator" class="tag-operator"></div>
<div fs-list-element="tag-value" class="tag-value"></div>
<button fs-list-element="tag-remove" class="remove-tag-btn"></button>
</div>
</div>
Note: Keep these separate! Don’t nest the .tag
inside .filter-tags
or the kittens will die!
Core Filter System (you need this for the rest of the scripts to work)
core-filter-system.js
Shared utilities and base functionality for all filter types
<script>
document.addEventListener('DOMContentLoaded', () => {
// Make core functions globally available
window.FilterSystem = {
// Utility: Convert string to Title Case (handles spaces)
toTitleCase(str) {
return str.replace(/\w\S*/g, txt =>
txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
);
},
// Detect user locale and currency
getUserCurrency() {
const userLocale = navigator.language || 'en-GB';
const localeCurrencyMap = {
'en-GB': 'GBP',
'en-US': 'USD',
'en-CA': 'CAD',
'en-AU': 'AUD',
};
const userCurrency = localeCurrencyMap[userLocale] || 'GBP';
const currencySymbol = new Intl.NumberFormat(userLocale, {
style: 'currency',
currency: userCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).formatToParts(0).find(part => part.type === 'currency')?.value || '£';
return { userLocale, userCurrency, currencySymbol };
},
// Get DOM elements
getElements() {
return {
tagContainer: document.getElementById('filter-tags'),
tagTemplate: document.getElementById('tag'),
liveRegion: document.getElementById('tag-live-region')
};
},
// Setup accessibility
setupAccessibility() {
const { tagContainer } = this.getElements();
if (!tagContainer) return;
tagContainer.setAttribute('role', 'list');
tagContainer.setAttribute('aria-label', 'Active filter tags');
let liveRegion = document.getElementById('tag-live-region');
if (!liveRegion) {
liveRegion = document.createElement('div');
liveRegion.id = 'tag-live-region';
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.style.position = 'absolute';
liveRegion.style.width = '1px';
liveRegion.style.height = '1px';
liveRegion.style.margin = '-1px';
liveRegion.style.border = '0';
liveRegion.style.padding = '0';
liveRegion.style.overflow = 'hidden';
liveRegion.style.clip = 'rect(0 0 0 0)';
tagContainer.parentNode.insertBefore(liveRegion, tagContainer.nextSibling);
}
},
// Announce changes for screen readers
announce(message) {
const { tagContainer } = this.getElements();
if (!tagContainer) return;
const liveRegion = document.getElementById('tag-live-region');
if (liveRegion) {
liveRegion.textContent = '';
setTimeout(() => {
liveRegion.textContent = message;
}, 100);
}
},
// Remove tag with animation
removeTag(field) {
const { tagContainer } = this.getElements();
const tag = tagContainer.querySelector(`[data-field="${field}"]`);
if (tag) {
const labelValue = tag.querySelector('.tag-value')?.textContent || '';
tag.classList.add('tag-exit');
setTimeout(() => {
tag.remove();
this.announce(`Removed filter: ${field} ${labelValue}`);
}, 300);
}
},
// Refresh list if available
refreshList() {
if (window.fsAttributes?.list) {
window.fsAttributes.list.refresh();
}
}
};
// Initialize accessibility
window.FilterSystem.setupAccessibility();
});
</script>
Checkbox Filter Handler (optional)
checkbox-filter.js
Handles checkbox filter tags
<script>
document.addEventListener('DOMContentLoaded', () => {
// Wait for core system to be available
if (!window.FilterSystem) {
console.error('FilterSystem not available. Make sure core-filter-system.js loads first.');
return;
}
const CheckboxFilter = {
// Create checkbox tag
createTag(field, value) {
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
const tag = tagTemplate.cloneNode(true);
tag.removeAttribute('id');
tag.style.display = '';
tag.setAttribute('data-field', field);
tag.setAttribute('data-value', value);
tag.setAttribute('role', 'listitem');
const labelValue = window.FilterSystem.toTitleCase(value);
tag.querySelector('.tag-field').textContent = field;
tag.querySelector('.tag-operator').textContent = ':';
tag.querySelector('.tag-value').textContent = labelValue;
tag.classList.add('tag-enter');
setTimeout(() => tag.classList.remove('tag-enter'), 300);
// Setup remove button
const removeBtn = tag.querySelector('.remove-tag-btn');
if (removeBtn) {
removeBtn.setAttribute('aria-label', `Remove filter: ${field} ${labelValue}`);
removeBtn.addEventListener('click', () => {
this.removeTagAndUncheck(field, value, tag, labelValue);
});
}
window.FilterSystem.announce(`Added filter: ${field} ${labelValue}`);
return tag;
},
// Remove tag and uncheck checkbox
removeTagAndUncheck(field, value, tag, labelValue) {
// Uncheck the checkbox
const checkbox = document.querySelector(`input[type="checkbox"][fs-list-field="${field}"][fs-list-value="${value}"]`);
if (checkbox) {
checkbox.checked = false;
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
}
// Remove tag with animation
tag.classList.add('tag-exit');
setTimeout(() => {
tag.remove();
window.FilterSystem.announce(`Removed filter: ${field} ${labelValue}`);
window.FilterSystem.refreshList();
}, 300);
},
// Handle checkbox change
handleCheckboxChange(checkbox) {
const field = checkbox.getAttribute('fs-list-field');
const value = checkbox.getAttribute('fs-list-value');
if (!field || !value) return;
const { tagContainer } = window.FilterSystem.getElements();
const existingTag = tagContainer.querySelector(`[data-field="${field}"][data-value='${value}']`);
if (checkbox.checked) {
if (!existingTag) {
const tag = this.createTag(field, value);
tagContainer.appendChild(tag);
}
} else if (existingTag) {
const labelValue = window.FilterSystem.toTitleCase(value);
existingTag.classList.add('tag-exit');
setTimeout(() => {
existingTag.remove();
window.FilterSystem.announce(`Removed filter: ${field} ${labelValue}`);
}, 300);
}
},
// Initialize checkbox tags from existing checked states
initTags() {
document.querySelectorAll('input[type="checkbox"][fs-list-field]').forEach(checkbox => {
if (checkbox.checked) {
const field = checkbox.getAttribute('fs-list-field');
const value = checkbox.getAttribute('fs-list-value');
if (field && value) {
const { tagContainer } = window.FilterSystem.getElements();
const tag = this.createTag(field, value);
tagContainer.appendChild(tag);
}
}
});
},
// Bind event listeners
bindEvents() {
document.querySelectorAll('input[type="checkbox"][fs-list-field]').forEach(checkbox => {
checkbox.addEventListener('change', () => this.handleCheckboxChange(checkbox));
});
}
};
// Initialize checkbox filters
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
if (tagContainer && tagTemplate) {
CheckboxFilter.initTags();
CheckboxFilter.bindEvents();
}
});
</script>
Radio Button Filter Handler (optional)
radio-filter.js
Handles radio button filter tags
<script>
document.addEventListener('DOMContentLoaded', () => {
// Wait for core system to be available
if (!window.FilterSystem) {
console.error('FilterSystem not available. Make sure core-filter-system.js loads first.');
return;
}
const RadioFilter = {
// Create radio tag
createTag(field, value) {
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
const tag = tagTemplate.cloneNode(true);
tag.removeAttribute('id');
tag.style.display = '';
tag.setAttribute('data-field', field);
tag.setAttribute('data-value', value);
tag.setAttribute('role', 'listitem');
const labelValue = window.FilterSystem.toTitleCase(value);
tag.querySelector('.tag-field').textContent = field;
tag.querySelector('.tag-operator').textContent = ':';
tag.querySelector('.tag-value').textContent = labelValue;
tag.classList.add('tag-enter');
setTimeout(() => tag.classList.remove('tag-enter'), 300);
// Setup remove button
const removeBtn = tag.querySelector('.remove-tag-btn');
if (removeBtn) {
removeBtn.setAttribute('aria-label', `Remove filter: ${field} ${labelValue}`);
removeBtn.addEventListener('click', () => {
this.removeTagAndUncheck(field, value, tag, labelValue);
});
}
window.FilterSystem.announce(`Added filter: ${field} ${labelValue}`);
return tag;
},
// Remove tag and uncheck radio
removeTagAndUncheck(field, value, tag, labelValue) {
// Uncheck the radio button
const radio = document.querySelector(`input[type="radio"][fs-list-field="${field}"][fs-list-value="${value}"]`);
if (radio) {
radio.checked = false;
radio.dispatchEvent(new Event('input', { bubbles: true }));
radio.dispatchEvent(new Event('change', { bubbles: true }));
}
// Remove tag with animation
tag.classList.add('tag-exit');
setTimeout(() => {
tag.remove();
window.FilterSystem.announce(`Removed filter: ${field} ${labelValue}`);
window.FilterSystem.refreshList();
}, 300);
},
// Handle radio button change
handleRadioChange(radio) {
const field = radio.getAttribute('fs-list-field');
const value = radio.getAttribute('fs-list-value');
if (!field || !value) return;
const { tagContainer } = window.FilterSystem.getElements();
if (radio.checked) {
// Remove any existing tag for this field (since radio buttons are exclusive)
const existingTag = tagContainer.querySelector(`[data-field="${field}"]`);
if (existingTag) {
existingTag.remove();
}
// Create new tag
const tag = this.createTag(field, value);
tagContainer.appendChild(tag);
}
},
// Initialize radio tags from existing checked states
initTags() {
document.querySelectorAll('input[type="radio"][fs-list-field]').forEach(radio => {
if (radio.checked) {
const field = radio.getAttribute('fs-list-field');
const value = radio.getAttribute('fs-list-value');
if (field && value) {
const { tagContainer } = window.FilterSystem.getElements();
const tag = this.createTag(field, value);
tagContainer.appendChild(tag);
}
}
});
},
// Bind event listeners
bindEvents() {
document.querySelectorAll('input[type="radio"][fs-list-field]').forEach(radio => {
radio.addEventListener('change', () => this.handleRadioChange(radio));
});
}
};
// Initialize radio filters
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
if (tagContainer && tagTemplate) {
RadioFilter.initTags();
RadioFilter.bindEvents();
}
});
</script>
Search Filter Handler (optional)
search-filter.js
Handles search/text input filter tags
<script>
document.addEventListener('DOMContentLoaded', () => {
// Wait for core system to be available
if (!window.FilterSystem) {
console.error('FilterSystem not available. Make sure core-filter-system.js loads first.');
return;
}
const SearchFilter = {
// Create search tag
createTag(field, value) {
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
const tag = tagTemplate.cloneNode(true);
tag.removeAttribute('id');
tag.style.display = '';
tag.setAttribute('data-field', field);
tag.setAttribute('data-value', value);
tag.setAttribute('role', 'listitem');
const labelValue = window.FilterSystem.toTitleCase(value);
tag.querySelector('.tag-field').textContent = field;
tag.querySelector('.tag-operator').textContent = ':';
tag.querySelector('.tag-value').textContent = labelValue;
tag.classList.add('tag-enter');
setTimeout(() => tag.classList.remove('tag-enter'), 300);
// Setup remove button
const removeBtn = tag.querySelector('.remove-tag-btn');
if (removeBtn) {
removeBtn.setAttribute('aria-label', `Remove filter: ${field} ${labelValue}`);
removeBtn.addEventListener('click', () => {
this.removeTagAndClearInput(field, tag, labelValue);
});
}
window.FilterSystem.announce(`Added filter: ${field} ${labelValue}`);
return tag;
},
// Remove tag and clear input
removeTagAndClearInput(field, tag, labelValue) {
// Clear the input
const input = document.querySelector(`input[fs-list-field="${field}"]:not([type="checkbox"]):not([type="radio"]):not([type="range"])`);
if (input) {
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
// Remove tag with animation
tag.classList.add('tag-exit');
setTimeout(() => {
tag.remove();
window.FilterSystem.announce(`Removed filter: ${field} ${labelValue}`);
window.FilterSystem.refreshList();
}, 300);
},
// Update or create tag
updateOrCreateTag(field, value) {
const { tagContainer } = window.FilterSystem.getElements();
const existingTag = tagContainer.querySelector(`[data-field="${field}"]`);
if (existingTag) {
// Update existing tag
existingTag.setAttribute('data-value', value);
const labelValue = window.FilterSystem.toTitleCase(value);
existingTag.querySelector('.tag-value').textContent = labelValue;
// Update remove button aria-label
const removeBtn = existingTag.querySelector('.remove-tag-btn');
if (removeBtn) {
removeBtn.setAttribute('aria-label', `Remove filter: ${field} ${labelValue}`);
}
window.FilterSystem.announce(`Updated filter: ${field} ${labelValue}`);
} else {
// Create new tag
const newTag = this.createTag(field, value);
tagContainer.appendChild(newTag);
}
},
// Handle search input change
handleInputChange(input) {
const field = input.getAttribute('fs-list-field');
if (!field) return;
const value = input.value.trim();
if (value) {
this.updateOrCreateTag(field, value);
} else {
window.FilterSystem.removeTag(field);
}
},
// Initialize search tags from existing values
initTags() {
document.querySelectorAll('input[fs-list-field]').forEach(input => {
// Skip checkboxes, radios, and range inputs (handled by other modules)
if (input.type === 'checkbox' || input.type === 'radio' || input.type === 'range') {
return;
}
// Skip price field inputs (handled by price range module)
const field = input.getAttribute('fs-list-field');
if (field === 'price') return;
if (input.value.trim()) {
const { tagContainer } = window.FilterSystem.getElements();
const tag = this.createTag(field, input.value.trim());
tagContainer.appendChild(tag);
}
});
},
// Bind event listeners
bindEvents() {
document.querySelectorAll('input[fs-list-field]').forEach(input => {
// Skip checkboxes, radios, and range inputs
if (input.type === 'checkbox' || input.type === 'radio' || input.type === 'range') {
return;
}
// Skip price field inputs
const field = input.getAttribute('fs-list-field');
if (field === 'price') return;
input.addEventListener('input', () => this.handleInputChange(input));
input.addEventListener('change', () => this.handleInputChange(input));
});
}
};
// Initialize search filters
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
if (tagContainer && tagTemplate) {
SearchFilter.initTags();
SearchFilter.bindEvents();
}
});
</script>
Price range Filter Handler (optional)
price-range-filter.js
Handles price range filter tags
<script>
document.addEventListener('DOMContentLoaded', () => {
// Wait for core system to be available
if (!window.FilterSystem) {
console.error('FilterSystem not available. Make sure core-filter-system.js loads first.');
return;
}
const PriceRangeFilter = {
maxPrice: 0,
initialMin: 0,
initialMax: 0,
currencySymbol: '£',
// Initialize price calculations
init() {
// Get currency info
const { currencySymbol } = window.FilterSystem.getUserCurrency();
this.currencySymbol = currencySymbol;
// Calculate max price from CMS rendered items
document.querySelectorAll('.item-price').forEach(el => {
const rawText = el.textContent.trim().replace(/[^\d.]/g, '');
const value = parseFloat(rawText);
if (!isNaN(value) && value > this.maxPrice) this.maxPrice = value;
});
this.maxPrice = Math.ceil(this.maxPrice);
this.initialMin = 0;
this.initialMax = this.maxPrice;
// Update range slider max attributes
this.updateRangeSlider();
// Format initial inputs
this.formatInitialInputs();
},
// Update range slider configuration
updateRangeSlider() {
const sliderWrapper = document.querySelector('#Active-Filters');
if (sliderWrapper && this.maxPrice > 0) {
sliderWrapper.setAttribute('fs-rangeslider-max', this.maxPrice);
const handles = sliderWrapper.querySelectorAll('[fs-rangeslider-element="handle"]');
handles.forEach(handle => {
handle.setAttribute('aria-valuemax', this.maxPrice.toFixed(2));
});
if (window.FsRangeSlider?.init) {
window.FsRangeSlider.init();
}
}
},
// Format inputs to remove decimals
formatNumberWithoutDecimals(input) {
let value = input.value;
if (value && !isNaN(value)) {
input.value = Math.round(parseFloat(value));
}
},
// Format initial inputs
formatInitialInputs() {
const fromInput = document.getElementById('From');
const toInput = document.getElementById('To');
if (fromInput) this.formatNumberWithoutDecimals(fromInput);
if (toInput) this.formatNumberWithoutDecimals(toInput);
},
// Create price range tag
createTag(field, value) {
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
const tag = tagTemplate.cloneNode(true);
tag.removeAttribute('id');
tag.style.display = '';
tag.setAttribute('data-field', field);
tag.setAttribute('data-value', JSON.stringify(value));
tag.setAttribute('role', 'listitem');
const labelValue = `${this.currencySymbol}${value.min}–${this.currencySymbol}${value.max}`;
tag.querySelector('.tag-field').textContent = field;
tag.querySelector('.tag-operator').textContent = ':';
tag.querySelector('.tag-value').textContent = labelValue;
tag.classList.add('tag-enter');
setTimeout(() => tag.classList.remove('tag-enter'), 300);
// Setup remove button
const removeBtn = tag.querySelector('.remove-tag-btn');
if (removeBtn) {
removeBtn.setAttribute('aria-label', `Remove filter: ${field} ${labelValue}`);
removeBtn.addEventListener('click', () => {
this.removeTagAndResetInputs(tag, labelValue);
});
}
window.FilterSystem.announce(`Added filter: ${field} ${labelValue}`);
return tag;
},
// Remove tag and reset price inputs
removeTagAndResetInputs(tag, labelValue) {
const fromInput = document.getElementById('From');
const toInput = document.getElementById('To');
// Reset inputs to initial values
if (fromInput) {
fromInput.value = this.initialMin;
fromInput.dispatchEvent(new Event('input', { bubbles: true }));
fromInput.dispatchEvent(new Event('change', { bubbles: true }));
this.formatNumberWithoutDecimals(fromInput);
}
if (toInput) {
toInput.value = this.initialMax;
toInput.dispatchEvent(new Event('input', { bubbles: true }));
toInput.dispatchEvent(new Event('change', { bubbles: true }));
this.formatNumberWithoutDecimals(toInput);
}
// Remove tag with animation
tag.classList.add('tag-exit');
setTimeout(() => {
tag.remove();
window.FilterSystem.announce(`Removed filter: price ${labelValue}`);
window.FilterSystem.refreshList();
}, 300);
},
// Update or create price tag
updateOrCreateTag(field, value) {
// Don't create tag if values are at default range
if (value.min <= this.initialMin && value.max >= this.initialMax) {
window.FilterSystem.removeTag(field);
return;
}
const { tagContainer } = window.FilterSystem.getElements();
const existingTag = tagContainer.querySelector(`[data-field="${field}"]`);
if (existingTag) {
// Update existing tag with fade animation
existingTag.setAttribute('data-value', JSON.stringify(value));
existingTag.classList.add('tag-exit');
setTimeout(() => {
const labelValue = `${this.currencySymbol}${value.min}–${this.currencySymbol}${value.max}`;
existingTag.querySelector('.tag-value').textContent = labelValue;
// Update remove button aria-label
const removeBtn = existingTag.querySelector('.remove-tag-btn');
if (removeBtn) {
removeBtn.setAttribute('aria-label', `Remove filter: ${field} ${labelValue}`);
}
existingTag.classList.remove('tag-exit');
window.FilterSystem.announce(`Updated filter: ${field} ${labelValue}`);
}, 300);
} else {
// Create new tag
const newTag = this.createTag(field, value);
tagContainer.appendChild(newTag);
}
},
// Handle price input change
handleInputChange(input) {
this.formatNumberWithoutDecimals(input);
const field = input.getAttribute('fs-list-field');
if (field !== 'price') return;
const fromInput = document.getElementById('From');
const toInput = document.getElementById('To');
const minVal = fromInput?.value.trim() || '';
const maxVal = toInput?.value.trim() || '';
if (!minVal && !maxVal) {
window.FilterSystem.removeTag(field);
return;
}
const minNum = minVal ? Number(minVal) : this.initialMin;
const maxNum = maxVal ? Number(maxVal) : this.initialMax;
this.updateOrCreateTag(field, { min: minNum, max: maxNum });
},
// Initialize price tags from existing values
initTags() {
const fromInput = document.getElementById('From');
const toInput = document.getElementById('To');
if (!fromInput && !toInput) return;
const minVal = fromInput?.value.trim() || '';
const maxVal = toInput?.value.trim() || '';
const minNum = minVal ? Number(minVal) : this.initialMin;
const maxNum = maxVal ? Number(maxVal) : this.initialMax;
if (minNum !== this.initialMin || maxNum !== this.initialMax) {
const { tagContainer } = window.FilterSystem.getElements();
const tag = this.createTag('price', { min: minNum, max: maxNum });
tagContainer.appendChild(tag);
}
},
// Bind event listeners
bindEvents() {
const fromInput = document.getElementById('From');
const toInput = document.getElementById('To');
[fromInput, toInput].forEach(input => {
if (input && input.getAttribute('fs-list-field') === 'price') {
input.addEventListener('input', () => this.handleInputChange(input));
input.addEventListener('change', () => this.handleInputChange(input));
}
});
}
};
// Initialize price range filter
const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
if (tagContainer && tagTemplate) {
PriceRangeFilter.init();
PriceRangeFilter.initTags();
PriceRangeFilter.bindEvents();
}
});
</script>
Supporting Notes:
File Structure
core-filter-system.js # Required base functionality
├── checkbox-filter.js # Handles checkbox filters
├── radio-filter.js # Handles radio button filters
├── search-filter.js # Handles text/search inputs
└── price-range-filter.js # Handles price range sliders
Loading Order
Important: Scripts must be loaded in the correct order:
html<!-- Load core system first -->
<script src="core-filter-system.js"></script>
<!-- Load filter types you need (any order) -->
<script src="checkbox-filter.js"></script>
<script src="radio-filter.js"></script>
<script src="search-filter.js"></script>
<script src="price-range-filter.js"></script>
What Each Script Does
Core Filter System (core-filter-system.js)
Required for all other scripts
Provides shared utilities and base functionality
Sets up accessibility features
Handles currency detection and formatting
Manages DOM elements and live region announcements
Checkbox Filter (checkbox-filter.js)
Creates tags for checked checkboxes
Handles multiple selections per field
Automatically unchecks when tag is removed
Supports input[type="checkbox"][fs-list-field]
Radio Filter (radio-filter.js)
Creates tags for selected radio buttons
Handles single selection per field (exclusive)
Automatically unchecks when tag is removed
Supports input[type="radio"][fs-list-field]
Search Filter (search-filter.js)
Creates tags for text/search inputs
Updates existing tags when input changes
Clears input when tag is removed
Supports input[fs-list-field]
(excluding checkboxes, radios, and price fields)
Price Range Filter (price-range-filter.js)
Handles price range sliders and inputs
Automatically calculates max price from .item-price
elements
Formats numbers without decimals
Updates range slider configuration
Supports inputs with fs-list-field="price"
and IDs From
/To
I hope this helps F’insweeters!
Big love!
@Linesy