Why does my logic, using closest, not work?

I'm trying to reference a second element when my first element is clicked, but it is not working. I've tried a couple different ways, but neither work.

$(function(){
  $('#firstName').on('blur', function(e){
    // I tried to get the closest first name to the input
    var $firstNameLabel = $(e.target).closest('.firstName');
    
    if (e.target.value.trim() === '') {
      $firstNameLabel.addClass('error');
    } else {
      $firstNameLabel.removeClass('error');
    }
  });

  $('#lastName').on('blur', function(e){
    // I also tried to use the parent get the closest last name to the input
    var $lastNameLabel = $(e.target).closest('.form-group .lastName');
    
    if (e.target.value.trim() === '') {
      $lastNameLabel.addClass('error');
    } else {
      $lastNameLabel.removeClass('error');
    }
  });
});
.form-group label { display: inline-block; min-width: 80px; }
.error { color: rgb(240, 0, 0); }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="form-group">
  <label for="firstName" class="firstName">First Name:</label>
  <input type="text" name="firstName" value="" id="firstName">
</div>

<div class="form-group">
  <label for="lastName" class="lastName">Last Name:</label>
  <input type="text" name="lastName" value="" id="lastName">
</div>

Solution 1:

The root issue is a misunderstanding of how jQuery closest() operates. As specified in the API...

For each element in the set, get the first element that matches the selector by testing the element itself and traversing up through its ancestors in the DOM tree.

The important part related to our issue is "its ancestors". What does this mean? An ancestor, in relation to the DOM, is a(n indirect) parent of an element. For instance, lets look at the following example HTML...

<html>
    <head>
    </head>
    <body>
        <div id="header">
            Welcome to my Site!
        </div>
        <div id="content">
            <p>Hope you like it!</p>
            <p>More to come later!</p>
        </div>
    </body>
</html>

If we look at the paragraph tags we can see that they are surrounded (or "encapsulated") by a div tag. Formatting the paragraphs like this denotes them as a child of that div, thus that div is the parent of those paragraphs. The paragraphs, both belonging to the same div parent, are considered to be siblings.

Furthermore, the div tag is encapuslated by the body tag, so the body is the parent of the div. Since the body is the direct parent of the div, and the div is the direct parent of the paragraphs; that means the body is an indirect parent of the paragraphs.


So, now lets see how this relates to our problem. In the first event listener, we are trying to get the firstName label before the firstName input using closest('.firstName'). However, as we know now, closest only looks for direct or indirect parents. Referencing our HTML structure, is the label a parent of the input? No. It is a preceeding sibling.

What about the second attempt, trying to get the secondName label before the secondName input? That selector is using '.form-group .lastName', so it's looking for the parent form-group that has a child of lastName, right? So it should work?

No. The reason the second attempt fails is because the selector you give to closest is intended to match only the parent element. It does not perform any nesting traversals to see if the selector matches some element. It must match the parent. And since a single element cannot match a selector that includes a nesting, it will not find an element to perform your actions upon.


Now that we know the issues, how can we fix them? Well, lets look at the firstName one...

$(function(){
  $('#firstName').on('blur', function(e){
    var $firstNameLabel = $(e.target).prev('.firstName');
    
    if (e.target.value.trim() === '') {
      $firstNameLabel.addClass('error');
    } else {
      $firstNameLabel.removeClass('error');
    }
  });
});
.form-group label { display: inline-block; min-width: 80px; }
.error { color: rgb(240, 0, 0); }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="form-group">
  <label for="firstName" class="firstName">First Name:</label>
  <input type="text" name="firstName" value="" id="firstName">
</div>

In this particular case, we know that the label is the immediately preceeding sibling to the input. Given that fact, we could choose to use the jQuery prev() method to get the previous sibling to our element. In the example above, I gave it an optional selector to filter by, but it is not necessary in this case. Using prev(), we can grab the element before our input, which is the label, and then do whatever we want to it.

But, what if you wanted to use closest() in order to avoid the inheriently brittle positional based logic that prev() involves? We still could use it, by changing up our logic slightly.

$(function(){
  $('#firstName').on('blur', function(e){
    var $firstNameLabel = $(e.target).closest('.form-group').find('.firstName');
    
    if (e.target.value.trim() === '') {
      $firstNameLabel.addClass('error');
    } else {
      $firstNameLabel.removeClass('error');
    }
  });
});
.form-group label { display: inline-block; min-width: 80px; }
.error { color: rgb(240, 0, 0); }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="form-group">
  <label for="firstName" class="firstName">First Name:</label>
  <input type="text" name="firstName" value="" id="firstName">
</div>

Now we have changed the closest to find the shared parent div to both the label and the input. Once we find the parent, we can then perform a find to get the nested label. Using this approach, it does not matter where the label is in the div, just so long as it is a child. So, using this logic if you had the label before the input today and some time in the future decided to move it (or maybe nest it in some other structure in the div) it would still find it.

This second approach also works for our lastName issue. Rather than trying to put the entire selector into the closest(), the closest becomes a lookup for the parent div, followed by a find() for the label.