shadimage
hirael
componentdata-tablesource: ok · refreshed

Data Table

TanStack-powered data table: faceted, text, range and date filters, sortable columns, column visibility, row selection and pagination, with page, sort and filters kept in the URL. One useDataTable hook drives it: pass pageCount to query server-side, or omit it to sort, filter and page a local array in memory. Ships as a folder of composable parts.

npx shadcn add "https://hirael.com/r/data-table.json"
free

Where it goes

data-table

Interaction

dropdown

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react
formszod

Registry dependencies

badgebuttoncalendarcommanddropdown-menufieldinputpopoverselectseparatorskeletonslidertable

Files14 · source cached 10/8/2026, 6:18:05 AM

registry/hirael/bases/radix/components/data-table/data-table.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { DataTablePagination } from './data-table-pagination';
import type { DataTableFeatures } from './data-table-features';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/registry/hirael/bases/radix/ui/table';
import { getColumnPinningStyle } from './data-table-utils';
import { cn } from '@/lib/utils';
import type { ReactTable, RowData } from '@tanstack/react-table';
import type * as React from 'react';
import { DEFAULT_DATA_TABLE_LABELS, DataTableLabelsContext, type DataTableLabels } from './data-table-labels';

interface DataTableProps<TData extends RowData> extends React.ComponentProps<'div'> {
  table: ReactTable<DataTableFeatures, TData>;
  actionBar?: React.ReactNode;
  /** Override any of the table's text, e.g. for another language. */
  labels?: Partial<DataTableLabels>;
}

export const DataTable = <TData extends RowData>({
  table,
  actionBar,
  labels,
  children,
  className,
  ...props
}: DataTableProps<TData>) => {
  return (
    <DataTableLabelsContext.Provider value={{ ...DEFAULT_DATA_TABLE_LABELS, ...labels }}>
      <div data-slot="data-table" className={cn('flex w-full flex-col gap-2.5 overflow-auto', className)} {...props}>
        {children}
        <div data-slot="data-table-content" className="overflow-hidden rounded-md border">
          <Table>
            <TableHeader>
              {table.getHeaderGroups().map((headerGroup) => (
                <TableRow key={headerGroup.id}>
                  {headerGroup.headers.map((header) => (
                    <TableHead
                      key={header.id}
                      colSpan={header.colSpan}
                      style={{
                        ...getColumnPinningStyle({ column: header.column }),
                      }}
                    >
                      {header.isPlaceholder ? null : <table.FlexRender header={header} />}
                    </TableHead>
                  ))}
                </TableRow>
              ))}
            </TableHeader>
            <TableBody>
              {table.getRowModel().rows?.length ? (
                table.getRowModel().rows.map((row) => (
                  <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
                    {row.getVisibleCells().map((cell) => (
                      <TableCell
                        key={cell.id}
                        style={{
                          ...getColumnPinningStyle({ column: cell.column }),
                        }}
                      >
                        <table.FlexRender cell={cell} />
                      </TableCell>
                    ))}
                  </TableRow>
                ))
              ) : (
                <TableRow>
                  <TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center">
                    {labels?.noResults ?? DEFAULT_DATA_TABLE_LABELS.noResults}
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </div>
        <div className="flex flex-col gap-2.5">
          <DataTablePagination table={table} />
          {actionBar && table.getFilteredSelectedRowModel().rows.length > 0 && actionBar}
        </div>
      </div>
    </DataTableLabelsContext.Provider>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-toolbar.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { DataTableDateFilter } from './data-table-date-filter';
import { DataTableFacetedFilter } from './data-table-faceted-filter';
import type { DataTableFeatures } from './data-table-features';
import { DataTableSliderFilter } from './data-table-slider-filter';
import { DataTableViewOptions } from './data-table-view-options';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { cn } from '@/lib/utils';
import type { Column, ReactTable, RowData } from '@tanstack/react-table';
import { X } from 'lucide-react';
import type * as React from 'react';
import { useDataTableLabels } from './data-table-labels';

interface DataTableToolbarProps<TData extends RowData> extends React.ComponentProps<'div'> {
  table: ReactTable<DataTableFeatures, TData>;
}

export const DataTableToolbar = <TData extends RowData>({
  table,
  children,
  className,
  ...props
}: DataTableToolbarProps<TData>) => {
  const labels = useDataTableLabels();
  const isFiltered = table.state.columnFilters.length > 0;

  const columns = table.getAllColumns().filter((column) => column.getCanFilter());

  const onReset = () => {
    table.resetColumnFilters();
  };

  return (
    <div
      role="toolbar"
      aria-orientation="horizontal"
      data-slot="data-table-toolbar"
      className={cn('flex w-full items-start justify-between gap-2 p-1', className)}
      {...props}
    >
      <div className="flex flex-1 flex-wrap items-center gap-2">
        {columns.map((column) => (
          <DataTableToolbarFilter key={column.id} column={column} />
        ))}
        {isFiltered && (
          <Button
            aria-label={labels.resetFiltersLabel}
            variant="outline"
            size="sm"
            className="border-dashed"
            onClick={onReset}
          >
            <X />
            {labels.resetFilters}
          </Button>
        )}
      </div>
      <div className="flex items-center gap-2">
        {children}
        <DataTableViewOptions table={table} align="end" />
      </div>
    </div>
  );
};

interface DataTableToolbarFilterProps<TData extends RowData> {
  column: Column<DataTableFeatures, TData>;
}

const DataTableToolbarFilter = <TData extends RowData>({ column }: DataTableToolbarFilterProps<TData>) => {
  const columnMeta = column.columnDef.meta;

  if (!columnMeta?.variant) return null;

  switch (columnMeta.variant) {
    case 'text':
      return (
        <Input
          aria-label={columnMeta.label ?? column.id}
          placeholder={columnMeta.placeholder ?? columnMeta.label}
          value={(column.getFilterValue() as string) ?? ''}
          onChange={(event) => column.setFilterValue(event.target.value)}
          className="h-8 w-40 lg:w-56"
        />
      );

    case 'number':
      return (
        <div className="relative">
          <Input
            type="number"
            inputMode="numeric"
            aria-label={columnMeta.label ?? column.id}
            placeholder={columnMeta.placeholder ?? columnMeta.label}
            value={(column.getFilterValue() as string) ?? ''}
            onChange={(event) => column.setFilterValue(event.target.value)}
            className={cn('h-8 w-[120px]', columnMeta.unit && 'pe-8')}
          />
          {columnMeta.unit && (
            <span className="absolute end-0 top-0 bottom-0 flex items-center rounded-e-md bg-accent px-2 text-sm text-muted-foreground">
              {columnMeta.unit}
            </span>
          )}
        </div>
      );

    case 'range':
      return <DataTableSliderFilter column={column} title={columnMeta.label ?? column.id} />;

    case 'date':
    case 'dateRange':
      return (
        <DataTableDateFilter
          column={column}
          title={columnMeta.label ?? column.id}
          multiple={columnMeta.variant === 'dateRange'}
        />
      );

    case 'select':
    case 'multiSelect':
      return (
        <DataTableFacetedFilter
          column={column}
          title={columnMeta.label ?? column.id}
          options={columnMeta.options ?? []}
          multiple={columnMeta.variant === 'multiSelect'}
        />
      );

    default:
      return null;
  }
};
registry/hirael/bases/radix/components/data-table/data-table-column-header.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import type { DataTableFeatures } from './data-table-features';
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/registry/hirael/bases/radix/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { CellData, Column, RowData } from '@tanstack/react-table';
import { ChevronDown, ChevronsUpDown, ChevronUp, EyeOff, X } from 'lucide-react';
import { useDataTableLabels } from './data-table-labels';

interface DataTableColumnHeaderProps<TData extends RowData, TValue extends CellData> extends React.ComponentProps<
  typeof DropdownMenuTrigger
> {
  column: Column<DataTableFeatures, TData, TValue>;
  label: string;
}

export const DataTableColumnHeader = <TData extends RowData, TValue extends CellData>({
  column,
  label,
  className,
  ...props
}: DataTableColumnHeaderProps<TData, TValue>) => {
  const labels = useDataTableLabels();
  if (!column.getCanSort() && !column.getCanHide()) {
    return (
      <div data-slot="data-table-column-header" className={cn(className)} {...(props as React.ComponentProps<'div'>)}>
        {label}
      </div>
    );
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        data-slot="data-table-column-header"
        className={cn(
          '-ms-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:ring-1 focus:ring-ring focus:outline-none data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',
          className,
        )}
        {...props}
      >
        {label}
        {column.getCanSort() &&
          (column.getIsSorted() === 'desc' ? (
            <ChevronDown />
          ) : column.getIsSorted() === 'asc' ? (
            <ChevronUp />
          ) : (
            <ChevronsUpDown />
          ))}
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-28">
        {column.getCanSort() && (
          <>
            <DropdownMenuCheckboxItem
              className="relative ps-2 pe-8 [&_svg]:text-muted-foreground [&>span:first-child]:start-auto [&>span:first-child]:end-2"
              checked={column.getIsSorted() === 'asc'}
              onClick={() => column.toggleSorting(false)}
            >
              <ChevronUp />
              {labels.sortAscending}
            </DropdownMenuCheckboxItem>
            <DropdownMenuCheckboxItem
              className="relative ps-2 pe-8 [&_svg]:text-muted-foreground [&>span:first-child]:start-auto [&>span:first-child]:end-2"
              checked={column.getIsSorted() === 'desc'}
              onClick={() => column.toggleSorting(true)}
            >
              <ChevronDown />
              {labels.sortDescending}
            </DropdownMenuCheckboxItem>
            {column.getIsSorted() && (
              <DropdownMenuItem className="ps-2 [&_svg]:text-muted-foreground" onClick={() => column.clearSorting()}>
                <X />
                {labels.resetSorting}
              </DropdownMenuItem>
            )}
          </>
        )}
        {column.getCanHide() && (
          <DropdownMenuCheckboxItem
            className="relative ps-2 pe-8 [&_svg]:text-muted-foreground [&>span:first-child]:start-auto [&>span:first-child]:end-2"
            checked={!column.getIsVisible()}
            onClick={() => column.toggleVisibility(false)}
          >
            <EyeOff />
            {labels.hideColumn}
          </DropdownMenuCheckboxItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-pagination.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { Button } from '@/registry/hirael/bases/radix/ui/button';
import type { DataTableFeatures } from './data-table-features';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/hirael/bases/radix/ui/select';
import { cn } from '@/lib/utils';
import type { ReactTable, RowData } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { useDataTableLabels } from './data-table-labels';

interface DataTablePaginationProps<TData extends RowData> extends React.ComponentProps<'div'> {
  table: ReactTable<DataTableFeatures, TData>;
  pageSizeOptions?: number[];
}

export const DataTablePagination = <TData extends RowData>({
  table,
  pageSizeOptions = [10, 20, 30, 40, 50],
  className,
  ...props
}: DataTablePaginationProps<TData>) => {
  const labels = useDataTableLabels();
  const pageSize = table.state.pagination.pageSize;
  const sizes = pageSizeOptions.includes(pageSize)
    ? pageSizeOptions
    : [...pageSizeOptions, pageSize].sort((a, b) => a - b);

  return (
    <div
      data-slot="data-table-pagination"
      className={cn(
        'flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8',
        className,
      )}
      {...props}
    >
      <div className="flex-1 text-sm whitespace-nowrap text-muted-foreground">
        {labels.rowsSelected(table.getFilteredSelectedRowModel().rows.length, table.getFilteredRowModel().rows.length)}
      </div>
      <div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
        <div className="flex items-center gap-2">
          <p className="text-sm font-medium whitespace-nowrap">{labels.rowsPerPage}</p>
          <Select
            value={`${pageSize}`}
            onValueChange={(value) => {
              table.setPageSize(Number(value));
            }}
          >
            <SelectTrigger className="h-8 w-18 data-size:h-8">
              <SelectValue placeholder={pageSize} />
            </SelectTrigger>
            <SelectContent side="top">
              {sizes.map((size) => (
                <SelectItem key={size} value={`${size}`}>
                  {size}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="flex items-center justify-center text-sm font-medium">
          {labels.page(table.state.pagination.pageIndex + 1, table.getPageCount())}
        </div>
        <div className="flex items-center gap-2">
          <Button
            aria-label={labels.firstPage}
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(0)}
            disabled={!table.getCanPreviousPage()}
          >
            <ChevronsLeft className="rtl:rotate-180" />
          </Button>
          <Button
            aria-label={labels.previousPage}
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            <ChevronLeft className="rtl:rotate-180" />
          </Button>
          <Button
            aria-label={labels.nextPage}
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            <ChevronRight className="rtl:rotate-180" />
          </Button>
          <Button
            aria-label={labels.lastPage}
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
            disabled={!table.getCanNextPage()}
          >
            <ChevronsRight className="rtl:rotate-180" />
          </Button>
        </div>
      </div>
    </div>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-view-options.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/registry/hirael/bases/radix/ui/command';
import type { DataTableFeatures } from './data-table-features';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { cn } from '@/lib/utils';
import type { ReactTable, RowData } from '@tanstack/react-table';
import { Check, Settings2 } from 'lucide-react';
import * as React from 'react';
import { useDataTableLabels } from './data-table-labels';

interface DataTableViewOptionsProps<TData extends RowData> extends React.ComponentProps<typeof PopoverContent> {
  table: ReactTable<DataTableFeatures, TData>;
  disabled?: boolean;
}

export const DataTableViewOptions = <TData extends RowData>({
  table,
  disabled,
  ...props
}: DataTableViewOptionsProps<TData>) => {
  const labels = useDataTableLabels();
  const columns = React.useMemo(
    () => table.getAllColumns().filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()),
    [table],
  );

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button
          aria-label={labels.toggleColumns}
          role="combobox"
          variant="outline"
          size="sm"
          data-slot="data-table-view-options"
          className="ms-auto hidden h-8 font-normal lg:flex"
          disabled={disabled}
        >
          <Settings2 className="text-muted-foreground" />
          {labels.view}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-44 p-0" {...props}>
        <Command>
          <CommandInput placeholder={labels.searchColumns} />
          <CommandList>
            <CommandEmpty>{labels.noColumns}</CommandEmpty>
            <CommandGroup>
              {columns.map((column) => (
                <CommandItem key={column.id} onSelect={() => column.toggleVisibility(!column.getIsVisible())}>
                  <span className="truncate">{column.columnDef.meta?.label ?? column.id}</span>
                  <Check
                    className={cn('ms-auto size-4 shrink-0', column.getIsVisible() ? 'opacity-100' : 'opacity-0')}
                  />
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-faceted-filter.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from '@/registry/hirael/bases/radix/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import type { DataTableFeatures } from './data-table-features';
import type { Option } from './data-table-utils';
import { cn } from '@/lib/utils';
import type { CellData, Column, RowData } from '@tanstack/react-table';
import { Check, PlusCircle, XCircle } from 'lucide-react';
import * as React from 'react';
import { useDataTableLabels } from './data-table-labels';

interface DataTableFacetedFilterProps<TData extends RowData, TValue extends CellData> {
  column?: Column<DataTableFeatures, TData, TValue>;
  title?: string;
  options: Option[];
  multiple?: boolean;
}

export const DataTableFacetedFilter = <TData extends RowData, TValue extends CellData>({
  column,
  title,
  options,
  multiple,
}: DataTableFacetedFilterProps<TData, TValue>) => {
  const labels = useDataTableLabels();
  const [open, setOpen] = React.useState(false);

  const columnFilterValue = column?.getFilterValue();
  const selectedValues = React.useMemo(
    () => new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []),
    [columnFilterValue],
  );

  const onItemSelect = React.useCallback(
    (option: Option, isSelected: boolean) => {
      if (!column) return;

      if (multiple) {
        const newSelectedValues = new Set(selectedValues);
        if (isSelected) {
          newSelectedValues.delete(option.value);
        } else {
          newSelectedValues.add(option.value);
        }
        const filterValues = Array.from(newSelectedValues);
        column.setFilterValue(filterValues.length ? filterValues : undefined);
      } else {
        column.setFilterValue(isSelected ? undefined : [option.value]);
        setOpen(false);
      }
    },
    [column, multiple, selectedValues],
  );

  const onReset = React.useCallback(() => {
    column?.setFilterValue(undefined);
  }, [column]);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <div className="flex items-center">
        {selectedValues?.size > 0 ? (
          <Button
            variant="outline"
            size="sm"
            aria-label={labels.clearFilter(title ?? '')}
            data-slot="data-table-faceted-filter-reset"
            className="rounded-e-none border-e-0 border-dashed px-2"
            onClick={onReset}
          >
            <XCircle />
          </Button>
        ) : null}
        <PopoverTrigger asChild>
          <Button
            variant="outline"
            size="sm"
            data-slot="data-table-faceted-filter"
            className={cn('border-dashed font-normal', selectedValues?.size > 0 && 'rounded-s-none')}
          >
            {selectedValues?.size > 0 ? null : <PlusCircle />}
            {title}
            {selectedValues?.size > 0 && (
              <>
                <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" />
                <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
                  {selectedValues.size}
                </Badge>
                <div className="hidden items-center gap-1 lg:flex">
                  {selectedValues.size > 2 ? (
                    <Badge variant="secondary" className="rounded-sm px-1 font-normal">
                      {labels.selectedCount(selectedValues.size)}
                    </Badge>
                  ) : (
                    options
                      .filter((option) => selectedValues.has(option.value))
                      .map((option) => (
                        <Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal">
                          {option.label}
                        </Badge>
                      ))
                  )}
                </div>
              </>
            )}
          </Button>
        </PopoverTrigger>
      </div>
      <PopoverContent className="w-50 p-0" align="start">
        <Command>
          <CommandInput placeholder={title} />
          <CommandList className="max-h-full">
            <CommandEmpty>{labels.noOptions}</CommandEmpty>
            <CommandGroup className="max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto">
              {options.map((option) => {
                const isSelected = selectedValues.has(option.value);

                return (
                  <CommandItem key={option.value} onSelect={() => onItemSelect(option, isSelected)}>
                    <div
                      className={cn(
                        'flex size-4 items-center justify-center rounded-sm border border-primary',
                        isSelected ? 'bg-primary' : 'opacity-50 [&_svg]:invisible',
                      )}
                    >
                      <Check />
                    </div>
                    {option.icon && <option.icon />}
                    <span className="truncate">{option.label}</span>
                    {option.count != null && option.count > 0 && (
                      <span className="ms-auto text-xs tabular-nums">{option.count}</span>
                    )}
                  </CommandItem>
                );
              })}
            </CommandGroup>
            {selectedValues.size > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem onSelect={() => onReset()} className="justify-center text-center">
                    {labels.clearFilters}
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-slider-filter.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { Button } from '@/registry/hirael/bases/radix/ui/button';
import type { DataTableFeatures } from './data-table-features';
import { Field, FieldLabel, FieldLegend, FieldSet } from '@/registry/hirael/bases/radix/ui/field';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { Slider } from '@/registry/hirael/bases/radix/ui/slider';
import { cn } from '@/lib/utils';
import type { Column, RowData } from '@tanstack/react-table';
import { PlusCircle, XCircle } from 'lucide-react';
import * as React from 'react';
import { useDataTableLabels } from './data-table-labels';

type RangeValue = [number, number];

const getIsValidRange = (value: unknown): value is RangeValue => {
  return Array.isArray(value) && value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number';
};

const parseValuesAsNumbers = (value: unknown): RangeValue | undefined => {
  if (!Array.isArray(value) || value.length !== 2) return undefined;

  const parsed = value.map((v) => {
    if (typeof v === 'number') return v;
    if (typeof v === 'string' && v.trim() !== '') return Number(v);

    return Number.NaN;
  });

  if (parsed.every((n) => Number.isFinite(n))) {
    return [parsed[0], parsed[1]];
  }

  return undefined;
};

interface DataTableSliderFilterProps<TData extends RowData> {
  column: Column<DataTableFeatures, TData>;
  title?: string;
}

export const DataTableSliderFilter = <TData extends RowData>({ column, title }: DataTableSliderFilterProps<TData>) => {
  const labels = useDataTableLabels();
  const id = React.useId();

  const columnFilterValue = parseValuesAsNumbers(column.getFilterValue());

  const defaultRange = column.columnDef.meta?.range;
  const unit = column.columnDef.meta?.unit;

  // Read outside the memo so a data change (new faceted tuple) recomputes the bounds.
  const facetedMinMax = defaultRange && getIsValidRange(defaultRange) ? undefined : column.getFacetedMinMaxValues();

  const { min, max, step } = React.useMemo<{ min: number; max: number; step: number }>(() => {
    let minValue = 0;
    let maxValue = 100;

    if (defaultRange && getIsValidRange(defaultRange)) {
      [minValue, maxValue] = defaultRange;
    } else {
      if (facetedMinMax && Array.isArray(facetedMinMax) && facetedMinMax.length === 2) {
        const [facetMinValue, facetMaxValue] = facetedMinMax;
        if (typeof facetMinValue === 'number' && typeof facetMaxValue === 'number') {
          minValue = facetMinValue;
          maxValue = facetMaxValue;
        }
      }
    }

    const rangeSize = maxValue - minValue;
    const step = rangeSize <= 20 ? 1 : rangeSize <= 100 ? Math.ceil(rangeSize / 20) : Math.ceil(rangeSize / 50);
    // Snap max up to the step grid, otherwise the slider can never reach the real max.
    const snappedMax = minValue + Math.ceil(rangeSize / step) * step;

    return { min: minValue, max: snappedMax, step };
  }, [defaultRange, facetedMinMax]);

  const range = React.useMemo((): RangeValue => {
    return columnFilterValue ?? [min, max];
  }, [columnFilterValue, min, max]);

  const [fromDraft, setFromDraft] = React.useState<string | null>(null);
  const [toDraft, setToDraft] = React.useState<string | null>(null);

  const formatValue = React.useCallback((value: number) => {
    return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
  }, []);

  const commitFrom = React.useCallback(() => {
    if (fromDraft === null) return;
    const numValue = Number(fromDraft);
    if (fromDraft.trim() !== '' && Number.isFinite(numValue)) {
      const clamped = Math.min(Math.max(numValue, min), range[1]);
      column.setFilterValue([clamped, range[1]]);
    }
    setFromDraft(null);
  }, [fromDraft, column, min, range]);

  const commitTo = React.useCallback(() => {
    if (toDraft === null) return;
    const numValue = Number(toDraft);
    if (toDraft.trim() !== '' && Number.isFinite(numValue)) {
      const clamped = Math.min(Math.max(numValue, range[0]), max);
      column.setFilterValue([range[0], clamped]);
    }
    setToDraft(null);
  }, [toDraft, column, max, range]);

  const onSliderValueChange = React.useCallback(
    (value: number[]) => {
      if (Array.isArray(value) && value.length === 2) {
        setFromDraft(null);
        setToDraft(null);
        column.setFilterValue(value);
      }
    },
    [column],
  );

  const onReset = React.useCallback(() => {
    setFromDraft(null);
    setToDraft(null);
    column.setFilterValue(undefined);
  }, [column]);

  return (
    <Popover
      onOpenChange={(open) => {
        if (!open) {
          setFromDraft(null);
          setToDraft(null);
        }
      }}
    >
      <div className="flex items-center">
        {columnFilterValue ? (
          <Button
            variant="outline"
            size="sm"
            aria-label={labels.clearFilter(title ?? '')}
            data-slot="data-table-slider-filter-reset"
            className="rounded-e-none border-e-0 border-dashed px-2"
            onClick={onReset}
          >
            <XCircle />
          </Button>
        ) : null}
        <PopoverTrigger asChild>
          <Button
            variant="outline"
            size="sm"
            data-slot="data-table-slider-filter"
            className={cn('border-dashed font-normal', columnFilterValue && 'rounded-s-none')}
          >
            {columnFilterValue ? null : <PlusCircle />}
            <span>{title}</span>
            {columnFilterValue ? (
              <>
                <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" />
                {formatValue(columnFilterValue[0])} - {formatValue(columnFilterValue[1])}
                {unit ? ` ${unit}` : ''}
              </>
            ) : null}
          </Button>
        </PopoverTrigger>
      </div>
      <PopoverContent align="start" className="flex w-auto flex-col gap-4">
        <FieldSet className="gap-3">
          <FieldLegend variant="label" className="leading-none">
            {title}
          </FieldLegend>
          <div className="flex items-center gap-4">
            <Field className="w-auto">
              <FieldLabel htmlFor={`${id}-from`} className="sr-only">
                {labels.rangeFrom}
              </FieldLabel>
              <div className="relative">
                <Input
                  id={`${id}-from`}
                  type="number"
                  aria-valuemin={min}
                  aria-valuemax={max}
                  inputMode="numeric"
                  pattern="[0-9]*"
                  placeholder={min.toString()}
                  min={min}
                  max={max}
                  value={fromDraft ?? range[0].toString()}
                  onChange={(event) => setFromDraft(event.target.value)}
                  onBlur={commitFrom}
                  onKeyDown={(event) => {
                    if (event.key === 'Enter') {
                      event.preventDefault();
                      commitFrom();
                    }
                  }}
                  className={cn('h-8 w-24', unit && 'pe-8')}
                />
                {unit && (
                  <span className="absolute end-0 top-0 bottom-0 flex items-center rounded-e-md bg-accent px-2 text-sm text-muted-foreground">
                    {unit}
                  </span>
                )}
              </div>
            </Field>
            <Field className="w-auto">
              <FieldLabel htmlFor={`${id}-to`} className="sr-only">
                {labels.rangeTo}
              </FieldLabel>
              <div className="relative">
                <Input
                  id={`${id}-to`}
                  type="number"
                  aria-valuemin={min}
                  aria-valuemax={max}
                  inputMode="numeric"
                  pattern="[0-9]*"
                  placeholder={max.toString()}
                  min={min}
                  max={max}
                  value={toDraft ?? range[1].toString()}
                  onChange={(event) => setToDraft(event.target.value)}
                  onBlur={commitTo}
                  onKeyDown={(event) => {
                    if (event.key === 'Enter') {
                      event.preventDefault();
                      commitTo();
                    }
                  }}
                  className={cn('h-8 w-24', unit && 'pe-8')}
                />
                {unit && (
                  <span className="absolute end-0 top-0 bottom-0 flex items-center rounded-e-md bg-accent px-2 text-sm text-muted-foreground">
                    {unit}
                  </span>
                )}
              </div>
            </Field>
          </div>
          <Field>
            <FieldLabel htmlFor={`${id}-slider`} className="sr-only">
              {labels.rangeSlider(title ?? '')}
            </FieldLabel>
            <Slider
              id={`${id}-slider`}
              min={min}
              max={max}
              step={step}
              value={range}
              onValueChange={onSliderValueChange}
            />
          </Field>
        </FieldSet>
        <Button aria-label={labels.clearFilter(title ?? '')} variant="outline" size="sm" onClick={onReset}>
          {labels.clearRange}
        </Button>
      </PopoverContent>
    </Popover>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-date-filter.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Calendar } from '@/registry/hirael/bases/radix/ui/calendar';
import type { DataTableFeatures } from './data-table-features';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
import { formatDate } from './data-table-utils';
import { cn } from '@/lib/utils';
import type { Column, RowData } from '@tanstack/react-table';
import { CalendarIcon, XCircle } from 'lucide-react';
import * as React from 'react';
import type { DateRange } from 'react-day-picker';
import { useDataTableLabels } from './data-table-labels';

type DateSelection = Date[] | DateRange;

const getIsDateRange = (value: DateSelection): value is DateRange => {
  return value && typeof value === 'object' && !Array.isArray(value);
};

const parseAsDate = (timestamp: number | string | undefined): Date | undefined => {
  if (!timestamp) return undefined;
  const numericTimestamp = typeof timestamp === 'string' ? Number(timestamp) : timestamp;
  const date = new Date(numericTimestamp);

  return !Number.isNaN(date.getTime()) ? date : undefined;
};

const parseColumnFilterValue = (value: unknown) => {
  if (value === null || value === undefined) {
    return [];
  }

  if (Array.isArray(value)) {
    return value.map((item) => {
      if (typeof item === 'number' || typeof item === 'string') {
        return item;
      }

      return undefined;
    });
  }

  if (typeof value === 'string' || typeof value === 'number') {
    return [value];
  }

  return [];
};

interface DataTableDateFilterProps<TData extends RowData> {
  column: Column<DataTableFeatures, TData>;
  title?: string;
  multiple?: boolean;
  /** Locale for the trigger label. Defaults to `en-US`. */
  locale?: string;
}

export const DataTableDateFilter = <TData extends RowData>({
  column,
  title,
  multiple,
  locale = 'en-US',
}: DataTableDateFilterProps<TData>) => {
  const labels = useDataTableLabels();
  const columnFilterValue = column.getFilterValue();

  const selectedDates = React.useMemo<DateSelection>(() => {
    if (!columnFilterValue) {
      return multiple ? { from: undefined, to: undefined } : [];
    }

    if (multiple) {
      const timestamps = parseColumnFilterValue(columnFilterValue);

      return {
        from: parseAsDate(timestamps[0]),
        to: parseAsDate(timestamps[1]),
      };
    }

    const timestamps = parseColumnFilterValue(columnFilterValue);
    const date = parseAsDate(timestamps[0]);

    return date ? [date] : [];
  }, [columnFilterValue, multiple]);

  const onSelect = React.useCallback(
    (date: Date | DateRange | undefined) => {
      if (!date) {
        column.setFilterValue(undefined);

        return;
      }

      if (multiple && !('getTime' in date)) {
        const from = date.from?.getTime();
        const to = date.to?.getTime();
        // Open-ended ranges keep an empty slot so the URL never carries `undefined`.
        column.setFilterValue(from || to ? [from ?? '', to ?? ''] : undefined);
      } else if (!multiple && 'getTime' in date) {
        column.setFilterValue(date.getTime());
      }
    },
    [column, multiple],
  );

  const onReset = React.useCallback(() => {
    column.setFilterValue(undefined);
  }, [column]);

  const hasValue = React.useMemo(() => {
    if (multiple) {
      if (!getIsDateRange(selectedDates)) return false;

      return selectedDates.from || selectedDates.to;
    }
    if (!Array.isArray(selectedDates)) return false;

    return selectedDates.length > 0;
  }, [multiple, selectedDates]);

  const formatDateRange = React.useCallback(
    (range: DateRange) => {
      if (!range.from && !range.to) return '';
      if (range.from && range.to) {
        return `${formatDate(range.from, {}, locale)} - ${formatDate(range.to, {}, locale)}`;
      }

      return formatDate(range.from ?? range.to, {}, locale);
    },
    [locale],
  );

  const label = React.useMemo(() => {
    if (multiple) {
      if (!getIsDateRange(selectedDates)) return null;

      const hasSelectedDates = selectedDates.from || selectedDates.to;
      const dateText = hasSelectedDates ? formatDateRange(selectedDates) : labels.selectDateRange;

      return (
        <span className="flex items-center gap-2">
          <span>{title}</span>
          {hasSelectedDates && (
            <>
              <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" />
              <span>{dateText}</span>
            </>
          )}
        </span>
      );
    }

    if (getIsDateRange(selectedDates)) return null;

    const hasSelectedDate = selectedDates.length > 0;
    const dateText = hasSelectedDate ? formatDate(selectedDates[0], {}, locale) : labels.selectDate;

    return (
      <span className="flex items-center gap-2">
        <span>{title}</span>
        {hasSelectedDate && (
          <>
            <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" />
            <span>{dateText}</span>
          </>
        )}
      </span>
    );
  }, [selectedDates, multiple, formatDateRange, title, locale, labels]);

  return (
    <Popover>
      <div className="flex items-center">
        {hasValue ? (
          <Button
            variant="outline"
            size="sm"
            aria-label={labels.clearFilter(title ?? '')}
            data-slot="data-table-date-filter-reset"
            className="rounded-e-none border-e-0 border-dashed px-2"
            onClick={onReset}
          >
            <XCircle />
          </Button>
        ) : null}
        <PopoverTrigger asChild>
          <Button
            variant="outline"
            size="sm"
            data-slot="data-table-date-filter"
            className={cn('border-dashed font-normal', hasValue && 'rounded-s-none')}
          >
            {hasValue ? null : <CalendarIcon />}
            {label}
          </Button>
        </PopoverTrigger>
      </div>
      <PopoverContent className="w-auto p-0" align="start">
        {multiple ? (
          <Calendar
            autoFocus
            captionLayout="dropdown"
            mode="range"
            selected={getIsDateRange(selectedDates) ? selectedDates : { from: undefined, to: undefined }}
            onSelect={onSelect}
          />
        ) : (
          <Calendar
            captionLayout="dropdown"
            mode="single"
            selected={!getIsDateRange(selectedDates) ? selectedDates[0] : undefined}
            onSelect={onSelect}
          />
        )}
      </PopoverContent>
    </Popover>
  );
};
registry/hirael/bases/radix/components/data-table/data-table-skeleton.tsx
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

import { Skeleton } from '@/registry/hirael/bases/radix/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/registry/hirael/bases/radix/ui/table';
import { cn } from '@/lib/utils';

interface DataTableSkeletonProps extends React.ComponentProps<'div'> {
  columnCount: number;
  rowCount?: number;
  filterCount?: number;
  cellWidths?: string[];
  withViewOptions?: boolean;
  withPagination?: boolean;
  shrinkZero?: boolean;
}

export const DataTableSkeleton = ({
  columnCount,
  rowCount = 10,
  filterCount = 0,
  cellWidths = ['auto'],
  withViewOptions = true,
  withPagination = true,
  shrinkZero = false,
  className,
  ...props
}: DataTableSkeletonProps) => {
  const cozyCellWidths = Array.from(
    { length: columnCount },
    (_, index) => cellWidths[index % cellWidths.length] ?? 'auto',
  );

  return (
    <div
      data-slot="data-table-skeleton"
      className={cn('flex w-full flex-col gap-2.5 overflow-auto', className)}
      {...props}
    >
      <div className="flex w-full items-center justify-between gap-2 overflow-auto p-1">
        <div className="flex flex-1 items-center gap-2">
          {filterCount > 0
            ? Array.from({ length: filterCount }).map((_, i) => <Skeleton key={i} className="h-7 w-18 border-dashed" />)
            : null}
        </div>
        {withViewOptions ? <Skeleton className="ms-auto hidden h-7 w-18 lg:flex" /> : null}
      </div>
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            <TableRow className="hover:bg-transparent">
              {Array.from({ length: columnCount }).map((_, j) => (
                <TableHead
                  key={j}
                  style={{
                    width: cozyCellWidths[j],
                    minWidth: shrinkZero ? cozyCellWidths[j] : 'auto',
                  }}
                >
                  <Skeleton className="h-6 w-full" />
                </TableHead>
              ))}
            </TableRow>
          </TableHeader>
          <TableBody>
            {Array.from({ length: rowCount }).map((_, i) => (
              <TableRow key={i} className="hover:bg-transparent">
                {Array.from({ length: columnCount }).map((_, j) => (
                  <TableCell
                    key={j}
                    style={{
                      width: cozyCellWidths[j],
                      minWidth: shrinkZero ? cozyCellWidths[j] : 'auto',
                    }}
                  >
                    <Skeleton className="h-6 w-full" />
                  </TableCell>
                ))}
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
      {withPagination ? (
        <div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8">
          <Skeleton className="h-7 w-40 shrink-0" />
          <div className="flex items-center gap-4 sm:gap-6 lg:gap-8">
            <div className="flex items-center gap-2">
              <Skeleton className="h-7 w-24" />
              <Skeleton className="h-7 w-18" />
            </div>
            <div className="flex items-center justify-center text-sm font-medium">
              <Skeleton className="h-7 w-20" />
            </div>
            <div className="flex items-center gap-2">
              <Skeleton className="hidden size-7 lg:block" />
              <Skeleton className="size-7" />
              <Skeleton className="size-7" />
              <Skeleton className="hidden size-7 lg:block" />
            </div>
          </div>
        </div>
      ) : null}
    </div>
  );
};
registry/hirael/bases/radix/components/data-table/use-data-table.ts
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use no memo';
'use client';

import { type DataTableFeatures, dataTableFeatures } from './data-table-features';
import { getSortingStateParser } from './data-table-parsers';
import type { ExtendedColumnSort, FilterVariant } from './data-table-utils';
import type { ColumnDef, FilterFn, RowData } from '@tanstack/react-table';
import {
  type ColumnFiltersState,
  type ColumnVisibilityState,
  type PaginationState,
  type RowSelectionState,
  type SortingState,
  type TableOptions,
  type TableState,
  type Updater,
  useTable,
} from '@tanstack/react-table';
import {
  parseAsArrayOf,
  parseAsString,
  parseAsInteger,
  type SingleParser,
  type UseQueryStateOptions,
  useQueryState,
  useQueryStates,
} from 'nuqs';
import * as React from 'react';

const useCallbackRef = <T extends (...args: never[]) => unknown>(callback: T | undefined): T => {
  const callbackRef = React.useRef(callback);
  React.useEffect(() => {
    callbackRef.current = callback;
  });

  return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
};

const useDebouncedCallback = <T extends (...args: never[]) => unknown>(callback: T, delay: number) => {
  const handleCallback = useCallbackRef(callback);
  const debounceTimerRef = React.useRef(0);
  React.useEffect(() => () => window.clearTimeout(debounceTimerRef.current), []);

  return React.useCallback(
    (...args: Parameters<T>) => {
      window.clearTimeout(debounceTimerRef.current);
      debounceTimerRef.current = window.setTimeout(() => handleCallback(...args), delay);
    },
    [handleCallback, delay],
  );
};

const toTime = (value: unknown): number | null => {
  if (value instanceof Date) return value.getTime();
  if (typeof value === 'number') return value;
  if (typeof value === 'string') {
    const time = new Date(value).getTime();

    return Number.isNaN(time) ? null : time;
  }

  return null;
};

const startOfDay = (timestamp: number): number => {
  const date = new Date(timestamp);
  date.setHours(0, 0, 0, 0);

  return date.getTime();
};

const endOfDay = (timestamp: number): number => {
  const date = new Date(timestamp);
  date.setHours(23, 59, 59, 999);

  return date.getTime();
};

const ARRAY_VARIANTS = new Set<FilterVariant>(['select', 'multiSelect', 'range', 'dateRange']);

const isArrayVariant = (variant: FilterVariant | undefined): boolean => {
  return variant ? ARRAY_VARIANTS.has(variant) : false;
};

const getFilterFn = <TData extends RowData>(variant: FilterVariant | undefined): FilterFn<DataTableFeatures, TData> => {
  return (row, columnId, filterValue) => {
    if (filterValue == null || filterValue === '') return true;
    const value = row.getValue(columnId);

    switch (variant) {
      case 'select':
      case 'multiSelect': {
        const selected = (Array.isArray(filterValue) ? filterValue : [filterValue]).map(String);
        if (selected.length === 0) return true;

        return selected.includes(String(value));
      }
      case 'range': {
        if (!Array.isArray(filterValue)) return true;
        const [min, max] = filterValue;
        const num = Number(value);
        if (Number.isNaN(num)) return false;
        if (min != null && min !== '' && num < Number(min)) return false;
        if (max != null && max !== '' && num > Number(max)) return false;

        return true;
      }
      case 'number': {
        const target = Number(filterValue);
        if (Number.isNaN(target)) return true;

        return Number(value) === target;
      }
      case 'date':
      case 'dateRange': {
        const rowTime = toTime(value);
        if (rowTime == null) return false;
        if (Array.isArray(filterValue)) {
          const [from, to] = filterValue;
          if (from != null && from !== '' && rowTime < startOfDay(Number(from))) return false;
          if (to != null && to !== '' && rowTime > endOfDay(Number(to))) return false;

          return true;
        }
        const target = Number(filterValue);
        if (Number.isNaN(target)) return true;

        return startOfDay(rowTime) === startOfDay(target);
      }
      default: {
        const needle = String(Array.isArray(filterValue) ? (filterValue[0] ?? '') : filterValue).toLowerCase();
        if (!needle) return true;

        return String(value ?? '')
          .toLowerCase()
          .includes(needle);
      }
    }
  };
};

// Mirrors TanStack's constructColumn: dotted accessor keys become `a_b` ids.
const getColumnId = <TData extends RowData>(column: ColumnDef<DataTableFeatures, TData>): string => {
  if (column.id !== undefined) return column.id;
  if ('accessorKey' in column && column.accessorKey !== undefined) {
    return String(column.accessorKey).split('.').join('_');
  }

  return typeof column.header === 'string' ? column.header : '';
};

const serializeFilterValue = (value: unknown): string => {
  if (value == null) return '';

  return Array.isArray(value) ? value.map(String).join(ARRAY_SEPARATOR) : String(value);
};

const toQueryValue = (value: unknown): string | string[] | null => {
  if (value == null || value === '') return null;
  if (Array.isArray(value)) {
    const items = value.map((item) => (item == null ? '' : String(item)));

    return items.every((item) => item === '') ? null : items;
  }

  return String(value);
};

const areFiltersEqual = (a: ColumnFiltersState, b: ColumnFiltersState): boolean => {
  if (a.length !== b.length) return false;
  const values = new Map(a.map((f) => [f.id, serializeFilterValue(f.value)]));

  return b.every((f) => values.has(f.id) && values.get(f.id) === serializeFilterValue(f.value));
};

const PAGE_KEY = 'page';
const PER_PAGE_KEY = 'perPage';
const SORT_KEY = 'sort';
const ARRAY_SEPARATOR = ',';
const DEBOUNCE_MS = 300;
const THROTTLE_MS = 50;

interface UseDataTableProps<TData extends RowData> extends Omit<
  TableOptions<DataTableFeatures, TData>,
  'features' | 'state' | 'pageCount' | 'manualFiltering' | 'manualPagination' | 'manualSorting'
> {
  /**
   * Total page count from the server. Pass it to run the table in manual mode
   * (server does pagination / sorting / filtering, `data` is the current page).
   * Omit it to run in memory: pass the full `data` and the table paginates,
   * sorts and filters it from the URL state.
   */
  pageCount?: number;
  /** Override the server/client mode. Defaults to `pageCount != null`. */
  manual?: boolean;
  initialState?: Omit<Partial<TableState<DataTableFeatures>>, 'sorting'> & {
    sorting?: ExtendedColumnSort<TData>[];
  };
  /** Controlled row selection. Pair with `onRowSelectionChange`. */
  rowSelection?: RowSelectionState;
  /** Controlled column visibility. Pair with `onColumnVisibilityChange`. */
  columnVisibility?: ColumnVisibilityState;
  prefix?: string;
  history?: 'push' | 'replace';
  debounceMs?: number;
  throttleMs?: number;
  clearOnDefault?: boolean;
  scroll?: boolean;
  shallow?: boolean;
  startTransition?: React.TransitionStartFunction;
}

export const useDataTable = <TData extends RowData>(props: UseDataTableProps<TData>) => {
  const {
    columns,
    pageCount,
    manual = pageCount != null,
    initialState,
    rowSelection: rowSelectionProp,
    columnVisibility: columnVisibilityProp,
    onRowSelectionChange: onRowSelectionChangeProp,
    onColumnVisibilityChange: onColumnVisibilityChangeProp,
    prefix = '',
    history = 'replace',
    debounceMs = DEBOUNCE_MS,
    throttleMs = THROTTLE_MS,
    clearOnDefault = false,
    scroll = false,
    shallow = true,
    startTransition,
    ...tableProps
  } = props;

  const pageKey = `${prefix}${PAGE_KEY}`;
  const perPageKey = `${prefix}${PER_PAGE_KEY}`;
  const sortKey = `${prefix}${SORT_KEY}`;

  const queryStateOptions = React.useMemo<Omit<UseQueryStateOptions<string>, 'parse'>>(
    () => ({
      history,
      scroll,
      shallow,
      throttleMs,
      debounceMs,
      clearOnDefault,
      startTransition,
    }),
    [history, scroll, shallow, throttleMs, debounceMs, clearOnDefault, startTransition],
  );

  const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>(
    initialState?.rowSelection ?? {},
  );
  const rowSelection = rowSelectionProp ?? internalRowSelection;

  const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<ColumnVisibilityState>(
    initialState?.columnVisibility ?? {},
  );
  const columnVisibility = columnVisibilityProp ?? internalColumnVisibility;

  const onRowSelectionChange = React.useCallback(
    (updaterOrValue: Updater<RowSelectionState>) => {
      if (rowSelectionProp === undefined) setInternalRowSelection(updaterOrValue);
      onRowSelectionChangeProp?.(updaterOrValue);
    },
    [rowSelectionProp, onRowSelectionChangeProp],
  );

  const onColumnVisibilityChange = React.useCallback(
    (updaterOrValue: Updater<ColumnVisibilityState>) => {
      if (columnVisibilityProp === undefined) setInternalColumnVisibility(updaterOrValue);
      onColumnVisibilityChangeProp?.(updaterOrValue);
    },
    [columnVisibilityProp, onColumnVisibilityChangeProp],
  );

  const [page, setPage] = useQueryState(pageKey, parseAsInteger.withOptions(queryStateOptions).withDefault(1));
  const [perPage, setPerPage] = useQueryState(
    perPageKey,
    parseAsInteger.withOptions(queryStateOptions).withDefault(initialState?.pagination?.pageSize ?? 10),
  );

  const pagination: PaginationState = React.useMemo(() => {
    return { pageIndex: page - 1, pageSize: perPage };
  }, [page, perPage]);

  const onPaginationChange = React.useCallback(
    (updaterOrValue: Updater<PaginationState>) => {
      if (typeof updaterOrValue === 'function') {
        const newPagination = updaterOrValue(pagination);
        void setPage(newPagination.pageIndex + 1);
        void setPerPage(newPagination.pageSize);
      } else {
        void setPage(updaterOrValue.pageIndex + 1);
        void setPerPage(updaterOrValue.pageSize);
      }
    },
    [pagination, setPage, setPerPage],
  );

  const columnIds = React.useMemo(() => {
    return new Set(columns.map((column) => getColumnId(column)).filter(Boolean));
  }, [columns]);

  const [sorting, setSorting] = useQueryState(
    sortKey,
    getSortingStateParser<TData>(columnIds)
      .withOptions(queryStateOptions)
      .withDefault(initialState?.sorting ?? []),
  );

  const onSortingChange = React.useCallback(
    (updaterOrValue: Updater<SortingState>) => {
      if (typeof updaterOrValue === 'function') {
        const newSorting = updaterOrValue(sorting);
        setSorting(newSorting as ExtendedColumnSort<TData>[]);
      } else {
        setSorting(updaterOrValue as ExtendedColumnSort<TData>[]);
      }
    },
    [sorting, setSorting],
  );

  const filterableColumns = React.useMemo(() => {
    return columns.filter((column) => column.enableColumnFilter);
  }, [columns]);

  const tableColumns = React.useMemo<ColumnDef<DataTableFeatures, TData>[]>(() => {
    return columns.map((column) => {
      if (column.filterFn || !column.enableColumnFilter) return column;

      return { ...column, filterFn: getFilterFn<TData>(column.meta?.variant) };
    });
  }, [columns]);

  const filterParsers = React.useMemo(() => {
    return filterableColumns.reduce<Record<string, SingleParser<string> | SingleParser<string[]>>>((acc, column) => {
      const id = getColumnId(column);
      if (!id) return acc;
      const key = `${prefix}${id}`;
      acc[key] = isArrayVariant(column.meta?.variant)
        ? parseAsArrayOf(parseAsString, ARRAY_SEPARATOR).withOptions(queryStateOptions)
        : parseAsString.withOptions(queryStateOptions);

      return acc;
    }, {});
  }, [filterableColumns, queryStateOptions, prefix]);

  const [filterValues, setFilterValues] = useQueryStates(filterParsers);

  const debouncedSetFilterValues = useDebouncedCallback((values: typeof filterValues) => {
    void setPage(1);
    void setFilterValues(values);
  }, debounceMs);

  const queryColumnFilters: ColumnFiltersState = React.useMemo(() => {
    return Object.entries(filterValues).reduce<ColumnFiltersState>((filters, [key, value]) => {
      if (value !== null) {
        filters.push({
          id: prefix ? key.slice(prefix.length) : key,
          value,
        });
      }

      return filters;
    }, []);
  }, [filterValues, prefix]);

  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(queryColumnFilters);

  const columnFiltersRef = React.useRef(columnFilters);
  React.useEffect(() => {
    columnFiltersRef.current = columnFilters;
  }, [columnFilters]);

  const [lastQueryFilters, setLastQueryFilters] = React.useState(queryColumnFilters);
  if (queryColumnFilters !== lastQueryFilters) {
    setLastQueryFilters(queryColumnFilters);
    setColumnFilters((prev) => (areFiltersEqual(prev, queryColumnFilters) ? prev : queryColumnFilters));
  }

  const onColumnFiltersChange = React.useCallback(
    (updaterOrValue: Updater<ColumnFiltersState>) => {
      const prev = columnFiltersRef.current;
      const next = typeof updaterOrValue === 'function' ? updaterOrValue(prev) : updaterOrValue;

      const filterUpdates = next.reduce<Record<string, string | string[] | null>>((acc, filter) => {
        if (filterableColumns.find((column) => getColumnId(column) === filter.id)) {
          acc[`${prefix}${filter.id}`] = toQueryValue(filter.value);
        }

        return acc;
      }, {});

      for (const prevFilter of prev) {
        if (!next.some((filter) => filter.id === prevFilter.id)) {
          filterUpdates[`${prefix}${prevFilter.id}`] = null;
        }
      }

      columnFiltersRef.current = next;
      setColumnFilters(next);
      debouncedSetFilterValues(filterUpdates);
    },
    [debouncedSetFilterValues, filterableColumns, prefix],
  );

  const table = useTable({
    ...tableProps,
    features: dataTableFeatures,
    columns: tableColumns,
    initialState,
    pageCount: manual ? (pageCount ?? -1) : undefined,
    state: {
      pagination,
      sorting,
      columnVisibility,
      rowSelection,
      columnFilters,
    },
    defaultColumn: {
      ...tableProps.defaultColumn,
      enableColumnFilter: false,
    },
    enableRowSelection: tableProps.enableRowSelection ?? true,
    onRowSelectionChange,
    onPaginationChange,
    onSortingChange,
    onColumnFiltersChange,
    onColumnVisibilityChange,
    manualPagination: manual,
    manualSorting: manual,
    manualFiltering: manual,
  });

  return { table, shallow, debounceMs, throttleMs };
};
registry/hirael/bases/radix/components/data-table/data-table-features.ts
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

import type { DataTableColumnMeta } from './data-table-utils';
import {
  columnFacetingFeature,
  columnFilteringFeature,
  columnOrderingFeature,
  columnPinningFeature,
  columnSizingFeature,
  columnVisibilityFeature,
  createFacetedMinMaxValues,
  createFacetedRowModel,
  createFacetedUniqueValues,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFns,
  metaHelper,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFns,
  tableFeatures,
} from '@tanstack/react-table';

export const dataTableFeatures = tableFeatures({
  columnFacetingFeature,
  columnFilteringFeature,
  columnOrderingFeature,
  columnPinningFeature,
  columnSizingFeature,
  columnVisibilityFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  facetedMinMaxValues: createFacetedMinMaxValues(),
  facetedRowModel: createFacetedRowModel(),
  facetedUniqueValues: createFacetedUniqueValues(),
  filteredRowModel: createFilteredRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  sortedRowModel: createSortedRowModel(),
  filterFns,
  sortFns,
  columnMeta: metaHelper<DataTableColumnMeta>(),
});

export type DataTableFeatures = typeof dataTableFeatures;
registry/hirael/bases/radix/components/data-table/data-table-parsers.ts
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

import type { ExtendedColumnSort } from './data-table-utils';
import { createParser } from 'nuqs/server';
import { z } from 'zod';

const sortingItemSchema = z.object({
  id: z.string(),
  desc: z.boolean(),
});

export const getSortingStateParser = <TData>(columnIds?: string[] | Set<string>) => {
  const validKeys = columnIds ? (columnIds instanceof Set ? columnIds : new Set(columnIds)) : null;

  return createParser({
    parse: (value) => {
      try {
        const parsed = JSON.parse(value);
        const result = z.array(sortingItemSchema).safeParse(parsed);

        if (!result.success) return null;

        if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
          return null;
        }

        return result.data as ExtendedColumnSort<TData>[];
      } catch {
        return null;
      }
    },
    serialize: (value) => JSON.stringify(value),
    eq: (a, b) =>
      a.length === b.length && a.every((item, index) => item.id === b[index]?.id && item.desc === b[index]?.desc),
  });
};
registry/hirael/bases/radix/components/data-table/data-table-utils.ts
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

import type { DataTableFeatures } from './data-table-features';
import type { Column, Row, RowData } from '@tanstack/react-table';
import type * as React from 'react';

export type FilterVariant = 'text' | 'number' | 'range' | 'date' | 'dateRange' | 'select' | 'multiSelect';

export interface ExtendedColumnSort<TData> {
  /** A key of `TData`, or any custom column id (dotted accessor keys become `a_b`). */
  id: Extract<keyof TData, string> | (string & Record<never, never>);
  desc: boolean;
}

export interface Option {
  label: string;
  value: string;
  count?: number;
  icon?: React.FC<React.SVGProps<SVGSVGElement>>;
}

export interface DataTableRowAction<TData extends RowData> {
  row: Row<DataTableFeatures, TData>;
  variant: 'update' | 'delete';
}

export interface DataTableColumnMeta {
  label?: string;
  placeholder?: string;
  variant?: FilterVariant;
  options?: Option[];
  range?: [number, number];
  unit?: string;
  icon?: React.FC<React.SVGProps<SVGSVGElement>>;
}

export const getColumnPinningStyle = <TData extends RowData>({
  column,
  withBorder = false,
}: {
  column: Column<DataTableFeatures, TData>;
  withBorder?: boolean;
}): React.CSSProperties => {
  const isPinned = column.getIsPinned();
  const isLastStartPinnedColumn = isPinned === 'start' && column.getIsLastColumn('start');
  const isFirstEndPinnedColumn = isPinned === 'end' && column.getIsFirstColumn('end');

  return {
    boxShadow: withBorder
      ? isLastStartPinnedColumn
        ? '-4px 0 4px -4px var(--border) inset'
        : isFirstEndPinnedColumn
          ? '4px 0 4px -4px var(--border) inset'
          : undefined
      : undefined,
    insetInlineStart: isPinned === 'start' ? `${column.getStart('start')}px` : undefined,
    insetInlineEnd: isPinned === 'end' ? `${column.getAfter('end')}px` : undefined,
    opacity: isPinned ? 0.97 : 1,
    position: isPinned ? 'sticky' : 'relative',
    // Only pinned cells need an opaque fill; on others it would hide row hover/selected tints.
    background: isPinned ? 'var(--background)' : undefined,
    width: column.getSize(),
    zIndex: isPinned ? 1 : undefined,
  };
};

export const formatDate = (
  date: Date | string | number | undefined,
  opts: Intl.DateTimeFormatOptions = {},
  locale = 'en-US',
) => {
  if (!date) return '';

  try {
    return new Intl.DateTimeFormat(locale, {
      month: opts.month ?? 'long',
      day: opts.day ?? 'numeric',
      year: opts.year ?? 'numeric',
      ...opts,
    }).format(new Date(date));
  } catch {
    return '';
  }
};
registry/hirael/bases/radix/components/data-table/data-table-labels.ts
// Data Table from Hirael <https://hirael.com/components/data/data-table>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';

export const DEFAULT_DATA_TABLE_LABELS = {
  noResults: 'No results.',
  rowsSelected: (selected: number, total: number) => `${selected} of ${total} row(s) selected.`,
  rowsPerPage: 'Rows per page',
  page: (page: number, count: number) => `Page ${page} of ${count}`,
  firstPage: 'Go to first page',
  previousPage: 'Go to previous page',
  nextPage: 'Go to next page',
  lastPage: 'Go to last page',
  sortAscending: 'Asc',
  sortDescending: 'Desc',
  resetSorting: 'Reset',
  hideColumn: 'Hide',
  resetFilters: 'Reset',
  resetFiltersLabel: 'Reset filters',
  clearFilter: (title: string) => `Clear ${title} filter`,
  clearFilters: 'Clear filters',
  clearRange: 'Clear',
  selectedCount: (count: number) => `${count} selected`,
  noOptions: 'No results found.',
  selectDate: 'Select date',
  selectDateRange: 'Select date range',
  rangeFrom: 'From',
  rangeTo: 'to',
  rangeSlider: (title: string) => `${title} slider`,
  view: 'View',
  toggleColumns: 'Toggle columns',
  searchColumns: 'Search columns...',
  noColumns: 'No columns found.',
};

export type DataTableLabels = typeof DEFAULT_DATA_TABLE_LABELS;

export const DataTableLabelsContext = React.createContext<DataTableLabels>(DEFAULT_DATA_TABLE_LABELS);

/** The table's text, as set by `<DataTable labels>`; English outside a table. */
export const useDataTableLabels = () => React.useContext(DataTableLabelsContext);

More from hirael

Browse all components in this library →