This is a single article from a series about creating of an advanced Data table component using React, TanStack Table 8, Tailwind CSS and Headless UI.
In the previous episode, we've extended table view functionality with sorting. This time, we are going to allow users to filter table columns.
Table cell filtering applies provided criteria to all cells of the certain column and shows the result to the user.
Here is the demo of the feature.
Create filter UI
This feature involves several UI elements. We need to create the following components:
Dialog. The component to show modal content over the main flow.
Various inputs field. Required to capture user filter changes.
Button. To be able to set filter.
Reusable TableDialog
component
We are going to create the TableDialog
component using Headless UI. We use Tailwind CSS stone-*
colors to match already used in the table. Component is placed in src/DataTable/dialogs/TableDialog.tsx
file.
Here are 3 goals we'll achieve with the TableDialog
component
Reusability: the component is reusable and accepts various content (children). It also has an optional
title
for the header text, anopen
prop to control the modal state, and anonClose
callback for closing the dialog.Backdrop: We use the
DialogBackdrop
component from Headless with the classesfixed inset-0 bg-stone-600/30 backdrop-blur-sm
to create a semi-transparent backdrop that blurs the underlying content.Transitions:
DialogPanel
component uses the classestransition duration-300 ease-out data-[closed]:opacity-0
to create fade in/out transitions for the dialog window.
Here is the code:
import { FC, ReactNode } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
DialogBackdrop,
} from '@headlessui/react';
import classNames from 'classnames';
export type Props = {
title?: string;
children: ReactNode;
open?: boolean;
onClose?: () => void;
};
export const TableDialog: FC<Props> = ({
title,
children,
open = false,
onClose = () => {},
}) => {
return (
<Dialog
open={open}
as="div"
className="relative z-50 focus:outline-none"
onClose={onClose}
>
{/* Create a backdrop */}
<DialogBackdrop className="..." />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-center justify-center">
<DialogPanel
// required to enable transition
transition
className={classNames(
// Tailwind classes
// ...
)}
>
{title && (
<DialogTitle
title={title}
as="h3"
>
{title}
</DialogTitle>
)}
{children}
</DialogPanel>
</div>
</div>
</Dialog>
);
};
Filter Dialog User eXperience
To implement modal component logic, we have to implement manageable state for the column filter dialog.
The hook, located at src/DataTable/features/useFilterDialogState.ts
, is responsible for controlling the Filter Dialog state. It has two distinct React states: one to track whether the dialog is open or closed, and another to store the ID of the currently selected column. This separation enables the openDialog
function to effectively handle both actions: opening the dialog and recording the selected column's ID for subsequent filtering operations.
import { useState, useCallback } from 'react';
export const useFilterDialogState = () => {
const [open, setOpen] = useState(false);
const [columnId, setColumnId] = useState<string>();
const openDialog = useCallback((selectedId: string) => {
setColumnId(selectedId);
setOpen(true);
}, []);
const closeDialog = useCallback(() => {
setOpen(false);
}, []);
return { openDialog, isOpen: open, closeDialog, selectedId: columnId };
};
This hook will be placed within the src/DataTable/DataTable.tsx
component. We'll save the openDialog
callback into the TanStack Table Meta configuration, following the same approach we used for the locale
setting.
const DataTable: FC<Props> = ({ tableData, locale = 'en-US' }) => {
// Initialize filter dialog state
const {
openDialog: openFilterDialog,
isOpen,
closeDialog,
selectedId,
} = useFilterDialogState();
const table = useReactTable({
meta: {
openFilterDialog,
//...
},
//...
}
//..
return (
<Fragment>
<FilterDialog
selectedColumn={selectedId}
isOpen={isOpen}
onClose={closeDialog}
tableContext={table}
/>
{/*...*/}
</Fragment>
}
We extend src/DataTable/declarations.d.ts
:
import '@tanstack/react-table';
import { Row } from './types.ts';
declare module '@tanstack/react-table' {
interface TableMeta<TData extends Row> {
openFilterDialog: (columnId: string) => void;
//...
}
//...
}
We can now programmatically control the Filter Dialog by invoking callbacks to open and close it manually. Finally, we'll integrate a filter action into our menu. This involves calling the openFilterDialog
function previously defined within the Table Meta configuration.
export const useColumnActions = (
context: HeaderContext<Row, unknown>,
): ColumnAction[] => {
//...
// Get column filter state using TanStack table API
const isFiltered = context.column.getIsFiltered();
return useMemo<ColumnAction[]>(
() => [
//...
{
label: !isFiltered ? 'Apply filter' : 'Edit/Remove filter',
icon: <Icon name="funnel" className="text-lg" />,
onClick: () => {
context.table.options.meta?.openFilterDialog(context.column.id);
},
},
],
[context.column, context.table, isFiltered, isPinned, isSorted],
);
};
Create inputs
Previously, we created 4 different Content types:
enum ContentTypes {
Text = 'Text',
Number = 'Number',
Date = 'Date',
Country = 'Country',
}
Now we'll support each of them with the relevant input to capture user filter values.
We are going to create 4 different input field components in the src/DataTable/inputs
folder.
Input component interface
label
: A string representing the text label displayed above the input field.className
: An optional string for applying custom CSS classes to the component.placeholder
: An optional string providing a text hint to the user within the input field.value
: An optional property representing the current value of the filter.onChange
: A function that handles changes to the input field's value.icon
: An optional string specifying the name of the icon to be displayed to the left of the input field.
Text input
Here is the TextInput
component code. Headless Field, Input, and Label, components are used to ensure accessibility requirements are met.
import { FC, useCallback, ChangeEvent } from 'react';
import { Field, Input, Label } from '@headlessui/react';
import classNames from 'classnames';
import { Icon } from '../../Icon.tsx';
export const TextField: FC<Props> = ({
label,
className,
placeholder,
value,
onChange,
icon = 'text-t',
}) => {
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
},
[onChange],
);
return (
<Field className="flex flex-col gap-1.5">
<Label
className={classNames(
'text-white/70 cursor-pointer text-sm font-semibold ',
)}
>
{label}
</Label>
<div className="flex flex-row items-center gap-2">
<Icon
name={icon}
className="shrink-0 text-2xl text-white/80"
variant="fill"
/>
<Input
onChange={handleChange}
value={value}
type="text"
placeholder={placeholder}
className={classNames(
// Tailwind classes
className,
)}
/>
</div>
</Field>
);
};
Numeric input
Similar to Text input, Numeric input utilizes the Headless UI Input component, with some adjustments for number display and basic validation.
Display: Tailwind classes
text-right tabular-nums
are applied to align numbers to the right and enhance their visual presentation in tables.Validation: The
pattern
attribute is used to restrict user input to valid decimal numbers. The provided pattern[+-]?(?:0|[1-9]\d*)(?:\.\d+)?"
allows positive, negative numbers, zero, and decimals. This article explains the topic in detail.
Date input
Date input expects the value to be a valid Date ISO string. We set the type
attribute of HTMLInputComponent to date
and utilize event.target.valueAsDate
property to capture input value as a Date object.
To be able to display selected date, we need to format our ISO string value into yyyy-mm-dd
format.
Here is how to do it. Notice, that we are reversing date formatted using hardcoded en-GB
locale, which is dd/mm/yyyy
.
const value = valueProp
? new Date(valueProp)
.toLocaleDateString('en-GB')
.split('/')
.reverse()
.join('-')
: '';
Combo input
For the Country content type, we'll pick a Headless UI Combobox input due to its suitability for handling enumerable data like a limited list of countries.
The code is located at src/DataTable/inputs/ComboField.tsx
.
Here is the demo of country selection:
To have a full list of country names linked to the country codes, we are storing in the table, we are going to create a locale-aware mocking function src/mocks/createCountryList.ts
:
import { countryCodes } from './countryCodes.ts';
export const createCountryList = (locale = 'en-US') =>
countryCodes
.map((code) => ({
value: code,
// use a standard browser built-in object to get
// a locale-aware country name from region code
label: new Intl.DisplayNames(locale, { type: 'region' }).of(code) || '',
}))
.sort(({ label: leftLabel }, { label: rightLabel }) =>
// use a standard browser built-in object to compare country names
new Intl.Collator(locale).compare(leftLabel, rightLabel),
);
Input composition
The currently visible input type now depends on the selected column content type.
const filterInput = useMemo(() => {
const countryOptions = createCountryList(tableContext.options.meta?.locale);
return selectedColumn
? {
[ContentTypes.Text]: (
<TextField
label="Cell contains:"
placeholder="Enter text (case insensitive)"
onChange={setNextFilterValue}
value={nextFilterValue as string}
/>
),
[ContentTypes.Number]: (
<NumericField
label="Number is bigger than:"
placeholder="-123.456"
onChange={setNextFilterValue}
value={nextFilterValue as string}
/>
),
[ContentTypes.Date]: (
<DateField
label="Filter dates after:"
placeholder="Pick date"
onChange={setNextFilterValue}
value={nextFilterValue as string}
/>
),
[ContentTypes.Country]: (
<ComboField
value={nextFilterValue as string}
icon="globe-stand"
options={countryOptions}
label="Select country"
placeholder="Start typing or expand"
onChange={setNextFilterValue}
/>
),
}[selectedColumn.contentType]
: null;
}, [nextFilterValue, selectedColumn, tableContext.options.meta?.locale]);
Button component
We also need a basic button to capture user clicks. The component (src/DataTable/inputs/Button.tsx
) needs to support a disabled state. This is because we want to prevent users from setting an empty filter and instead encourage them to use a reset function.
We'll use Headless Button and use Tailwind disabled:
helper to manage styles.
Apply and reset filter(s)
The filtering feature requires a list of columns that can be filtered. Not all columns may be filterable (we'll explore this further in next chapters).
The src/DataTable/features/useFilterableColumns.ts
hook retrieves the table's column configuration using the TanStack Table API. It then transforms this raw column data into a new array specifically designed for the Filter Dialog. This array contains only the necessary information for each filterable column. Finally, the hook removes any columns from the array where filtering is explicitly disabled, as well as the filterable
prop itself.
import { Table } from '@tanstack/react-table';
import { useMemo } from 'react';
import { Row, ContentTypes } from '../types.ts';
/**
* Column definition to use in Dialogs
*/
export type Column = {
id: string;
title: string;
contentType: keyof typeof ContentTypes;
};
export const useFilterableColumns = (table: Table<Row>): Column[] => {
return useMemo(
() =>
table
.getAllColumns()
.map((column) => {
return {
title: column.columnDef.meta?.title as string,
contentType: column.columnDef.meta
?.contentType as keyof typeof ContentTypes,
id: column.id,
filterable: column?.getCanFilter(),
filterValue: column?.getFilterValue(),
};
})
//
.filter(({ filterable }) => filterable)
.map(({ filterable, ...restProps }) => ({
...restProps,
})),
[table],
);
};
The src/DataTable/dialogs/FilterDialog.tsx
component implements the following filtering logic. It obtains a list of all filterable columns by utilizing the useFilterableColumns.ts
hook. The component determines the currently selected column from the list of available filterable columns. The current filter value for the selected column is retrieved using the TanStack Table API. A state variable is created to capture the next filter value. An effect is implemented to ensure that the state variable remains synchronized with the selectedColumn
prop, effectively updating the filter value whenever the selected column changes
export const FilterDialog: FC<Props> = ({
isOpen,
selectedColumn: selectedColumnProp,
onClose = () => {},
tableContext,
}) => {
const columns = useFilterableColumns(tableContext);
const selectedColumn = useMemo(
() => columns.find(({ id }) => id === selectedColumnProp),
[columns, selectedColumnProp],
);
const filterValue =
selectedColumnProp &&
(tableContext.getColumn(selectedColumnProp)?.getFilterValue() as
| string
| undefined);
const [nextFilterValue, setNextFilterValue] = useState<string>('');
useEffect(() => {
setNextFilterValue(filterValue || '');
}, [
filterValue,
// This dependency ensures that the state is cleared after Dialog dismissed
selectedColumn,
]);
//...
}
We bind set and reset functions to the corresponding Filter Dialog buttons.
const handleReset = useCallback(() => {
tableContext.getColumn(selectedColumnProp as string)?.setFilterValue('');
onClose();
}, [onClose, selectedColumnProp, tableContext]);
const handleSetFilter = useCallback(() => {
tableContext
.getColumn(selectedColumnProp as string)
?.setFilterValue(nextFilterValue);
onClose();
}, [nextFilterValue, onClose, selectedColumnProp, tableContext]);
Implement data filtering
TanStack Table offers extensive Filtering API support.
Column definitions at src/DataTable/columnsConfig.tsx
have to be extended with filterFn
property dictating which filter function should be applied to the column data. Here, we can choose among TanStack built-in functions or create our own.
To enable data filtering, you need to extend the column definitions in src/DataTable/columnsConfig.tsx
with the filterFn
property. This property specifies the filter function to be applied to the column data. You can choose from TanStack built-in functions or create our own
includesString
forText
content type (built-in function).isBiggerNumber
forNumber
content type (custom function).isAfterDate
forDate
content type (custom function).
Custom filters
We put custom filter functions inside src/DataTable/features/filterFns.ts
.
import { FilterFn } from '@tanstack/react-table';
import { Row } from '../types.ts';
export const isBiggerNumber: FilterFn<Row[]> = (
row,
columnId,
filterValue: number,
) => {
return row.getValue<number>(columnId) > filterValue;
};
export const isAfterDate: FilterFn<Row[]> = (
row,
columnId,
filterValue: Date,
) => {
const cellDate = new Date(row.getValue(columnId));
return cellDate.getTime() > filterValue.getTime();
};
isAfterDate.resolveFilterValue = (filterValue) => {
return new Date(filterValue);
};
Apply Filtered Row Model
Next step, we import and apply the Filtered Row Model to the table, register custom filter functions and initialize the Filter Dialog state.
import {
getFilteredRowModel,
} from '@tanstack/react-table';
import { useFilterDialogState } from './features/useFilterDialogState.ts';
import { isBiggerNumber, isAfterDate } from './features/filterFns.ts';
const DataTable: FC<Props> = ({ tableData, locale = 'en-US' }) => {
// ...
const {
openDialog: openFilterDialog,
isOpen,
closeDialog,
selectedId,
} = useFilterDialogState();
const table = useReactTable({
meta: {
// save reference for open filter dialog callback
openFilterDialog,
},
getFilteredRowModel: getFilteredRowModel(),
filterFns: {
// set the custom filter function for numeric content
isBiggerNumber,
// set the custom filter function for dates
isAfterDate
},
//...
});
return (
<Fragment>
<FilterDialog
selectedColumn={selectedId}
isOpen={isOpen}
onClose={closeDialog}
tableContext={table}
/>
{/*...*/}
</Fragment>
)
}
Style filtered cells
We have to conditionally change the style of filtered cells, making them more visible.
<td
key={cell.id}
className={classNames(
// ... rest classes
{
'font-normal': !cell.column.getIsFiltered(),
'font-semibold italic': cell.column.getIsFiltered(),
},
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
Handle no results
Sometimes the filtering will provide an empty result to the user. We can handle this case by showing warning and button to reset filters.
const handleResetFilters = useCallback(() => {
table.resetColumnFilters();
}, [table]);
// ...
return (
<Fragment>
{/*...*/}
{rows.length === 0 && (
<div className="flex items-center justify-center gap-3 p-3 font-medium">
<div>No data to render.</div>
<Button
icon="funnel-x"
title="Reset all filters"
onClick={handleResetFilters}
/>
</div>
)}
</Fragment>
)
Mouse over row highlight
The last step of this exercise will be adding the highlight to table rows when the user is hovering them. This enhancement aims to further improve our table focusing capabilities.
Here is the demo.
We implement this feature using the Tailwind group:
helper, which translates parent states to children.
<tr className="group">
<td className="group-hover:!bg-cyan-100" />
</tr>
Demo
Here is a working demo of this exercise.
To be continued...
Top comments (0)