Recently, I implemented nested filters for the habits page on increaser.org. This article will guide you through creating similar interfaces using React and the Tree data structure.
Initially, we need to define a type for our tree node. It will be a generic type, usable for any kind of data and it only contains two fields: value
and children
. Value
is a generic type T
and children
is an array of TreeNode<T>
.
export interface TreeNode<T> {
value: T
children: TreeNode<T>[]
}
To locate a specific node, we will traverse the tree to the desired node using the getTreeNode
function. The path is an array of numbers, with each number being the index of the child node.
export function getTreeNode<T>(tree: TreeNode<T>, path: number[]): TreeNode<T> {
return path.reduce((node, i) => node.children[i], tree)
}
To retrieve all the values of a given node, we will employ the getTreeValues
function. This function returns an array of values of the specific node and all its children.
export function getTreeValues<T>(tree: TreeNode<T>): T[] {
return [tree.value, ...tree.children.flatMap(getTreeValues)]
}
We can now define our tree of habits. The data of a node will be represented by the HabitTreeNodeValue
which contains an id for the habit category, such as "health", "relationships", "work", etc. This also has an optional array of habit ids and an optional color. For instance, the "happiness" category doesn't have its own habits; instead, it's an combination of other habits defined in the children
field.
import { TreeNode } from "@radzionkit/utils/tree"
import { HabitId } from "./habits"
export interface HabitTreeNodeValue {
id: string
habits?: HabitId[]
color?: number
}
export interface HabitTreeNode extends TreeNode<HabitTreeNodeValue> {}
export const habitTree: HabitTreeNode = {
value: {
id: "happiness",
color: 5,
},
children: [
{
value: {
id: "health",
color: 4,
},
children: [
{
value: {
id: "sleep",
habits: [
"sunlight",
"limitCoffee",
"noAlcohol",
"earlySleep",
"noLateFood",
"noWorkAfterDinner",
"noElectronicsInBedroom",
],
},
children: [],
},
{
value: {
id: "nutrition",
habits: ["morningFast", "noLateFood", "supplements", "content"],
},
children: [],
},
{
value: {
id: "body",
habits: ["outdoors", "exercise", "walk"],
},
children: [],
},
{
value: {
id: "mind",
habits: [
"meditation",
"learn",
"max",
"noWorkAfterDinner",
"noElectronicsInBedroom",
],
},
children: [],
},
],
},
{
value: {
id: "relationships",
color: 11,
},
children: [
{
value: {
id: "marriage",
habits: [
"compliment",
"review",
"help",
"noWorkAfterDinner",
"noElectronicsInBedroom",
],
},
children: [],
},
],
},
{
value: {
id: "work",
color: 2,
},
children: [
{
value: {
id: "productivity",
habits: [
"noWorkAfterDinner",
"sunlight",
"limitCoffee",
"noAlcohol",
"earlySleep",
"morningFast",
"prepare",
"noEarlyCoffee",
"noLateFood",
"outdoors",
"exercise",
"noElectronicsInBedroom",
],
},
children: [],
},
],
},
],
}
Following this, we find a list of habits that aren't aware they are being used in a tree. They are only a list of unique habit ids and information about them which includes emoji
, name
, and description
.
With React, we can now implement our nested filters. The present category is stored as an array of numbers in the useState
hook. The 'happiness' category will be an empty array, the 'health' category will be [0]
, the 'sleep' category will be [0, 0]
, and so forth.
import { capitalizeFirstLetter } from "@radzionkit/utils/capitalizeFirstLetter"
import { getTreeNode, getTreeValues } from "@radzionkit/utils/tree"
import { withoutDuplicates } from "@radzionkit/utils/array/withoutDuplicates"
import { HStack, VStack } from "@radzionkit/ui/ui/Stack"
import { TreeFilter } from "@radzionkit/ui/ui/tree/TreeFilter"
import { useState, useMemo } from "react"
import styled from "styled-components"
import { HabitTreeNode, habitTree } from "./data/habitTree"
import { habitRecord } from "./data/habits"
import { Text } from "@radzionkit/ui/ui/Text"
import { HabitItem } from "./HabitItem"
const Container = styled(HStack)`
width: 100%;
flex-wrap: wrap;
gap: 40px;
align-items: start;
`
const Content = styled(VStack)`
gap: 20px;
flex: 1;
`
const FilterWrapper = styled.div`
position: sticky;
top: 0;
`
const getCategoriesColors = (
{ value, children }: HabitTreeNode,
parentColor?: number
): Record<string, number | undefined> => {
const color = value.color ?? parentColor
return {
[value.id]: color,
...children.reduce(
(acc, child) => ({
...acc,
...getCategoriesColors(child, color),
}),
{}
),
}
}
const defaultColor = 3
export const CuratedHabits = () => {
const [path, setPath] = useState<number[]>([])
const values = useMemo(() => getTreeValues(habitTree), [])
const categoryColorRecord = useMemo(() => getCategoriesColors(habitTree), [])
const node = getTreeNode(habitTree, path)
const habits = withoutDuplicates(
getTreeValues(node).flatMap((value) => value.habits || [])
)
.map((id) => ({
id,
...habitRecord[id],
}))
.map((habit) => ({
...habit,
tags: values
.filter((value) => value.habits?.includes(habit.id))
.map((value) => ({
name: value.id,
color: categoryColorRecord[value.id] ?? defaultColor,
})),
}))
return (
<Container>
<FilterWrapper>
<TreeFilter
tree={habitTree}
renderName={(value) => capitalizeFirstLetter(value.id)}
value={path}
onChange={setPath}
/>
</FilterWrapper>
<Content>
<Text weight="bold" size={24}>
{capitalizeFirstLetter(node.value.id)} habits{" "}
<Text as="span" color="supporting">
({habits.length})
</Text>
</Text>
{habits.map((habit) => (
<HabitItem {...habit} key={habit.id} />
))}
</Content>
</Container>
)
}
Using the getTreeValues
function, we'll obtain all the habits in the tree. Every habit has a colored tag, but not all have a color field defined. It's only present at the category level. So, we'll use the getCategoriesColors
to get a record of category ids and their colors. It's a recursive function that assigns the color of the parent category to its children if they don't have their own color defined.
To get the current node, we use the getTreeNode
function. Some habits may be present in more than one category. For instance, the habit "View sunlight after waking up" is in both the "sleep" and "productivity" category. We don't want to display it twice, so we remove duplicates with the withoutDuplicates
function. Then we add a list of tags to each unique habit. The tags represent the categories to which the habit belongs. We use the categoryColorRecord
to fetch the color of the category.
export function withoutDuplicates<T>(
items: T[],
areEqual: (a: T, b: T) => boolean = (a, b) => a === b
): T[] {
const result: T[] = []
items.forEach((item) => {
if (!result.find((i) => areEqual(i, item))) {
result.push(item)
}
})
return result
}
To display the habits, we pull from the habits
array and exploit the HabitItem
component. The generic TreeFilter
component is what we'll depend on to filter the habits.
import { useState, Fragment } from "react"
import styled, { useTheme } from "styled-components"
import { Circle } from "../Circle"
import { NonEmptyOnly } from "../NonEmptyOnly"
import { VStack, HStack } from "../Stack"
import { defaultTransitionCSS } from "../animations/transitions"
import { getVerticalPaddingCSS } from "../utils/getVerticalPaddingCSS"
import { Text } from "../Text"
import { handleWithStopPropagation } from "../../shared/events"
import { InputProps } from "../../props"
import { TreeNode } from "@radzionkit/utils/tree"
interface TreeFilterProps<T> extends InputProps<number[]> {
tree: TreeNode<T>
renderName: (value: T) => string
}
const Content = styled(VStack)`
margin-left: 20px;
`
const Container = styled(VStack)`
cursor: pointer;
`
const Item = styled(HStack)`
${getVerticalPaddingCSS(4)}
align-items: center;
gap: 8px;
${defaultTransitionCSS}
`
export function TreeFilter<T>({
tree,
renderName,
value,
onChange,
}: TreeFilterProps<T>) {
const [hovered, setHovered] = useState<number[] | undefined>()
const { colors } = useTheme()
const recursiveRender = (node: TreeNode<T>, path: number[]) => {
const isSelected = value.every((v, i) => v === path[i])
let color = isSelected ? colors.text : colors.textShy
if (hovered) {
const isHovered = hovered.every((v, i) => v === path[i])
color = isHovered ? colors.text : colors.textShy
}
return (
<Container
onClick={handleWithStopPropagation(() => onChange(path))}
onMouseEnter={() => setHovered(path)}
onMouseLeave={() => {
setHovered(
path.length === 0 ? undefined : path.slice(0, path.length - 1)
)
}}
>
<Item
style={{
color: color.toCssValue(),
}}
>
<Circle
size={8}
background={isSelected ? colors.primary : colors.transparent}
/>
<Text weight="bold">{renderName(node.value)}</Text>
</Item>
<NonEmptyOnly
array={node.children}
render={(items) => (
<Content>
{items.map((child, index) => (
<Fragment key={index}>
{recursiveRender(child, [...path, index])}
</Fragment>
))}
</Content>
)}
/>
</Container>
)
}
return <>{recursiveRender(tree, [])}</>
}
Its props extend the generic InputProps
, which consist of value
and onChange
props. In this case, the value will be the path to the node. We must also pass the entire tree to the component and a function that will render the name of the node.
As we're rendering a tree structure, we can't avoid recursion. In the recursiveRender
function, we check if the current node is selected by comparing the value
prop with the path
argument. We then apply different styles to the filter item based on their selection status. We also update the color of the item when it's hovered by changing the hovered
state. The handleWithStopPropagation
function is used to prevent the click event from bubbling up to the parent element.
We render the children of the node within the Content
component, which indents them from the parents with a 20px margin on the left side. As we don't want to render the Content
component when there are no children, we use a small helper component called NonEmptyOnly
to render the children only if they exist.
import { ReactNode } from "react"
interface NonEmptyOnlyProps<T> {
array?: T[]
render: (array: T[]) => ReactNode
}
export function NonEmptyOnly<T>({ array, render }: NonEmptyOnlyProps<T>) {
if (array && array.length > 0) {
return <>{render(array)}</>
}
return null
}