Feature Request: Single Filter Tags vs Grouped Filter Tags option in V2

In Attributes V1, a single filter tag was displayed to represent each active filter. In V2, tags with the same fs-list-field attribute are grouped into one tag.

This is useful for some projects, but presents a UX issue for filters inside a drawer. The tags can’t be dismissed individually on the page without reopening the drawer. Dismissing a V2 filter clears all filters associated with the tag.

Could the behaviour be made optional via an FS attribute? Such as…

‘Filter Tag Mode = Single’

or

‘Filter Tag Mode = Grouped’

Thanks

*Reopened this thread under a different title, since I hijacked @aleksandr.hlahola initial post to which a solution was given for a separate issue.

Hey @Linesy! Great faeture suggestion! I’ll pass it on to the team :flexed_biceps:

Perhaps this would be useful for preventing certain filters from creating tags…

‘Filter Tag Mode = None’

Hey @Linesy! I’m afraid that the original request to add the option of individual vs. grouped tags cannot be implemented, as it would introduce a lot of complexity without a lot of benefit.

We have, however, added the setting to prevent certain filters from creating a tag.

Simply add fs-list-showtag="false" to any filter element (or a parent), and the filter will not display a tag when interacting with it.

Please feel free to try this! Just remember to clear your cache or test it in an incognito window!

We will update the documentation to showcase this new attribute shortly :wink:

1 Like

@Support-Luis @Linesy
Oh, this one is super useful.
For me the issue was that toggle button was displaying “true” value in the tag field which was unnecessary.

Also, I’ve already seen a few requests to make the tags separate rather than grouped.
It’s unfortunate there’s no way to do that, though. I think I might still switch to v1 in this case.

Thanks

Thanks, @Support-Luis. The hide tags feature is great, thank you!

Solved
I’ve actually solved the separate filter tags using JavaScript. Obviously, it’s not as good as native, but it works flawlessly and it’s pretty clean too. It can hook into the existing setup with a few minor modifications.

Notes: Filter Wrapper and Filter Tag (tag template) require an ID. The Filter Tag needs to sit outside of the Filter Wrapper so the template doesn’t appear. The JS needs to be the highest selector in the stack (bottom layer in Webflow). All FInsweet attributes need to be removed from the tag and wrapper. The script will take care of the rest.

Example: Here’s my site. I will update this post ASAP but hopefully you can strip it out in the meantime… @slawek

Solution for separate filters in Finsweet Attributes V2 using a clone method

You want to start with something like this (see Checklist below this example)…

<div id="tag-template" class="filter-tag">
    <div class="tag-field"></div>
    <div class="tag-operator">:</div>
    <div class="tag-value button-text">Tag</div>
    <button class="remove-tag-btn"></button>
</div>

<div id="custom-tags-container" class="filter-tags-container"></div>

Checklist

  • Use the same naming conventions, or write your own and update them in the script to match.
  • Remove all Finsweet Custom Attributes from your .filter-tags-container
  • Remove all Finsweet Custom Attributes from your .filter-tag (including ALL child elements)
  • Add an ID of custom-tags-container to the .filter-tags-container
  • Add an ID of tag-template to the .filter-tag
  • Move your Filter Tag outside of the .filter-tags-container (prevents seeing the filter tag template on load or when a collection is empty - the script will render new tags inside the container)
  • Copy and paste the following JavaScript into your page or sites Footer Code (Add code before tag):
<!-- Separate Filter Tags In Finsweet Attributes V2 -->
<script>
const tagContainer = document.getElementById('custom-tags-container');
const tagTemplate = document.getElementById('tag-template');

function createTag(field, value) {
  const tag = tagTemplate.cloneNode(true);
  tag.removeAttribute('id');
  tag.style.display = '';
  tag.setAttribute('data-field', field);
  tag.setAttribute('data-value', value);
  tag.classList.add('filter-tag');

  tag.querySelector('.tag-field').textContent = field;
  tag.querySelector('.tag-operator').textContent = ':';
  tag.querySelector('.tag-value').textContent = value;

  tag.querySelector('.remove-tag-btn').addEventListener('click', () => {
    const checkbox = document.querySelector(`input[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 }));

      setTimeout(() => {
        if (window.fsAttributes && window.fsAttributes['list']) {
          window.fsAttributes['list'].refresh();
        }
      }, 50);

      tag.classList.remove('fade');
      tag.addEventListener('transitionend', () => {
        tag.remove();
      }, { once: true });
    }
  });

  tagContainer.appendChild(tag);

  // Trigger fade-in after append
  requestAnimationFrame(() => tag.classList.add('fade'));

  return tag;
}

function handleCheckboxChange(checkbox) {
  const field = checkbox.getAttribute('fs-list-field');
  const value = checkbox.getAttribute('fs-list-value');
  const selector = `[data-field="${field}"][data-value="${value}"]`;
  const existingTag = tagContainer.querySelector(selector);

  if (checkbox.checked) {
    if (!existingTag) {
      createTag(field, value);
    }
  } else {
    if (existingTag) {
      existingTag.classList.remove('fade');
      existingTag.addEventListener('transitionend', () => {
        existingTag.remove();
      }, { once: true });
    }
  }
}

document.addEventListener('change', (e) => {
  if (e.target.matches('input[type="checkbox"][fs-list-value]')) {
    handleCheckboxChange(e.target);
  }
});

document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('input[type="checkbox"][fs-list-value]:checked').forEach(handleCheckboxChange);
});
</script>

Extra

  • Animate your tags by adding the class Fade (.fade) to the Filter Tag (.filter-tag), to end up with Filter Tag Fade (.filter-tag.fade) in Webflow. Style the combo-class and then remove it. This means the combo-class now exists and will be included in the published site.

Transition styling example…

  • .filter-tag (initial state e.g. opacity: 0; transition: opacity 200ms ease;)
  • .filter-tag.fade (active state e.g. opacity: 1;)
1 Like

Oh @Linesy this is gold, thank you so much :blush:

You’re welcome @slawek. This covers checkbox filters but I can update it to include other types of filter. Perhaps even a catch-all for all filter types, when I get a moment. In the meantime, you can always ask an AI to add the rest using the same method. But make sure AI is aware you’re using V2 as it loves to reference ‘cmsfilter’ which I believe is V1 only.

2 Likes

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

1 Like

Select Filter Handler (optional)
select-filter.js
Handles select dropdown filter tags (Finsweet Attributes V2)

UNTESTED - Please provide feedback if there are any issues

<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 SelectFilter = {
    // Create select 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.removeTagAndResetSelect(field, value, tag, labelValue);
        });
      }

      window.FilterSystem.announce(`Added filter: ${field} ${labelValue}`);
      return tag;
    },

    // Remove tag and reset select
    removeTagAndResetSelect(field, value, tag, labelValue) {
      // Find the select element
      const select = document.querySelector(`select[fs-list-field="${field}"]`);
      if (select) {
        // Reset to first option (usually empty/placeholder)
        select.selectedIndex = 0;
        select.value = '';
        select.dispatchEvent(new Event('input', { bubbles: true }));
        select.dispatchEvent(new Event('change', { bubbles: true }));

        // Update custom dropdown display if present
        this.updateCustomDropdownDisplay(select);
      }

      // Remove tag with animation
      tag.classList.add('tag-exit');
      setTimeout(() => {
        tag.remove();
        window.FilterSystem.announce(`Removed filter: ${field} ${labelValue}`);
        window.FilterSystem.refreshList();
      }, 300);
    },

    // Clear select and remove associated tag
    clearSelectAndTag(select) {
      const field = select.getAttribute('fs-list-field');
      if (!field) return;

      // Remove existing tag for this field
      const { tagContainer } = window.FilterSystem.getElements();
      const existingTag = tagContainer.querySelector(`[data-field="${field}"]`);
      
      if (existingTag) {
        const labelValue = existingTag.getAttribute('data-value');
        const titleCaseValue = window.FilterSystem.toTitleCase(labelValue);
        
        // Remove tag with animation
        existingTag.classList.add('tag-exit');
        setTimeout(() => {
          existingTag.remove();
          window.FilterSystem.announce(`Removed filter: ${field} ${titleCaseValue}`);
          window.FilterSystem.refreshList();
        }, 300);
      }

      // Reset select
      select.selectedIndex = 0;
      select.value = '';
      
      // Update custom dropdown display
      this.updateCustomDropdownDisplay(select);
    },

    // Update custom dropdown display text
    updateCustomDropdownDisplay(select) {
      // Find the parent custom dropdown component
      const customDropdown = select.closest('[fs-selectcustom-element="dropdown"]');
      if (!customDropdown) return;

      const toggle = customDropdown.querySelector('.fs-selectcustom_dropdown-toggle');
      const selectedOption = select.options[select.selectedIndex];
      
      if (toggle && selectedOption) {
        // Update the toggle text (skip the icon)
        const textNode = Array.from(toggle.childNodes).find(node => 
          node.nodeType === Node.TEXT_NODE || 
          (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('fs-selectcustom_icon'))
        );
        
        if (textNode) {
          if (textNode.nodeType === Node.TEXT_NODE) {
            textNode.textContent = selectedOption.text;
          } else {
            textNode.textContent = selectedOption.text;
          }
        }

        // Update dropdown links aria-selected states
        const dropdownLinks = customDropdown.querySelectorAll('.fs-selectcustom_link');
        dropdownLinks.forEach((link, index) => {
          if (index === select.selectedIndex) {
            link.setAttribute('aria-selected', 'true');
            link.classList.add('w--current');
          } else {
            link.setAttribute('aria-selected', 'false');
            link.classList.remove('w--current');
          }
        });
      }
    },

    // Handle select change
    handleSelectChange(select) {
      const field = select.getAttribute('fs-list-field');
      if (!field) return;

      const value = select.value.trim();
      const { tagContainer } = window.FilterSystem.getElements();

      // Remove any existing tag for this field (selects are exclusive)
      const existingTag = tagContainer.querySelector(`[data-field="${field}"]`);
      if (existingTag) {
        existingTag.remove();
      }

      // Create new tag if a value is selected (not empty/placeholder)
      if (value && value !== '') {
        const tag = this.createTag(field, value);
        tagContainer.appendChild(tag);
      }

      // Update custom dropdown display
      this.updateCustomDropdownDisplay(select);
    },

    // Handle custom dropdown link clicks
    handleDropdownLinkClick(link, select) {
      const linkText = link.textContent.trim();
      const field = select.getAttribute('fs-list-field');
      
      // Find matching option in select
      const matchingOption = Array.from(select.options).find(option => 
        option.text.trim() === linkText
      );

      if (matchingOption) {
        // Update select value
        select.value = matchingOption.value;
        select.dispatchEvent(new Event('change', { bubbles: true }));
        select.dispatchEvent(new Event('input', { bubbles: true }));
        
        // Handle the filter change
        this.handleSelectChange(select);
      }
    },

    // Initialize select tags from existing selected values
    initTags() {
      document.querySelectorAll('select[fs-list-field]').forEach(select => {
        const field = select.getAttribute('fs-list-field');
        const value = select.value.trim();
        
        if (field && value && value !== '') {
          const { tagContainer } = window.FilterSystem.getElements();
          const tag = this.createTag(field, value);
          tagContainer.appendChild(tag);
        }

        // Update custom dropdown display on init
        this.updateCustomDropdownDisplay(select);
      });
    },

    // Bind event listeners
    bindEvents() {
      // Listen for select changes
      document.querySelectorAll('select[fs-list-field]').forEach(select => {
        select.addEventListener('change', () => this.handleSelectChange(select));
      });

      // Listen for custom dropdown link clicks
      document.querySelectorAll('[fs-selectcustom-element="dropdown"]').forEach(dropdown => {
        const select = dropdown.querySelector('select[fs-list-field]');
        if (!select) return;

        const links = dropdown.querySelectorAll('.fs-selectcustom_link');
        links.forEach(link => {
          link.addEventListener('click', (e) => {
            e.preventDefault();
            this.handleDropdownLinkClick(link, select);
          });
        });
      });

      // Listen for custom dropdown clear button clicks
      document.querySelectorAll('[fs-selectcustom-element="clear"]').forEach(clearBtn => {
        clearBtn.addEventListener('click', (e) => {
          e.preventDefault();
          
          const dropdown = clearBtn.closest('[fs-selectcustom-element="dropdown"]');
          const select = dropdown?.querySelector('select[fs-list-field]');
          
          if (select) {
            this.clearSelectAndTag(select);
          }
        });
      });

      // Also listen for native Webflow dropdown functionality
      // This ensures compatibility with Webflow's built-in dropdown behavior
      document.addEventListener('click', (e) => {
        if (e.target.classList.contains('fs-selectcustom_link')) {
          const dropdown = e.target.closest('[fs-selectcustom-element="dropdown"]');
          const select = dropdown?.querySelector('select[fs-list-field]');
          
          if (select) {
            // Small delay to let Webflow's handler run first
            setTimeout(() => {
              this.handleSelectChange(select);
            }, 10);
          }
        }
      });
    }
  };

  // Initialize select filters
  const { tagContainer, tagTemplate } = window.FilterSystem.getElements();
  if (tagContainer && tagTemplate) {
    SelectFilter.initTags();
    SelectFilter.bindEvents();
  }
});
</script>
1 Like