Suppose you are working on a table in a LiveView project. This table has limited static data of not more than one page (you can avoid questions about pagination in the comment section ๐). From a user's point of view, it becomes hard to look into the table for a specific record. Of course, a simple solution could be using a browser search by pressing Cmd + F and then searching the text, but not everyone is a tech expert who knows these kinds of hacks. In this blog, we will create a simple search feature on the table highlighting the most matched row (very similar to the Cmd + F search we discussed above). Refer to the gif image below to get the idea.
Getting started
First of all, we should have a LiveView project. On any page of the project, add a table with a few dummy records or render real records from the database in the table. The records should be enough to make the table scrollable to transition to the particular searched record. Now let's get started.
Add search input
As per the demo gif you saw earlier, adding an input field is the main ingredient to achieve the above feature. So, we are going to add a search input above the table. Make sure to add input
, and the table
code in the same container div
. This is because the search input and the table will be used in the javascript hook for DOM access. Refer to the code below.
<div class="user-table-container" id="user-table-container" phx-hook="SearchUser">
//Add below container to add search input
<div class="search-container">
<label for="search" class="block text-sm">Search</label>
<input name="search" placeholder=" Search..." id="search-user">
</div>
<table>
<thead class="bg-gray-50">
<tr>
<th scope="col" class="w-3 text-left text-sm"></th>
<th scope="col" class="w-3 text-left text-sm">>Name</th>
<th scope="col" class="w-3 text-left text-sm">>Count</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<%= for {record, index} <- Enum.with_index(@records) do %>
<tr>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 user-name"><%= record.name %></td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"><%= record.count %></td>
<tr>
<% end %>
</tbody>
</table>
</div>
Adding Javascript Hook
As per the title of this blog, it is quite obvious that the solution revolves around DOM (Document Object Manipulation). DOM manipulation is achieved using JavaScript. To integrate JavaScript into our LiveView app, LiveView has a concept of JS Hooks. JS Hooks allows us to register JavaScript lifecycle callbacks on DOM nodes (To know more about Hooks, I wrote a blog about Infinite Scroll in LiveView and explained Hooks in great detail). We will add a file assets/js/user_search.js
with the following code.
export default SearchUser = {
mounted() {
const searchInput = this.el.querySelector("input")
console.log(searchInput)
}
}
Notice, the mounted
function, it is the first code that executed when the Hook is initialized( Please read this blog if this doesn't make sense). The above code finds the input field on the web page (where users type their search queries) using querySelector
. This will be the starting point of the JavaScript logic. Now, whenever a user starts to type in the search bar, the hook will detect the change and log the searchInput
object in the console. In the next section, we will look at the remaining work and explanation of the logic of the DOM search.
Search code explanation
Refer to the complete code of the hook. You've already seen the explanation of searchInput
. Now, let's look into how each line of the code works.
export default SearchUser = {
mounted() {
const searchInput = this.el.querySelector("input")
searchInput.addEventListener('input', () => {
let searchQuery = this.el.querySelector("input").value
const tableRows = this.el.querySelectorAll('table tbody tr')
tableRows.forEach(row => {
// Get the text content of the row
const rowData = row.textContent.toLowerCase()
const userNameContainer = row.querySelector('.user-name')
if (searchQuery.length && rowData.includes(searchQuery.toLowerCase())) {
// Move focus to the row
userNameContainer.classList.add('ring-4') // `ring-4` is a tailwind class.
row.scrollIntoView({behavior: "smooth", block: "center", inline: "nearest"})
} else {
userNameContainer.classList.remove('ring-4') // `ring-4` is a tailwind class.
}
});
if (rowData.includes(searchQuery)) {
row.scrollIntoView({behavior: "smooth", block: "center", inline: "nearest"})
}
}
);
}
}
Listening to search events:
We want to listen for the user's typing event. As soon as the user starts typing in the search input, we need to begin searching for the element. To do this, we add an event listener that triggers whenever the user types into the input field (using the input
event). The addEventListener
method handles this for us.
searchInput.addEventListener('input', () => {
let searchQuery = this.el.querySelector("input").value
}
We will also get the searched query into a variable searchQuery
.
Getting the rows:
Whatever search text we get in the event listener, we need to use that text to filter the table. For that, we will first need to get all the records on the page. We want to select all the table rows (<tr>
) inside the body (<tbody>
) of a table (<table>
). We will do this using querySelectorAll
, which returns a list (or a NodeList) of all matching rows in the table.
const tableRows = this.el.querySelectorAll('table tbody tr')
this.el
refers to the parent element that contains the table. It's used to scope the search within that element.'table tbody tr'
is the CSS selector used to find all the<tr>
elements (table rows) that are inside<tbody>
of a<table>
.
This line essentially collects all the rows in the table so that we can later loop through each of them to check their content.
Get each row content:
Now, we are going to loop over each row of the table we queried in the previous step. For each row in the table, it converts the row's text to lowercase (to make the search case insensitive) and checks if the row contains the search query.
tableRows.forEach(row => {
// Get the text content of the row
const rowData = row.textContent.toLowerCase()
...
...
})
Highlight Matching Rows:
We are going to search for the name
of the user in the table. If you remember (or you can go through the html
code), we have added the user-name
class to the name
td
in each row. We will query the user-name
element and save it in userNameContainer
. Later, we will use it to highlight (add a border) or de-highlight (remove border) matched rows.
const userNameContainer = row.querySelector('.user-name')
We will add a condition to check if searchQuery
has any content and if each row contains the searchQuery
. If the row contains the search term, the row is highlighted by adding a ring-4
class (this is a Tailwind CSS class that adds a visible border around the row) and also scrolls smoothly to the row, making it visible using scrollIntoView
.
if (searchQuery.length && rowData.includes(searchQuery.toLowerCase())) {
// Move focus to the row
userNameContainer.classList.add('ring-4')
row.scrollIntoView({behavior: "smooth", block: "center", inline: "nearest"})
} else {
userNameContainer.classList.remove('ring-4') // `ring-4` is a tailwind class.
}
Similarly, when if the searchQuery
doesn't match remove the previous highlighted class i.e ring-4
Scroll to Matched Rows:
if (rowData.includes(searchQuery)) {
row.scrollIntoView({behavior: "smooth", block: "center", inline: "nearest"})
}
This code checks if a specific table row contains the user's searched query. If it does, the page scrolls smoothly to bring the matching row into view, centered on the screen.
Conclusion
I hope you like this blog. If you have any questions please comment below. Thanks for reading ๐.