让我们创建数据表之列过滤

这是关于使用 **React**、**TanStack Table 8**、**Tailwind CSS** 和 **Headless UI** 创建高级数据表组件的系列文章中的一篇。

在上一集中,我们扩展了表视图的功能,使其具有排序功能。这一次,我们将允许用户过滤表列。

表格单元格过滤将提供的条件应用于某一列的所有单元格并向用户显示结果。

这是该功能的演示。

创建过滤器 UI

此功能涉及多个 UI 元素。我们需要创建以下组件:

  • 对话框。在主流程上显示模态内容的组件。
  • 各种输入字段。需要捕获用户过滤器更改。
  • 按钮。可以设置过滤器。
  • Filter UI

    可重复使用的 TableDialog 组件

    我们将使用 Headless UI 创建 `TableDialog` 组件。我们使用 Tailwind CSS `stone-*` 颜色来匹配表格中已经使用的颜色。组件位于 `src/DataTable/dialogs/TableDialog.tsx` 文件中。

    以下是我们将使用 `TableDialog` 组件实现的 3 个目标

  • 可重用性:该组件可重用并接受各种内容(子项)。它还具有可选的标题标题文本、用于控制模态状态的 open 属性以及用于关闭对话框的 onClose 回调。
  • 背景:我们使用 Headless 的 DialogBackdrop 组件,并使用类 fixed inset-0 bg-stone-600/30 BACKGROUND-Blur-sm 来创建模糊底层内容的半透明背景。
  • 过渡:DialogPanel 组件使用类 transition duration-300 ease-out data-[closed]:opacity-0 为对话框窗口创建淡入/淡出过渡。
  • 以下是代码:

    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 = ({
      title,
      children,
      open = false,
      onClose = () => {},
    }) => {
      return (
        
          {/* Create a backdrop */}
          
          
    {title && ( {title} )} {children}
    ); };

    过滤对话框用户体验

    为了实现模态组件逻辑,我们必须为列过滤器对话框实现可管理的状态。

    该钩子位于 `src/DataTable/features/useFilterDialogState.ts`,负责控制过滤对话框状态。它有两个不同的 React 状态:一个用于跟踪对话框是打开还是关闭,另一个用于存储当前选定列的 ID。这种分离使 `openDialog` 函数能够有效地处理这两个操作:打开对话框并记录选定列的 ID 以供后续过滤操作使用。

    import { useState, useCallback } from 'react';
    
    export const useFilterDialogState = () => {
      const [open, setOpen] = useState(false);
      const [columnId, setColumnId] = useState();
    
      const openDialog = useCallback((selectedId: string) => {
        setColumnId(selectedId);
        setOpen(true);
      }, []);
    
      const closeDialog = useCallback(() => {
        setOpen(false);
      }, []);
    
      return { openDialog, isOpen: open, closeDialog, selectedId: columnId };
    };

    此钩子将被放置在 `src/DataTable/DataTable.tsx` 组件内。我们将把 `openDialog` 回调保存到 TanStack Table Meta 配置中,方法与我们用于 `locale` 设置的方法相同。

    const DataTable: FC = ({ tableData, locale = 'en-US' }) => {
    
      // Initialize filter dialog state
      const {
        openDialog: openFilterDialog,
        isOpen,
        closeDialog,
        selectedId,
      } = useFilterDialogState();
    
      const table = useReactTable({
        meta: {
          openFilterDialog,
          //...
        },
        //...
       }
      //..
      return (
        
          
          {/*...*/}
        
    }

    我们扩展`src/DataTable/declarations.d.ts`:

    import '@tanstack/react-table';
    import { Row } from './types.ts';
    
    declare module '@tanstack/react-table' {
      interface TableMeta {
         openFilterDialog: (columnId: string) => void;
         //...
      }
      //...
    }

    现在,我们可以通过手动调用回调来以编程方式控制 Filter Dialog。最后,我们将过滤操作集成到菜单中。这涉及调用先前在 Table Meta 配置中定义的“openFilterDialog”函数。

    export const useColumnActions = (
      context: HeaderContext,
    ): ColumnAction[] => {
      //...
    
      // Get column filter state using TanStack table API
      const isFiltered = context.column.getIsFiltered();
    
      return useMemo(
        () => [
          //...
          {
            label: !isFiltered ? 'Apply filter' : 'Edit/Remove filter',
            icon: ,
            onClick: () => {
              context.table.options.meta?.openFilterDialog(context.column.id);
            },
          },
        ],
        [context.column, context.table, isFiltered, isPinned, isSorted],
      );
    };

    创建输入

    之前,我们创建了 4 种不同的内容类型:

    enum ContentTypes {
      Text = 'Text',
      Number = 'Number',
      Date = 'Date',
      Country = 'Country',
    }

    现在我们将通过相关输入支持它们每一个来捕获用户过滤值。

    Input component interface

    我们将在“src/DataTable/inputs”文件夹中创建 4 个不同的输入字段组件。

    输入组件接口

  • 标签:表示显示在输入字段上方的文本标签的字符串。
  • className:用于将自定义 CSS 类应用到组件的可选字符串。
  • 占位符:在输入字段中向用户提供文本提示的可选字符串。
  • value:可选属性,表示过滤器的当前值。
  • onChange:处理输入字段值变化的函​​数。
  • 图标:可选字符串,指定显示在输入字段左侧的图标的名称。
  • 文本输入

    这是 `TextInput` 组件代码。Headless Field、Input 和 Label 组件用于确保满足可访问性要求。

    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 = ({
      label,
      className,
      placeholder,
      value,
      onChange,
      icon = 'text-t',
    }) => {
      const handleChange = useCallback(
        (event: ChangeEvent) => {
          onChange(event.target.value);
        },
        [onChange],
      );
      return (
        
          
          
    ); };

    数字输入

    与文本输入类似,数字输入使用无头 UI 输入组件,并对数字显示和基本验证进行了一些调整。

  • 显示:Tailwind 类 text-right tabular-nums 用于将数字右对齐并增强其在表格中的视觉呈现。
  • 验证:pattern 属性用于将用户输入限制为有效的十进制数。提供的模式 [+-]?(?:0|[1-9]\d*)(?:\.\d+)?” 允许正数、负数、零和小数。本文详细解释了该主题。
  • 日期输入

    日期输入要求值为有效的日期 ISO 字符串。我们将 HTMLInputComponent 的“type”属性设置为“date”,并利用“event.target.valueAsDate”属性将输入值捕获为日期对象。

    为了能够显示选定的日期,我们需要将 ISO 字符串值格式化为“yyyy-mm-dd”格式。

    以下是具体操作方法。请注意,我们正在反转使用硬编码“en-GB”语言环境格式化的日期,即“dd/mm/yyyy”。

    const value = valueProp
        ? new Date(valueProp)
            .toLocaleDateString('en-GB')
            .split('/')
            .reverse()
            .join('-')
        : '';

    组合输入

    对于国家/地区内容类型,我们将选择无头 UI 组合框输入,因为它适合处理可枚举数据(例如有限的国家/地区列表)。

    代码位于`src/DataTable/inputs/ComboField.tsx`。

    以下是国家选择的演示:

    为了获得与国家代码相关联的国家名称的完整列表,我们将存储在表中,我们将创建一个可感知语言环境的模拟函数“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),
        );

    输入组合

    当前可见的输入类型现在取决于所选的列内容类型。

    const filterInput = useMemo(() => {
        const countryOptions = createCountryList(tableContext.options.meta?.locale);
        return selectedColumn
          ? {
              [ContentTypes.Text]: (
                
              ),
              [ContentTypes.Number]: (
                
              ),
              [ContentTypes.Date]: (
                
              ),
              [ContentTypes.Country]: (
                
              ),
            }[selectedColumn.contentType]
          : null;
      }, [nextFilterValue, selectedColumn, tableContext.options.meta?.locale]);

    按钮组件

    我们还需要一个基本按钮来捕获用户点击。组件(`src/DataTable/inputs/Button.tsx`)需要支持禁用状态。这是因为我们希望防止用户设置空过滤器,而是鼓励他们使用重置功能。

    我们将使用 Headless Button 并使用 Tailwind `disabled:` 助手来管理样式。

    应用并重置过滤器

    过滤功能需要可过滤的列列表。并非所有列都可过滤(我们将在下一章中进一步探讨这一点)。

    `src/DataTable/features/useFilterableColumns.ts` 钩子使用 TanStack Table API 检索表的列配置。然后,它将原始列数据转换为专为过滤对话框设计的新数组。此数组仅包含每个可过滤列的必要信息。最后,该钩子从数组中删除明确禁用过滤的所有列以及 `filterable` 属性本身。

    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): 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],
      );
    };

    `src/DataTable/dialogs/FilterDialog.tsx` 组件实现了以下过滤逻辑。它利用 `useFilterableColumns.ts` 钩子获取所有可过滤列的列表。该组件从可用的可过滤列列表中确定当前选定的列。使用 TanStack Table API 检索选定列的当前过滤器值。创建状态变量以捕获下一个过滤器值。实现一个以确保状态变量与 `selectedColumn` 属性保持同步,从而每当选定列发生变化时有效地更新过滤器值

    export const FilterDialog: FC = ({
      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('');
    
      useEffect(() => {
        setNextFilterValue(filterValue || '');
      }, [
        filterValue,
        // This dependency ensures that the state is cleared after Dialog dismissed
        selectedColumn,
      ]);
      //...
    }

    我们将设置和重置功能绑定到相应的过滤对话框按钮。

    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]);

    实施数据过滤

    TanStack Table 提供广泛的过滤 API 支持。

    要启用数据过滤,您需要使用 `filterFn` 属性扩展 `src/DataTable/columnsConfig.tsx` 中的列定义。此属性指定要应用于列数据的过滤函数。您可以从 TanStack 内置函数中进行选择,也可以创建我们自己的函数

  • includesString 为文本内容类型(内置函数)。
  • isBiggerNumber 为 Number 内容类型(自定义函数)。
  • isAfterDate 为日期内容类型(自定义函数)。
  • 自定义过滤器

    我们将自定义过滤函数放在`src/DataTable/features/filterFns.ts`中。

    import { FilterFn } from '@tanstack/react-table';
    
    import { Row } from '../types.ts';
    
    export const isBiggerNumber: FilterFn = (
      row,
      columnId,
      filterValue: number,
    ) => {
      return row.getValue(columnId) > filterValue;
    };
    
    export const isAfterDate: FilterFn = (
      row,
      columnId,
      filterValue: Date,
    ) => {
      const cellDate = new Date(row.getValue(columnId));
      return cellDate.getTime() > filterValue.getTime();
    };
    
    isAfterDate.resolveFilterValue = (filterValue) => {
      return new Date(filterValue);
    };

    应用筛选行模型

    下一步,我们导入并应用过滤行模型到表,注册自定义过滤函数并初始化过滤对话框状态。

    import {
      getFilteredRowModel,
    } from '@tanstack/react-table';
    import { useFilterDialogState } from './features/useFilterDialogState.ts';
    import { isBiggerNumber, isAfterDate } from './features/filterFns.ts';
    
    const DataTable: FC = ({ 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 (
        
         
         {/*...*/}
        
      )
    }

    设置已筛选单元格的样式

    我们必须有条件地改变过滤单元格的样式,使它们更加明显。

    
      {flexRender(
        cell.column.columnDef.cell,
        cell.getContext(),
      )}
    

    处理无结果

    有时过滤会向用户提供空结果。我们可以通过显示警告和重置过滤器的按钮来处理这种情况。

    const handleResetFilters = useCallback(() => {
      table.resetColumnFilters();
    }, [table]);
    
    // ...
    
    return (
      
        {/*...*/}
        {rows.length === 0 && (
          
    No data to render.
    )}
    )

    鼠标悬停在行突出显示上

    本练习的最后一步是当用户将鼠标悬停在表格行上时,将突出显示添加到表格行。此增强功能旨在进一步提高我们的表格聚焦能力。

    这是演示。

    我们使用 Tailwind `group:` 助手实现此功能,它将父状态转换为子状态。

    
      
    

    演示

    这是此练习的工作演示。