About

Wednesday, August 12, 2020

Practical Use Cases for JavaScript’s closest() Method

Have you ever had the problem of finding the parent of a DOM node in JavaScript, but aren’t sure how many levels you have to traverse up to get to it? Let’s look at this HTML for instance:

<div data-id="123">
  <button>Click me</button>
</div>

That’s pretty straightforward, right? Say you want to get the value of data-id after a user clicks the button:

var button = document.querySelector("button");


button.addEventListener("click", (evt) => {
  console.log(evt.target.parentNode.dataset.id);
  // prints "123"
});

In this very case, the Node.parentNode API is sufficient. What it does is return the parent node of a given element. In the above example, evt.targetis the button clicked; its parent node is the div with the data attribute.

But what if the HTML structure is nested deeper than that? It could even be dynamic, depending on its content.

<div data-id="123">
  <article>
    <header>
      <h1>Some title</h1>
      <button>Click me</button>
    </header>
     <!-- ... -->
  </article>
</div>

Our job just got considerably more difficult by adding a few more HTML elements. Sure, we could do something like element.parentNode.parentNode.parentNode.dataset.id, but come on… that isn’t elegant, reusable or scalable.

The old way: Using a while-loop

One solution would be to make use of a while loop that runs until the parent node has been found.

function getParentNode(el, tagName) {
  while (el && el.parentNode) {
    el = el.parentNode;
    
    if (el && el.tagName == tagName.toUpperCase()) {
      return el;
    }
  }
  
  return null;
}

Using the same HTML example from above again, it would look like this:

var button = document.querySelector("button");


console.log(getParentNode(button, 'div').dataset.id);
// prints "123"

This solution is far from perfect. Imagine if you want to use IDs or classes or any other type of selector, instead of the tag name. At least it allows for a variable number of child nodes between the parent and our source.

There’s also jQuery

Back in the day, if you didn’t wanted to deal with writing the sort of function we did above for each application (and let’s be real, who wants that?), then a library like jQuery came in handy (and it still does). It offers a .closest() method for exactly that:

$("button").closest("[data-id='123']")

The new way: Using Element.closest()

Even though jQuery is still a valid approach (hey, some of us are beholden to it), adding it to a project only for this one method is overkill, especially if you can have the same with native JavaScript.

And that’s where Element.closest comes into action:

var button = document.querySelector("button");


console.log(button.closest("div"));
// prints the HTMLDivElement

There we go! That’s how easy it can be, and without any libraries or extra code.

Element.closest() allows us to traverse up the DOM until we get an element that matches the given selector. The awesomeness is that we can pass any selector we would also give to Element.querySelector or Element.querySelectorAll. It can be an ID, class, data attribute, tag, or whatever.

element.closest("#my-id"); // yep
element.closest(".some-class"); // yep
element.closest("[data-id]:not(article)") // hell yeah

If Element.closest finds the parent node based on the given selector, it returns it the same way as  document.querySelector. Otherwise, if it doesn’t find a parent, it returns null instead, making it easy to use with if conditions:

var button = document.querySelector("button");


console.log(button.closest(".i-am-in-the-dom"));
// prints HTMLElement


console.log(button.closest(".i-am-not-here"));
// prints null


if (button.closest(".i-am-in-the-dom")) {
  console.log("Hello there!");
} else {
  console.log(":(");
}

Ready for a few real-life examples? Let’s go!

Use Case 1: Dropdowns

Our first demo is a basic (and far from perfect) implementation of a dropdown menu that opens after clicking one of the top-level menu items. Notice how the menu stays open even when clicking anywhere inside the dropdown or selecting text? But click somewhere on the outside, and it closes.

The Element.closest API is what detects that outside click. The dropdown itself is a <ul> element with a .menu-dropdown class, so clicking anywhere outside the menu will close it. That’s because the value for evt.target.closest(".menu-dropdown") is going to be null since there is no parent node with this class.

function handleClick(evt) {
  // ...
  
  // if a click happens somewhere outside the dropdown, close it.
  if (!evt.target.closest(".menu-dropdown")) {
    menu.classList.add("is-hidden");
    navigation.classList.remove("is-expanded");
  }
}

Inside the handleClick callback function, a condition decides what to do: close the dropdown. If somewhere else inside the unordered list is clicked, Element.closest will find and return it, causing the dropdown to stay open.

Use Case 2: Tables

This second example renders a table that displays user information, let’s say as a component in a dashboard. Each user has an ID, but instead of showing it, we save it as a data attribute for each <tr> element.

<table>
  <!-- ... -->
  <tr data-userid="1">
    <td>
      <input type="checkbox" data-action="select">
    </td>
    <td>John Doe</td>
    <td>john.doe@gmail.com</td>
    <td>
      <button type="button" data-action="edit">Edit</button>
      <button type="button" data-action="delete">Delete</button>
    </td>
  </tr>
</table>

The last column contains two buttons for editing and deleting a user from the table. The first button has a data-action attribute of edit, and the second button is delete. When we click on either of them, we want to trigger some action (like sending a request to a server), but for that, the user ID is needed.

A click event listener is attached to the global window object, so whenever the user clicks somewhere on the page, the callback function handleClick is called.

function handleClick(evt) {
  var { action } = evt.target.dataset;
  
  if (action) {
    // `action` only exists on buttons and checkboxes in the table.
    let userId = getUserId(evt.target);
    
    if (action == "edit") {
      alert(`Edit user with ID of ${userId}`);
    } else if (action == "delete") {
      alert(`Delete user with ID of ${userId}`);
    } else if (action == "select") {
      alert(`Selected user with ID of ${userId}`);
    }
  }
}

If a click happens somewhere else other than one of these buttons, no data-action attribute exists, hence nothing happens. However, when clicking on either button, the action will be determined (that’s called event delegation by the way), and as the next step, the user ID will be retrieved by calling getUserId:

function getUserId(target) {
  // `target` is always a button or checkbox.
  return target.closest("[data-userid]").dataset.userid;
}

This function expects a DOM node as the only parameter and, when called, uses Element.closest to find the table row that contains the pressed button. It then returns the data-userid value, which can now be used to send a request to a server.

Use Case 3: Tables in React

Let’s stick with the table example and see how we’d handle it on a React project. Here’s the code for a component that returns a table:

function TableView({ users }) {
  function handleClick(evt) {
    var userId = evt.currentTarget
    .closest("[data-userid]")
    .getAttribute("data-userid");


    // do something with `userId`
  }


  return (
    <table>
      {users.map((user) => (
        <tr key={user.id} data-userid={user.id}>
          <td>{user.name}</td>
          <td>{user.email}</td>
          <td>
            <button onClick={handleClick}>Edit</button>
          </td>
        </tr>
      ))}
    </table>
  );
}

I find that this use case comes up frequently — it’s fairly common to map over a set of data and display it in a list or table, then allow the user to do something with it. Many people use inline arrow-functions, like so:

<button onClick={() => handleClick(user.id)}>Edit</button>

While this is also a valid way of solving the issue, I prefer to use the data-userid technique. One of the drawbacks of the inline arrow-function is that each time React re-renders the list, it needs to create the callback function again, resulting in a possible performance issue when dealing with large amounts of data.

In the callback function, we simply deal with the event by extracting the target (the button) and getting the parent <tr> element that contains the data-userid value.

function handleClick(evt) {
  var userId = evt.target
  .closest("[data-userid]")
  .getAttribute("data-userid");


  // do something with `userId`
}

Use Case 4: Modals

This last example is another component I’m sure you’ve all encountered at some point: a modal. Modals are often challenging to implement since they need to provide a lot of features while being accessible and (ideally) good looking.

We want to focus on how to close the modal. In this example, that’s possible by either pressing Esc on a keyboard, clicking on a button in the modal, or clicking anywhere outside the modal.

In our JavaScript, we want to listen for clicks somewhere in the modal:

var modal = document.querySelector(".modal-outer");

modal.addEventListener("click", handleModalClick);

The modal is hidden by default through a .is-hidden utility class. It’s only when a user clicks the big red button that the modal opens by removing this class. And once the modal is open, clicking anywhere inside it — with the exception of the close button — will not inadvertently close it. The event listener callback function is responsible for that:

function handleModalClick(evt) {
  // `evt.target` is the DOM node the user clicked on.
  if (!evt.target.closest(".modal-inner")) {
    handleModalClose();
  }
}

evt.target is the DOM node that’s clicked which, in this example, is the entire backdrop behind the modal, <div class="modal-outer">. This DOM node is not within <div class="modal-inner">, hence Element.closest() can bubble up all it wants and won’t find it. The condition checks for that and triggers the handleModalClose function.

Clicking somewhere inside the nodal, say the heading, would make <div class="modal-inner"> the parent node. In that case, the condition isn’t truthy, leaving the modal in its open state.

Oh, and about browser support…

As with any cool “new” JavaScript API, browser support is something to consider. The good news is that Element.closest is not that new and is supported in all of the major browsers for quite some time, with a whopping 94% support coverage. I’d say this qualifies as safe to use in a production environment.

The only browser not offering any support whatsoever is Internet Explorer (all versions). If you have to support IE, then you might be better off with the jQuery approach.


As you can see, there are some pretty solid use cases for Element.closest. What libraries, like jQuery, made relatively easy for us in the past can now be used natively with vanilla JavaScript.

Thanks to the good browser support and easy-to-use API, I heavily depend on this little method in many applications and haven’t been disappointed, yet.

Do you have any other interesting use cases? Feel free to let me know.


The post Practical Use Cases for JavaScript’s closest() Method appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.



source https://css-tricks.com/practical-use-cases-for-javascripts-closest-method/

No comments:

Post a Comment