Tutorial: Build the Github Issue Filter component
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.
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715085056.png)
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).
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715085407.png)
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.
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715090132.png)
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 usedfont-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
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715091230.png)
Now, this looks good, but we seem to have some positioning issues:
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715091906.png)
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:
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715093309.png)
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
andonOpenChange
props to theuseFloating
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
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715100030.png)
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.
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715143256.png)
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:
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715144608.png)
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 :)
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240715144724.png)
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"))}
/>
![](https://reactpractice.dev/content/images/2024/07/Pasted-image-20240716100341.png)
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.
Member discussion