9 min read

Tutorial: Build the Github Issue Filter component

💡
This is a tutorial that solves the "Build the Github Issue Filter component" exercise.
If you haven't already, try to solve it yourself first before reading the solution!

The first step in building the generic Filter component is to define its API.
How do we want to use this component?

A straightforward way would be to simply pass the name, header, input placeholder and items as props:

<Filter 
	name="Milestone" 
	header="Filter by milestone" 
	placeholder="Filter milestones" 
	items={milestones} />

But what about rendering each item itself? For milestones, we only need to show the title, but for labels the colour is also shown and for authors the profile picture.
This means we need to allow the users of our component to define how to render each item. A common way to achieve this is to have a prop that is a function, for rendering each item. We can call it renderItem:

<Filter 
   name="Milestone" 
   ...
   renderItem=(milestone => milestone.title)
/>

Then, for labels, we could also add the colour:

<Filter 
   name="Label" 
   ...
   renderItem=(label => <div><ColorSwatch color={label.color} />{label.name} </div>)
/>

Next, we want to also allow the user to customise how the items are filtered - for label, we might filter based on the name, but for milestones we want to filter on title.
We can add a filterFn prop:

<Filter 
   name="Label" 
   ...
   filterFn={(label, query) => label.name.match(new RegExp(query, "i"))}
/>

With the API in place, let's start building the component!


First, we need a button with a down arrow, that toggles the filters popup. I chose to use the AngleDown icon from Font Awesome for this:

import { FaAngleDown } from "react-icons/fa6";
import { useState } from "react";

const GithubFilter = ({ name }) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button
        className="flex items-center gap-2"
        onClick={() => setIsOpen(!isOpen)}
      >
        {name}
        <span>
          <FaAngleDown />
        </span>
      </button>
      {isOpen ? <div>Open</div> : <div>Closed</div>}
    </div>
  );
};

export default GithubFilter;

This works, but is not really that interesting.

To make it more realistic, for starters, we can add the gray background color for the menu items and white for the filters container (commit).

This is better!


The filters container is now shown inline, but we want it to pop up over the filter name.
To achieve this, we can use the "Floating UI" library (formerly known as Popper).
The library takes care of calculating the position of the container relative to the clicked element, so we can focus on just displaying the content.

First, let's install it:

npm install @floating-ui/react

Next, to use it, we need to do two things:

  • pass a reference to the element that will open the popup - in our case the "name" button
  • calculate the position of the popup using the useFloating hook and pass it to our "floating" container - in our case, the filters container

Here's how the updated component would look:

import { FaAngleDown } from "react-icons/fa6";
import { useState } from "react";
import { useFloating } from "@floating-ui/react";

const GithubFilter = ({ name }) => {
  const [isOpen, setIsOpen] = useState(false);
  const { refs, floatingStyles } = useFloating();
  return (
    <div>
      <button
        className="flex items-center gap-2"
        onClick={() => setIsOpen(!isOpen)}
        ref={refs.setReference}
      >
        {name}
        <span>
          <FaAngleDown />
        </span>
      </button>
      {isOpen ? (
        <div
          ref={refs.setFloating}
          style={floatingStyles}
          className="bg-white p-4 border border-gray-200 shadow-md"
        >
          Open
        </div>
      ) : null}
    </div>
  );
};

We also added border and shadow to the filters container, to make it look more like a popup.

Great, with this in place we can move on to building the filters container itself.


Each filter container has three "areas":

  • the header
  • the input used for filtering the items
  • the list of items

These are the props we should be accepting in our component.
Also, since each item can have a different structure, we will also accept the renderItem function prop that we can use to render each element of the list.

Here is an example for the Milestone filter:

<GithubFilter
      name="Milestones"
      header="Filter by milestone"
      placeholder="Filter milestones"
      items={milestones}
      renderItem={(milestone) => milestone.title}
    />

Using these elements, here is how the updated Filter component will look like:

import { FaAngleDown } from "react-icons/fa6";
import { useState } from "react";
import { useFloating } from "@floating-ui/react";

const GithubFilter = ({ name, header, placeholder, items, renderItem }) => {
  const [isOpen, setIsOpen] = useState(false);
  const { refs, floatingStyles } = useFloating();
  return (
    <div>
      <button
        className="flex items-center gap-2"
        onClick={() => setIsOpen(!isOpen)}
        ref={refs.setReference}
      >
        {name}
        <span>
          <FaAngleDown />
        </span>
      </button>
      {isOpen ? (
        <div
          ref={refs.setFloating}
          style={floatingStyles}
          className="bg-white border border-gray-200 shadow-md text-xs w-64 flex flex-col"
        >
          <div className="m-2 pl-1 font-semibold">{header}</div>
          <hr />
          <input
            type="text"
            placeholder={placeholder}
            className="m-2 p-2 border border-gray-300 rounded-lg"
          />
          <hr />
          <div className="overflow-auto h-64 flex flex-col">
            {items.map((item) => (
              <a
                key={item.id}
                href="#"
                className="border-b border-gray-200 font-semibold p-2 pl-6 hover:bg-gray-100 "
              >
                {renderItem(item)}
              </a>
            ))}
          </div>
        </div>
      ) : null}
    </div>
  );
};

export default GithubFilter;

A few things worth pointing out:

  • we are displaying the three "areas" (header, input, list) using flexbox, with the direction "column"
  • we use the hr  element to separate the areas
  • we tweaked the font size using text-xs, to show the text as smaller than the rest of the page; we used font-semibold make the text bold
  • we used overflow-auto to make the container scrollable
  • we used links for the list element, so that the cursor changes to a hand on hover; also made sure the background color changes on hover using hover:bg-gray-100

Now, this looks good, but we seem to have some positioning issues:

This is because we haven't really fully configured the popup.
The "Floating UI" library supports more options, to make sure popup is moved if it doesn't fit ("shifting") or if it touches the edges of the screen ("flipping").
We also want to display the popup a bit further away from the button itself, to make it look closer to Github's design.
Let's update the component based on the official tutorial:

...
import { useFloating, offset, flip, shift } from "@floating-ui/react";

const GithubFilter = ({ name, header, placeholder, items, renderItem }) => {
  const [isOpen, setIsOpen] = useState(false);
  const { refs, floatingStyles } = useFloating({
    placement: "bottom-end",
    middleware: [offset({ mainAxis: 5, crossAxis: 10 }), flip(), shift()],
  });
  return (
   ...
  );
};

Notice we also updated the main filter bar in App.jsx, to make it look more like Github's filter bar and make it easier to check that the popup appears to the bottom left of the text and a bit offset:


While testing the app so far, you might have noticed the popup never closes 😅
Let's build that as well. We want it to close:

  • when the user clicks outside
  • when the user presses Esc
  • when the user clicks the "x" icon to the right of the header

We don't need to build this ourselves - luckily "Floating UI" also gives us utilities for closing the popover.

We can use the useDismiss hook, together with the useInteractions hook:

import { MdClose } from "react-icons/md";
import {
  useFloating,
  offset,
  flip,
  shift,
  useDismiss,
  useInteractions,
} from "@floating-ui/react";

const GithubFilter = ({ name, header, placeholder, items, renderItem }) => {
  const [isOpen, setIsOpen] = useState(false);
  const { refs, floatingStyles, context } = useFloating({
    open: isOpen,
    onOpenChange: setIsOpen,
    placement: "bottom-end",
    middleware: [offset({ mainAxis: 5, crossAxis: 10 }), flip(), shift()],
  });

  const dismiss = useDismiss(context);

  // Merge all the interactions into prop getters
  const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);

  return (
    <div>
      <button
        className="flex items-center gap-1"
        onClick={() => setIsOpen(!isOpen)}
        ref={refs.setReference}
        {...getReferenceProps()}
      >
        {name}
        <span>
          <FaAngleDown />
        </span>
      </button>
      {isOpen ? (
        <div
          ref={refs.setFloating}
          style={floatingStyles}
          {...getFloatingProps()}
          className="bg-white border border-gray-200 shadow-md text-xs w-64 flex flex-col rounded-lg"
        >
          <div className="m-2 pl-1 font-semibold flex items-center">
            {header}
            <button onClick={() => setIsOpen(false)} className="ml-auto px-1 ">
              <MdClose className="w-4 h-4" />
            </button>
          </div>
          <hr />
          <input
            type="text"
            placeholder={placeholder}
            className="m-2 p-2 border border-gray-300 rounded-lg"
          />
          <hr />
          <div className="overflow-auto h-64 flex flex-col">
            {items.map((item) => (
              <a
                key={item.id}
                href="#"
                className="border-b border-gray-200 font-semibold p-2 pl-6 hover:bg-gray-100 "
              >
                {renderItem(item)}
              </a>
            ))}
          </div>
        </div>
      ) : null}
    </div>
  );
};

I included the entire component code above because there are quite a few things that changed:

  • we pass the isOpen and onOpenChange props to the useFloating hook, so "Floating UI" can close the popover for us when the user clicks outside
  • we call the useDismiss hook to setup the "dismiss" interaction
  • we call the useInteractions hook so we can let "Floating UI" calculate the props for us for the reference element (the button) and the floating element (our filters container); we pass this further as props using destructuring - {...getFloatingProps()}
  • we added a button to manually close the popup, using the Material UI "Close" icon

The component looks the same, but now it closes as expected when you click outside or click the "x" button!


Next, let's improve the way the items are rendered. For labels, we want to display the colours in a circle and for authors we want to show their profile image and username, if defined.

We can see the colour is provided for each label under the color property.

To display a circle of the given colour, we can draw a div with very rounded corners. Thus, the renderItem prop would be as follows:

<GithubFilter
  name="Label"
  header="Filter by label"
  placeholder="Filter labels"
  items={labels}
  renderItem={(label) => (
	<div className="flex items-center gap-2">
	  <div
		style={{ backgroundColor: `#${label.color}` }}
		className="w-4 h-4 rounded-lg border border-gray-300"
></div>
	  {label.name}
	</div>
  )}
/>

And voila! The label filter is now done:

Next, let's do the same for the author filter. We want to display user profile photo and name. The profile photo is available under avatar_url, but unfortunately the name is not available, so we can just skip it :)

The Author filter would then look as follows:

<GithubFilter
      name="Author"
      header="Filter by author"
      placeholder="Filter authors"
      items={authors}
      renderItem={(author) => (
        <div className="flex gap-2 items-center">
          <img
            src={author.avatar_url}
            alt={author.login}
            className="w-4 h-4 rounded-lg border border-gray-300"
          />
          {author.login}
        </div>
      )}
    />

Lastly, you'll notice none of the components actually has working filtering :D
If you type something in the input, nothing happens.

To implement that, we need to hook in the input and filter the items based on the filterFn function:

const [filterQuery, setFilterQuery] = useState("");
const filteredItems = items.filter((item) => filterFn(item, filterQuery));

return (
    <div>
  
      {isOpen ? (
        <div ...>
          ...
          <input
            type="text"
            placeholder={placeholder}
            className="m-2 p-2 border border-gray-300 rounded-lg"
            value={filterQuery}
            onChange={(e) => setFilterQuery(e.target.value)}
          />
          <hr />
          <div className="overflow-auto h-64 flex flex-col">
            {filteredItems.map((item) => (
              <a ...> ... </a>
            ))}
          </div>
        </div>
      ) : null}
    </div>
  );

With this in place, each component can then customise its filter:

<GithubFilter
      name="Label"
      ...
      filterFn={(label, query) => label.name.match(new RegExp(query, "i"))}
    />

And that's that!

Checkout the full working solution over at https://github.com/reactpractice-dev/github-issues-filter-component/tree/solution.

How did you implement this? Share your thoughts in the comments below.