rmlumley
rmlumley

Reputation: 783

Need Help Optimizing Vanilla JS Function

I'm starting to use more Vanilla JS and rewriting old code that was in jQuery into Vanilla JS.

What I'm trying to do:

So, I have a list of filters. Under that, I have a list of stories under those filters. The idea is that when you click on a filter, you will only see the stories that match. If you click the "All" filter, you will see all the stories.

JQuery Solution

With jQuery, this is the old code:

var $filters = $('.filters').click(function(e) {
  e.preventDefault();
  if ($(this).data('filter') == 'all') {
    $('.directory .item').fadeIn(450);
  } else {
    var $el = $('.' + $(this).data('filter')).fadeIn(450);
    $('.directory .item').not($el).hide();
  }
  $filters.removeClass('selected');
  $(this).addClass('selected');
})

Vanilla JS Solution

I'm rebuilding this using Vanilla JS and have what I built over on CodePen: https://codepen.io/rmlumley/pen/ExjVxgw

You can also see the JS here:

// Find list of items with a class of filters
const filters = document.querySelectorAll('.filters');
function toggleFilter(e) {
    e.preventDefault(); // Prevent link from working.

    // Remove Selected class from other filters.
    for (var i = 0; i < filters.length; i++) {
        filters[i].classList.remove('selected');
    }
    // Add selected class to this filter.
    this.classList.add('selected');

    // Grab all Items.
    const all = document.querySelectorAll('.directory .item'); 
    // If selecting filter "All", then show all items.
    if (this.dataset.filter == 'all') {
        for (var i = 0; i < all.length; i++) {
            all[i].classList.remove('hide');
        }
    // Otherwise, filter by the data-attribute of filter that is set.
    } else {
        const filter = this.dataset.filter;
        // First off, hide all elements.
        for (var i = 0; i < all.length; i++) {
            all[i].classList.add('hide');
        }
        // Now show all elements that match.
        let selected = document.querySelectorAll(`.directory .${filter}`);
        for (var i = 0; i < selected.length; i++) {
            selected[i].classList.remove('hide');
        }
    }
}

// Event Listener on any Filter that is clicked.
filters.forEach(filter => filter.addEventListener('click', toggleFilter));

Questions

Upvotes: 1

Views: 146

Answers (2)

ffflabs
ffflabs

Reputation: 17511

Just one of several ways to do it:

The fading behavior can be emulated purely with css using transition. In this case, if a style attribute is changed due to the addition or removal of a class, there's an fluid transition from the initial state to the changed one. For example, let's say the hidden items will have opacity:0; and height:0

.item {
  opacity:1;
  height:initial;
  transition: height 0.5s ease-out, opacity 0.3s ease-out;

}
.item.hide {
  height:0;
  opacity:0;
}

Toggling the .hide class will fade the elements in or out and also smoothly change their height. It's not perfect tho, you'll need to fine tune those timings and easing functions.

Regarding the function itself, you can pick the items that should be hidden vs the selected ones separately:

 const toHide = document.querySelectorAll(`.directory .item:not(.${filter})`),
       toShow = document.querySelectorAll(`.directory .${filter}`);

On the other hand, given they all have the .item class, I would use that classname to adress all of them, because:

    document.querySelectorAll(`.directory .item`);

does indeed matches every item.

I don't quite get what is this.dataset.filter but assuming it's irrelevant, I'll say the filter is taken from the rel attribute.

This is my humble POC:

// Find list of items with a class of filters
const filters = document.querySelectorAll('.filter');

function toggleFilter(e) {
  e.preventDefault();

  // Remove Selected class from other filters.
  for (var i = 0; i < filters.length; i++) {
    filters[i].classList.remove('selected');
  }
  // Add selected class to this filter.
  this.classList.add('selected');

  const filter = this.attributes.rel.value;
  
  const toHide = document.querySelectorAll(`.directory .item:not(.${filter})`),
  toShow = document.querySelectorAll(`.directory .${filter}`);
  // hide elements that do not match
  for (var i = 0; i < toHide.length; i++) {
    toHide[i].classList.add('hide');
  }
  // show all elements that match.
  for (var i = 0; i < toShow.length; i++) {
    toShow[i].classList.remove('hide');
  }

}

// Event Listener on any Filter that is clicked.
filters.forEach(filter => filter.addEventListener('click', toggleFilter))
.filters {
  display: flex;
}

.filter {
  display: flex;
  flex-direction: column;
  padding: 4px;
  border: 1px solid cyan;
  margin: 3px;
  cursor: pointer;
  width: 50px;
}

.filter.selected {
  color: white;
  background: blue;
}
.item {
  opacity:1;
  height:initial;
  transition: height 0.5s ease-out, opacity 0.3s ease-out;

}
.item.hide {
  height:0;
  opacity:0;
}
<div class="filters">
  <div class="filter" rel="even">even</div>
  <div class="filter" rel="odd">odd</div>
  <div class="filter selected" rel="item">all</div>
</div>
<ul class="directory">
  <li class="item odd">one</li>
  <li class="item even">two</li>
  <li class="item odd">three</li>
  <li class="item even">four</li>
</ul>

Edit: Simpler way

If you knew the categories beforehand, you could do this without modifying the items's classList.

Let's say the .directory element had a class with defines the filter, and the only the children items having that same class will remain visible:

/* hidden by default */
.directory .item {
  transition: height 0.5s ease-out, opacity 0.3s ease-out;
  height:0;
  opacity:0;
}
/* visible if filter is "all" or filter matches their className */
.directory.all .item,
.directory.even .item.even,
.directory.odd .item.odd {
  opacity:1;
  height:initial;
}

// Find list of items with a class of filters
const filters = document.querySelectorAll('.filter');

function toggleFilter(e) {
  e.preventDefault();

  // Remove Selected class from other filters.
  for (var i = 0; i < filters.length; i++) {
    filters[i].classList.remove('selected');
  }
  // Add selected class to this filter.
  this.classList.add('selected');

  const filter = this.attributes.rel.value,
    directoryContainer=document.querySelector(`.directory`);
  
  directoryContainer.className=`directory ${filter}`;

}

// Event Listener on any Filter that is clicked.
filters.forEach(filter => filter.addEventListener('click', toggleFilter))
.filters {
  display: flex;
}

.filter {
  display: flex;
  flex-direction: column;
  padding: 4px;
  border: 1px solid cyan;
  margin: 3px;
  cursor: pointer;
  width: 50px;
}

.filter.selected {
  color: white;
  background: blue;
}
.directory .item {
  transition: height 0.5s ease-out, opacity 0.3s ease-out;
  height:0;
  opacity:0;
}
.directory.all .item,
.directory.even .item.even,
.directory.odd .item.odd {
  opacity:1;
  height:initial;
}
<div class="filters">
  <div class="filter" rel="even">even</div>
  <div class="filter" rel="odd">odd</div>
  <div class="filter selected" rel="all">all</div>
</div>
<ul class="directory all">
  <li class="item odd">one</li>
  <li class="item even">two</li>
  <li class="item odd">three</li>
  <li class="item even">four</li>
</ul>

Upvotes: 2

volt
volt

Reputation: 1003

How do I animate it similar to the fadeIn I was using with jQuery?

Since you're adding the class hidden to non-active items, you can fade them in with a CSS animation of opacity like so:

.item {
  animation: fade 0.75s ease-in-out;
}

.hide {
  display: none;
}

@keyframes fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

You can skip !important for .hidden It's not needed.

Is there a better way to write this function?

There's always a better way to write a piece of code and it can be very subjective. A few things I would recommend:

  1. use arrow functions when you don't have a lot of different scopes
  2. for loops are faster if you iterate over the same function 1000 times, but in the real world, forEach is a lot more readable.
  3. Keep things that you don't need to constantly query outside of loops.
  4. If there's code that doesn't need to be executed if a certain condition is met (example: user clicked all filter) use return;
  5. Your code should explain itself and you shouldn't need to provide a comment for every line.
  6. Consider reading about the spread syntax that can be used to convert nodeLists to arrays.
  7. consider reading about const and let

That said, here's how I would write that JS

const directory = document.querySelector('.directory')
const allItems = [...directory.querySelectorAll('.item')];
const filterWrapper = document.getElementById('isotope-filters')

const toggleFilter = event => {
  if (!event.target.dataset.filter) return;
  event.preventDefault();

  const filter = event.target.dataset.filter;
  const oldSelected = filterWrapper.querySelector('.selected');
  const currentSlected = [...directory.querySelectorAll(`.${filter}`)];

  oldSelected.classList.remove('selected');
  event.target.classList.add('selected');

  if (filter === 'all') {
    allItems.forEach(item => {
      item.classList.remove('hide');
    })
    return
  }

  allItems.forEach(item => {
    item.classList.add('hide');
  })

  currentSlected.forEach(item => {
    item.classList.remove('hide');
  })
}

filterWrapper.addEventListener('click', toggleFilter)

and here's a working snippet of the entire thing

const directory = document.querySelector('.directory')
const allItems = [...directory.querySelectorAll('.item')];
const filterWrapper = document.getElementById('isotope-filters')

const toggleFilter = event => {
  if (!event.target.dataset.filter) return;
  event.preventDefault();

  const filter = event.target.dataset.filter;
  const oldSelected = filterWrapper.querySelector('.selected');
  const currentSlected = [...directory.querySelectorAll(`.${filter}`)];

  oldSelected.classList.remove('selected');
  event.target.classList.add('selected');

  if (filter === 'all') {
    allItems.forEach(item => {
      item.classList.remove('hide');
    })
    return
  }

  allItems.forEach(item => {
    item.classList.add('hide');
  })

  currentSlected.forEach(item => {
    item.classList.remove('hide');
  })
}

filterWrapper.addEventListener('click', toggleFilter)
.selected {
  border: 1px solid black;
  background-color: gray;
}

.item {
  border: 1px solid black;
  margin: 1em;
  padding: 1em;
  animation: fade 0.75s ease-in-out;
}

.hide {
  display: none;
}

@keyframes fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
<ul id="isotope-filters">
  <li><a href="#" data-filter="all" class="selected filters">All</a></li>
  <li><a href="#" data-filter="bioethics" class="filters">Bioethics</a></li>
  <li><a href="#" data-filter="medical-engineering" class="filters">Medical Engineering</a></li>
  <li><a href="#" data-filter="metabolism" class="filters">Metabolism</a></li>
  <li><a href="#" data-filter="outreach" class="filters">Outreach</a></li>
  <li><a href="#" data-filter="regenerative-biology" class="filters">Regenerative Biology</a></li>
  <li><a href="#" data-filter="virology" class="filters">Virology</a></li>
</ul>

<div class="directory">
  <ul>
    <li class="item medical-engineering">Story 1</li>
    <li class="item bioethics">Story 2</li>
    <li class="item medical-engineering">Story 3</li>
    <li class="item outreach">Story 4</li>
    <li class="item medical-engineering">Story 5</li>
    <li class="item regenerative-biology">Story 6</li>
    <li class="item medical-engineering">Story 7</li>
    <li class="item virology">Story 8</li>
    <li class="item metabolism">Story 9</li>
    <li class="item regenerative-biology">Story 10</li>
    <li class="item outreach">Story 11</li>
    <li class="item virology">Story 12</li>
  </ul>
</div>

Upvotes: 2

Related Questions