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 · lighticonslucide-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);