uicapsule
blockfilter-barsource: ok · hit
Filter Bar
npx shadcn add "https://uicapsule.com/r/filter-bar.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
commandcmdk
iconslucide-react
Files22 · source cached 10/8/2026, 8:17:00 PM
/_components/active-filters.tsx
import { Button, Separator } from "./ui";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { X } from "lucide-react";
import type {
Column,
FilterBinding,
DataTableFilterActions,
FiltersState,
FilterStrategy,
} from "../filter-package";
import { getColumn, bindFilter } from "../filter-package";
import { FilterOperator } from "./filter-operator";
import { FilterSubject } from "./filter-subject";
import { FilterValue } from "./filter-value";
interface ActiveFiltersProps {
columns: Column[];
filters: FiltersState;
actions: DataTableFilterActions;
strategy: FilterStrategy;
entityName?: string;
aiGenerating?: boolean;
}
const ActiveFilterSkeleton = () => (
<div className="border-(--border) bg-(--muted)/60 text-(--muted-foreground) flex h-7 items-center gap-2 rounded-2xl border px-3 text-xs shadow-xs">
<div className="flex items-center gap-2">
<span className="bg-(--muted-foreground)/60 block h-2 w-10 animate-pulse rounded" />
<span className="bg-(--muted-foreground)/40 block h-2 w-6 animate-pulse rounded" />
</div>
</div>
);
interface ActiveFilterProps {
binding: FilterBinding;
actions: DataTableFilterActions;
strategy: FilterStrategy;
entityName?: string;
}
export const ActiveFilter = ({ binding, actions, strategy, entityName }: ActiveFilterProps) => {
const { column, filter } = binding;
if (!filter) {
return null;
}
return (
<div className="border-(--border) bg-(--background) flex h-7 items-center rounded-2xl border text-xs shadow-xs">
<FilterSubject column={column} entityName={entityName} />
<Separator orientation="vertical" />
<FilterOperator filter={filter} actions={actions} />
<Separator orientation="vertical" />
<FilterValue
binding={binding}
actions={actions}
strategy={strategy}
entityName={entityName}
/>
<Separator orientation="vertical" />
<Button
variant="ghost"
className="text-(--muted-foreground) hover:text-(--primary) h-full w-7 rounded-none rounded-r-2xl text-xs"
aria-label={`Remove ${column.displayName} filter`}
onClick={() => actions.removeFilter(filter.columnId)}
>
<X className="size-4 -translate-x-0.5" />
</Button>
</div>
);
};
export const ActiveFilters = ({
columns,
filters,
actions,
strategy,
entityName,
aiGenerating,
}: ActiveFiltersProps) => (
<>
{filters.map((filter) => {
const id = filter.columnId;
const column = getColumn(columns, id);
return (
<ActiveFilter
key={`active-filter-${filter.columnId}`}
binding={bindFilter(column, filter)}
actions={actions}
strategy={strategy}
entityName={entityName}
/>
);
})}
{aiGenerating && <ActiveFilterSkeleton />}
</>
);
export const ActiveFiltersContainer = ({ children }: { children: ReactNode }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftBlur, setShowLeftBlur] = useState(false);
const [showRightBlur, setShowRightBlur] = useState(true);
const checkScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) {
return;
}
const { scrollLeft, scrollWidth, clientWidth } = el;
setShowLeftBlur(scrollLeft > 0);
// 1px buffer absorbs subpixel rounding so the right blur clears at the end.
setShowRightBlur(scrollLeft + clientWidth < scrollWidth - 1);
}, []);
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) {
return;
}
const resizeObserver = new ResizeObserver(checkScroll);
resizeObserver.observe(el);
const mutationObserver = new MutationObserver(checkScroll);
mutationObserver.observe(el, { characterData: true, childList: true, subtree: true });
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [checkScroll]);
return (
<div className="relative min-w-0 flex-1 overflow-x-hidden md:overflow-visible">
{showLeftBlur && (
<div className="from-(--background) animate-in fade-in-0 pointer-events-none absolute top-0 bottom-0 left-0 z-10 md:hidden w-16 bg-gradient-to-r to-transparent" />
)}
<div
ref={scrollContainerRef}
className="flex gap-2 overflow-x-auto md:flex-wrap md:overflow-visible"
onScroll={checkScroll}
>
{children}
</div>
{showRightBlur && (
<div className="from-(--background) animate-in fade-in-0 pointer-events-none absolute top-0 right-0 bottom-0 z-10 md:hidden w-16 bg-gradient-to-l to-transparent" />
)}
</div>
);
};
/_components/data-table-filter.tsx
"use client";
import type {
Column,
DataTableFilterActions,
FiltersState,
FilterStrategy,
} from "../filter-package";
import { ActiveFilters, ActiveFiltersContainer } from "./active-filters";
import { FilterActions } from "./filter-actions";
import { FilterSelector } from "./filter-selector";
import { useAiFilterSimulation } from "./use-ai-filter-simulation";
interface DataTableFilterProps {
columns: Column[];
filters: FiltersState;
actions: DataTableFilterActions;
strategy: FilterStrategy;
entityName?: string;
}
export const DataTableFilter = ({
columns,
filters,
actions,
strategy,
entityName,
}: DataTableFilterProps) => {
const { aiGenerating, handleAiFilterSubmit } = useAiFilterSimulation({
actions,
columns,
});
const selectorProps = {
actions,
aiGenerating,
columns,
filters,
onAIFilterSubmit: handleAiFilterSubmit,
strategy,
} as const;
return (
<div className="filter-bar flex w-full items-start gap-2">
<FilterSelector {...selectorProps} />
<ActiveFiltersContainer>
<ActiveFilters
columns={columns}
filters={filters}
actions={actions}
strategy={strategy}
entityName={entityName}
aiGenerating={aiGenerating}
/>
<FilterActions hasFilters={filters.length > 0} actions={actions} />
</ActiveFiltersContainer>
</div>
);
};
/_components/filter-actions.tsx
import { Button } from "./ui";
import { cn } from "cn";
import type { DataTableFilterActions } from "../filter-package";
interface FilterActionsProps {
hasFilters: boolean;
actions?: DataTableFilterActions;
}
export const FilterActions = ({ hasFilters, actions }: FilterActionsProps) => (
<Button
className={cn("text-(--muted-foreground) h-7 px-2 font-normal", !hasFilters && "hidden")}
variant="ghost"
onClick={() => actions?.removeAllFilters()}
>
clear
</Button>
);
/_components/filter-operator.tsx
import { useState } from "react";
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from "./ui";
import type {
ColumnDataType,
DataTableFilterActions,
FilterModel,
FilterOperatorDetails,
FilterOperators,
FilterOperatorUpdate,
} from "../filter-package";
import { filterTypeOperatorDetails } from "../filter-package";
const operatorDetails = <K extends ColumnDataType>(
type: K,
operator: FilterOperators[K],
): { key: string; target: "single" | "multiple" } => filterTypeOperatorDetails[type][operator];
const relatedOperators = <K extends ColumnDataType>(type: K, operator: FilterOperators[K]) => {
const current = operatorDetails(type, operator);
const details: FilterOperatorDetails<FilterOperators[K], K>[] = Object.values(
filterTypeOperatorDetails[type],
);
return details.filter((detail) => detail.target === current.target);
};
const operatorChoices = (
filter: FilterModel,
): { label: string; update: FilterOperatorUpdate }[] => {
const { columnId } = filter;
switch (filter.type) {
case "text": {
return relatedOperators("text", filter.operator).map((detail) => ({
label: detail.key,
update: { columnId, operator: detail.value, type: "text" },
}));
}
case "number": {
return relatedOperators("number", filter.operator).map((detail) => ({
label: detail.key,
update: { columnId, operator: detail.value, type: "number" },
}));
}
case "date": {
return relatedOperators("date", filter.operator).map((detail) => ({
label: detail.key,
update: { columnId, operator: detail.value, type: "date" },
}));
}
case "boolean": {
return relatedOperators("boolean", filter.operator).map((detail) => ({
label: detail.key,
update: { columnId, operator: detail.value, type: "boolean" },
}));
}
case "option": {
return relatedOperators("option", filter.operator).map((detail) => ({
label: detail.key,
update: { columnId, operator: detail.value, type: "option" },
}));
}
case "multiOption": {
return relatedOperators("multiOption", filter.operator).map((detail) => ({
label: detail.key,
update: { columnId, operator: detail.value, type: "multiOption" },
}));
}
default: {
return [];
}
}
};
export const FilterOperator = ({
filter,
actions,
}: {
filter: FilterModel;
actions: DataTableFilterActions;
}) => {
const [open, setOpen] = useState(false);
const current = operatorDetails(filter.type, filter.operator);
return (
<Popover
open={open}
onOpenChange={(next) => {
if (filter.type !== "boolean") {
setOpen(next);
}
}}
>
<PopoverTrigger
render={
<Button
variant="ghost"
className="m-0 h-full w-fit rounded-none p-0 px-2 text-xs whitespace-nowrap"
onClick={() => {
if (filter.type !== "boolean") {
return;
}
actions.setFilterOperator({
columnId: filter.columnId,
operator: filter.operator === "is" ? "is not" : "is",
type: "boolean",
});
}}
/>
}
>
<span className="text-(--muted-foreground)">{current.key}</span>
</PopoverTrigger>
<PopoverContent align="start" className="w-fit origin-(--transform-origin) p-0">
<Command loop>
<CommandInput placeholder="search" />
<CommandEmpty>No results</CommandEmpty>
<CommandList className="max-h-fit">
<CommandGroup heading={filter.type === "date" ? undefined : "operators"}>
{operatorChoices(filter).map(({ label, update }) => (
<CommandItem
key={update.operator}
value={update.operator}
onSelect={() => {
actions.setFilterOperator(update);
setOpen(false);
}}
>
{label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
/_components/filter-selector.tsx
import {
Button,
Checkbox,
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from "./ui";
import { Fragment, isValidElement, useEffect, useMemo, useRef, useState } from "react";
import type { ElementType as ReactElementType, ReactElement } from "react";
import {
ArrowLeftIcon,
ArrowRightIcon,
ChevronRightIcon,
ListFilterIcon,
SparklesIcon,
} from "lucide-react";
import type {
Column,
ColumnDataType,
DataTableFilterActions,
FiltersState,
FilterStrategy,
} from "../filter-package";
import { getColumn, bindFilter } from "../filter-package";
import { FilterValueController } from "./filter-value";
const renderColumnIcon = (icon: ReactElement | ReactElementType, className: string) => {
if (isValidElement(icon)) {
return icon;
}
const IconComp = icon;
return <IconComp className={className} />;
};
export const FilterableColumn = <TType extends ColumnDataType>({
column,
setProperty,
actions,
filters,
}: {
column: Column<TType>;
setProperty: (value: string) => void;
actions: DataTableFilterActions;
filters: FiltersState;
}) => {
const { icon: Icon } = column;
const isFiltered = filters.some(
(filter) => filter.columnId === column.id && filter.values.length > 0,
);
const handleSelect = () => {
if (column.type === "boolean") {
actions.setFilterValue({ columnId: column.id, type: "boolean", values: [true] });
return;
}
setProperty(column.id);
};
return (
<CommandItem
value={column.id}
keywords={[column.displayName]}
onSelect={handleSelect}
className="group"
>
<div className="flex w-full items-center justify-between">
<div className="inline-flex items-center gap-1.5">
{Icon && (
<div className="relative">
{renderColumnIcon(Icon, "size-4 stroke-[2.25px]")}
{isFiltered && (
<div className="absolute -right-0.5 -bottom-0.5 h-2 w-2 rounded-full bg-green-500" />
)}
</div>
)}
{!Icon && isFiltered && <div className="h-2 w-2 rounded-full bg-green-500" />}
<span>{column.displayName}</span>
</div>
{column.type !== "boolean" && (
<ArrowRightIcon className="size-4 opacity-0 group-aria-selected:opacity-100" />
)}
</div>
</CommandItem>
);
};
interface QuickSearchFiltersProps {
search?: string;
filters: FiltersState;
columns: Column[];
actions: DataTableFilterActions;
}
export const QuickSearchFilters = ({
search,
filters,
columns,
actions,
}: QuickSearchFiltersProps) => {
const cols = useMemo(
() => columns.filter((column) => column.type === "option" || column.type === "multiOption"),
[columns],
);
if (!search || search.trim().length < 2) {
return null;
}
return (
<>
{cols.map((column) => {
const filter = filters.find((f) => f.columnId === column.id);
const { options } = column;
const handleOptionSelect = (value: string, check: boolean) => {
if (check) {
actions.addFilterValue(column, [value]);
} else {
actions.removeFilterValue(column, [value]);
}
};
return (
<Fragment key={column.id}>
{options.map((v) => {
const checked =
(filter?.type === "option" || filter?.type === "multiOption") &&
filter.values.includes(v.value);
return (
<CommandItem
key={v.value}
value={v.value}
keywords={[v.label, v.value]}
onSelect={() => {
handleOptionSelect(v.value, !checked);
}}
className="group"
>
<div className="group flex items-center gap-1.5">
<Checkbox
checked={checked}
className="dark:border-(--ring) mr-1 opacity-0 group-data-[selected=true]:opacity-100 data-checked:opacity-100"
/>
<div className="flex w-4 items-center justify-center">
{v.icon && renderColumnIcon(v.icon, "text-(--primary) size-4")}
</div>
<div className="flex items-center gap-0.5">
<span className="text-(--muted-foreground)">{column.displayName}</span>
<ChevronRightIcon className="text-(--muted-foreground)/75 size-3.5" />
<span>{v.label}</span>
</div>
</div>
</CommandItem>
);
})}
</Fragment>
);
})}
</>
);
};
interface FilterSelectorProps {
filters: FiltersState;
columns: Column[];
actions: DataTableFilterActions;
strategy: FilterStrategy;
onAIFilterSubmit?: (prompt: string) => void;
aiGenerating?: boolean;
}
export const FilterSelector = ({
filters,
columns,
actions,
strategy,
onAIFilterSubmit,
aiGenerating,
}: FilterSelectorProps) => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const [property, setProperty] = useState<string | undefined>();
const inputRef = useRef<HTMLInputElement>(null);
const visibleColumns = useMemo(() => columns.filter((c) => !c.hidden), [columns]);
const visibleFilters = useMemo(
() => filters.filter((f) => visibleColumns.find((c) => c.id === f.columnId)),
[filters, visibleColumns],
);
const column = property ? getColumn(visibleColumns, property) : undefined;
const filter = property ? visibleFilters.find((f) => f.columnId === property) : undefined;
useEffect(() => {
if (!property) {
return;
}
inputRef.current?.focus();
}, [property]);
useEffect(() => {
if (open) {
return;
}
const timeoutId = setTimeout(() => setValue(""), 150);
return () => clearTimeout(timeoutId);
}, [open]);
const content = useMemo(
() =>
property && column && column.type !== "boolean" ? (
<div className="flex flex-col">
<div className="flex h-9 items-center gap-2 border-b px-2">
<Button
variant="ghost"
size="icon"
onClick={() => setProperty(undefined)}
className="h-6 w-6 p-0"
>
<ArrowLeftIcon className="size-4" />
<span className="sr-only">Back</span>
</Button>
<div className="flex items-center gap-1.5 text-sm">
{column.icon && renderColumnIcon(column.icon, "size-4 stroke-[2.25px]")}
<span className="font-medium">{column.displayName}</span>
</div>
</div>
<FilterValueController
binding={bindFilter(column, filter)}
actions={actions}
strategy={strategy}
/>
</div>
) : (
<Command
loop
filter={(itemValue, search, keywords) => {
const extendValue = `${itemValue} ${keywords?.join(" ")}`;
return extendValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<CommandInput
value={value}
onValueChange={setValue}
ref={inputRef}
placeholder="Search or ask AI"
/>
<CommandList className="max-h-fit">
<CommandGroup>
<CommandItem
value={value}
disabled={!onAIFilterSubmit || aiGenerating}
onSelect={() => {
const prompt = value.trim();
if (!prompt || !onAIFilterSubmit) {
return;
}
onAIFilterSubmit(prompt);
setOpen(false);
setTimeout(() => {
setValue("");
setProperty(undefined);
}, 100);
}}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<SparklesIcon className="size-4" />
<span>Ask AI</span>
</div>
<span className="text-(--muted-foreground) truncate text-xs">
{value.trim().length > 0 ? `"${value.trim()}"` : "Type a prompt"}
</span>
</div>
</CommandItem>
{visibleColumns.map((visibleColumn) => (
<FilterableColumn
key={visibleColumn.id}
column={visibleColumn}
setProperty={(id) => {
setValue("");
setProperty(id);
}}
actions={actions}
filters={visibleFilters}
/>
))}
<QuickSearchFilters
search={value}
filters={visibleFilters}
columns={visibleColumns}
actions={actions}
/>
</CommandGroup>
</CommandList>
</Command>
),
[
aiGenerating,
property,
column,
filter,
visibleFilters,
visibleColumns,
actions,
value,
strategy,
onAIFilterSubmit,
],
);
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setTimeout(() => setProperty(undefined), 100);
}
}}
>
<PopoverTrigger
aria-label="Add filter"
render={<Button variant="ghost" size="icon" className="size-7 p-0" />}
>
<ListFilterIcon className="size-4" />
</PopoverTrigger>
<PopoverContent align="start" side="bottom" className="w-fit origin-(--transform-origin) p-0">
{content}
</PopoverContent>
</Popover>
);
};
/_components/filter-subject.tsx
import { isValidElement } from "react";
import type { Column, ColumnDataType } from "../filter-package";
interface FilterSubjectProps<TType extends ColumnDataType> {
column: Column<TType>;
entityName?: string;
}
export const FilterSubject = <TType extends ColumnDataType>({
column,
entityName,
}: FilterSubjectProps<TType>) => {
const subject = column.type === "boolean" ? entityName : column.displayName;
const { icon: Icon } = column;
return (
<span className="flex items-center gap-1 px-2 font-medium whitespace-nowrap select-none">
{Icon &&
(isValidElement(Icon)
? Icon
: (() => {
const IconComp = Icon;
return <IconComp className="text-(--primary) size-4 stroke-[2.25px]" />;
})())}
<span>{subject}</span>
</span>
);
};
/_components/filter-value.tsx
import {
Button,
Calendar,
Checkbox,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Slider,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "./ui";
import {
Fragment,
cloneElement,
isValidElement,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import type {
ChangeEvent,
ElementType as ReactElementType,
InputHTMLAttributes,
Key,
ReactElement,
} from "react";
import type { DateRange } from "react-day-picker";
import { cn } from "cn";
import { format, isEqual } from "date-fns";
import { Ellipsis } from "lucide-react";
import type {
Column,
FilterBinding,
ColumnDataType,
ColumnOptionExtended,
DataTableFilterActions,
FilterModel,
FilterStrategy,
} from "../filter-package";
import { createNumberRange, numberFilterOperators } from "../filter-package";
import { useDebouncedCallback } from "./use-debounced-callback";
interface IconProps {
className?: string;
key?: Key;
}
type IconLike = ReactElement | ReactElementType;
const renderIcon = (icon: IconLike, props: IconProps = {}) => {
if (isValidElement(icon)) {
// oxlint-disable-next-line react/no-clone-element -- applies the caller's className and key to an element the config supplied; wrapping it would change the DOM
return cloneElement(icon, props);
}
const IconComp = icon;
const { key, ...iconProps } = props;
return <IconComp key={key} {...iconProps} />;
};
export const DebouncedInput = ({
value: initialValue,
onChange,
debounceMs = 500,
onBlur,
onKeyDown,
...props
}: {
value: string | number;
onChange: (value: string | number) => void;
debounceMs?: number;
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange">) => {
const [value, setValue] = useState(initialValue);
const [prevInitialValue, setPrevInitialValue] = useState(initialValue);
if (prevInitialValue !== initialValue) {
setPrevInitialValue(initialValue);
setValue(initialValue);
}
const { schedule: debouncedOnChange, cancel, flush } = useDebouncedCallback(onChange, debounceMs);
useEffect(cancel, [initialValue, cancel]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
debouncedOnChange(newValue);
};
return (
<Input
{...props}
value={value}
onChange={handleChange}
onBlur={(event) => {
flush();
onBlur?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!event.defaultPrevented && (event.key === "Enter" || event.key === "Escape")) {
flush();
}
}}
/>
);
};
interface FilterValueProps {
binding: FilterBinding;
actions: DataTableFilterActions;
strategy: FilterStrategy;
entityName?: string;
}
interface FilterValueDisplayProps<K extends ColumnDataType> {
filter: FilterModel<K>;
column: Column<K>;
actions: DataTableFilterActions;
entityName?: string;
}
export const FilterValueOptionDisplay = ({ filter, column }: FilterValueDisplayProps<"option">) => {
const { options } = column;
const selected = options.filter((o) => filter?.values.includes(o.value));
const [only] = selected;
if (selected.length === 1 && only) {
const { label, icon: Icon } = only;
const hasIcon = !!Icon;
return (
<span className="inline-flex items-center gap-1">
{hasIcon && renderIcon(Icon, { className: "text-(--primary) size-4" })}
<span>{label}</span>
</span>
);
}
const name = column.displayName.toLowerCase();
const pluralName = name.endsWith("s") ? `${name}es` : `${name}s`;
const hasOptionIcons = !options?.some((o) => !o.icon);
return (
<div className="inline-flex items-center gap-0.5">
{hasOptionIcons &&
selected.slice(0, 3).map(({ value, icon }) => {
const Icon = icon;
if (!Icon) {
return null;
}
return renderIcon(Icon, { className: "size-4", key: value });
})}
<span className={cn(hasOptionIcons && "ml-1.5")}>
{selected.length} {pluralName}
</span>
</div>
);
};
export const FilterValueMultiOptionDisplay = ({
filter,
column,
}: FilterValueDisplayProps<"multiOption">) => {
const { options } = column;
const selected = options.filter((o) => filter.values.includes(o.value));
const [only] = selected;
if (selected.length === 1 && only) {
const { label, icon: Icon } = only;
const hasIcon = !!Icon;
return (
<span className="inline-flex items-center gap-1.5">
{hasIcon && renderIcon(Icon, { className: "text-(--primary) size-4" })}
<span>{label}</span>
</span>
);
}
const name = column.displayName.toLowerCase();
const hasOptionIcons = !options?.some((o) => !o.icon);
return (
<div className="inline-flex items-center gap-1.5">
{hasOptionIcons && (
<div key="icons" className="inline-flex items-center gap-0.5">
{selected.slice(0, 3).map(({ value, icon }) => {
const Icon = icon;
if (!Icon) {
return null;
}
return isValidElement(Icon) ? (
<Fragment key={value}>{Icon}</Fragment>
) : (
renderIcon(Icon, { className: "size-4", key: value })
);
})}
</div>
)}
<span>
{selected.length} {name}
</span>
</div>
);
};
const formatDateRange = (start: Date, end: Date) => {
const sameMonth = start.getMonth() === end.getMonth();
const sameYear = start.getFullYear() === end.getFullYear();
if (sameMonth && sameYear) {
return `${format(start, "MMM d")} - ${format(end, "d, yyyy")}`;
}
if (sameYear) {
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
}
return `${format(start, "MMM d, yyyy")} - ${format(end, "MMM d, yyyy")}`;
};
export const FilterValueDateDisplay = ({ filter }: FilterValueDisplayProps<"date">) => {
if (!filter) {
return null;
}
if (filter.values.length === 0) {
return <Ellipsis className="size-4" />;
}
const [first, second] = filter.values;
if (filter.values.length === 1 && first) {
const formattedDateStr = format(first, "MMM d, yyyy");
return <span>{formattedDateStr}</span>;
}
if (filter.values.length === 2 && first && second) {
const formattedRangeStr = formatDateRange(first, second);
return <span>{formattedRangeStr}</span>;
}
return null;
};
export const FilterValueTextDisplay = ({ filter }: FilterValueDisplayProps<"text">) => {
if (!filter) {
return null;
}
if (filter.values.length === 0 || (filter.values[0] && filter.values[0].trim() === "")) {
return <Ellipsis className="size-4" />;
}
const [value] = filter.values;
return <span>{value}</span>;
};
export const FilterValueNumberDisplay = ({ filter }: FilterValueDisplayProps<"number">) => {
if (!filter || !filter.values || filter.values.length === 0) {
return null;
}
if (filter.operator === "is between" || filter.operator === "is not between") {
const [minValue, maxValue] = filter.values;
return (
<span className="tracking-tight tabular-nums">
{minValue} and {maxValue}
</span>
);
}
const [value] = filter.values;
return <span className="tracking-tight tabular-nums">{value}</span>;
};
export const FilterValueBooleanDisplay = ({
filter,
column,
}: FilterValueDisplayProps<"boolean">) => {
if (!filter || filter.values.length === 0) {
return null;
}
return <span>{column.toggledStateName}</span>;
};
export const FilterValueDisplay = ({
binding,
actions,
entityName,
}: Omit<FilterValueProps, "strategy">) => {
switch (binding.type) {
case "option": {
return (
binding.filter && (
<FilterValueOptionDisplay
filter={binding.filter}
column={binding.column}
actions={actions}
entityName={entityName}
/>
)
);
}
case "multiOption": {
return (
binding.filter && (
<FilterValueMultiOptionDisplay
filter={binding.filter}
column={binding.column}
actions={actions}
entityName={entityName}
/>
)
);
}
case "date": {
return (
binding.filter && (
<FilterValueDateDisplay
filter={binding.filter}
column={binding.column}
actions={actions}
entityName={entityName}
/>
)
);
}
case "text": {
return (
binding.filter && (
<FilterValueTextDisplay
filter={binding.filter}
column={binding.column}
actions={actions}
entityName={entityName}
/>
)
);
}
case "number": {
return (
binding.filter && (
<FilterValueNumberDisplay
filter={binding.filter}
column={binding.column}
actions={actions}
entityName={entityName}
/>
)
);
}
case "boolean": {
return (
binding.filter && (
<FilterValueBooleanDisplay
filter={binding.filter}
column={binding.column}
actions={actions}
entityName={entityName}
/>
)
);
}
default: {
return null;
}
}
};
interface FilterValueControllerProps<K extends ColumnDataType> {
filter?: FilterModel<K>;
column: Column<K>;
actions: DataTableFilterActions;
strategy: FilterStrategy;
}
interface OptionItemProps {
option: ColumnOptionExtended;
onToggle: (value: string, checked: boolean) => void;
}
const OptionItemBase = ({ option, onToggle }: OptionItemProps) => {
const { value, label, icon: Icon, selected } = option;
const handleSelect = useCallback(() => {
onToggle(value, !selected);
}, [onToggle, value, selected]);
return (
<CommandItem
key={value}
onSelect={handleSelect}
className="group flex items-center justify-between gap-4"
>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Checkbox
checked={selected}
className="dark:border-(--ring) mr-1 shrink-0 opacity-0 group-data-[selected=true]:opacity-100 data-checked:opacity-100"
/>
<div className="shrink-0">
{Icon && renderIcon(Icon, { className: "text-(--primary) size-4" })}
</div>
<span className="overflow-x-hidden overflow-ellipsis whitespace-nowrap">{label}</span>
</div>
</CommandItem>
);
};
const OptionItem = memo(OptionItemBase);
export const FilterValueOptionController = ({
filter,
column,
actions,
}: FilterValueControllerProps<"option" | "multiOption">) => {
// oxlint-disable-next-line react/hook-use-state -- a one-time snapshot of the selection when the controller opens, so items stay in their group while toggling; there is deliberately no setter
const [initialSelectedValues] = useState(() => new Set(filter?.values || []));
const { selectedOptions, unselectedOptions } = useMemo(() => {
const allOptions = column.options.map((o) => {
const currentlySelected = filter?.values.includes(o.value) ?? false;
return {
count: o.count ?? 0,
icon: o.icon,
label: o.label,
selected: currentlySelected,
value: o.value,
};
});
const selected = allOptions.filter((o) => initialSelectedValues.has(o.value));
const unselected = allOptions.filter((o) => !initialSelectedValues.has(o.value));
return { selectedOptions: selected, unselectedOptions: unselected };
}, [column, filter?.values, initialSelectedValues]);
const handleToggle = useCallback(
(value: string, checked: boolean) => {
if (checked) {
actions.addFilterValue(column, [value]);
} else {
actions.removeFilterValue(column, [value]);
}
},
[actions, column],
);
return (
<Command className="max-w-[300px]" loop>
<CommandInput autoFocus placeholder="search" />
<CommandEmpty>No results</CommandEmpty>
<CommandList>
<CommandGroup className={cn(selectedOptions.length === 0 && "hidden")}>
{selectedOptions.map((option) => (
<OptionItem key={option.value} option={option} onToggle={handleToggle} />
))}
</CommandGroup>
<CommandSeparator
className={cn(
(unselectedOptions.length === 0 || selectedOptions.length === 0) && "hidden",
)}
/>
<CommandGroup className={cn(unselectedOptions.length === 0 && "hidden")}>
{unselectedOptions.map((option) => (
<OptionItem key={option.value} option={option} onToggle={handleToggle} />
))}
</CommandGroup>
</CommandList>
</Command>
);
};
export const FilterValueDateController = ({
filter,
column,
actions,
}: FilterValueControllerProps<"date">) => {
const start = filter?.values[0];
const date: DateRange | undefined = start ? { from: start, to: filter?.values[1] } : undefined;
const changeDateRange = (value: DateRange | undefined) => {
const from = value?.from;
const to = from && value?.to && !isEqual(from, value.to) ? value.to : undefined;
let newValues: Date[] = [];
if (from && to) {
newValues = [from, to];
} else if (from) {
newValues = [from];
}
actions.setFilterValue({ columnId: column.id, type: column.type, values: newValues });
};
return (
<Command>
<CommandList className="max-h-fit">
<CommandGroup>
<div>
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={changeDateRange}
numberOfMonths={1}
/>
</div>
</CommandGroup>
</CommandList>
</Command>
);
};
export const FilterValueTextController = ({
filter,
column,
actions,
}: FilterValueControllerProps<"text">) => {
const changeText = (value: string | number) => {
actions.setFilterValue({ columnId: column.id, type: "text", values: [String(value)] });
};
return (
<Command>
<CommandList className="max-h-fit">
<CommandGroup>
<CommandItem>
<DebouncedInput
placeholder="search"
autoFocus
value={filter?.values[0] ?? ""}
onChange={changeText}
/>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
};
export const FilterValueNumberController = ({
filter,
column,
actions,
}: FilterValueControllerProps<"number">) => {
const minMax = useMemo<[number, number]>(
() => [column.min, column.max],
[column.min, column.max],
);
const [sliderMin, sliderMax] = [minMax ? minMax[0] : 0, minMax ? minMax[1] : 0];
const [values, setValues] = useState(filter?.values ?? [0, 0]);
const [previousFilterValues, setPreviousFilterValues] = useState(filter?.values);
if (previousFilterValues !== filter?.values) {
setPreviousFilterValues(filter?.values);
setValues(filter?.values ?? [0, 0]);
}
const isNumberRange = filter && numberFilterOperators[filter.operator].target === "multiple";
const setNumberFilterValue = useCallback(
(newValues: number[]) =>
actions.setFilterValue({ columnId: column.id, type: "number", values: newValues }),
[actions, column],
);
const {
schedule: setFilterValueDebounced,
cancel: cancelFilterValueUpdate,
flush: flushFilterValueUpdate,
} = useDebouncedCallback(setNumberFilterValue, 500);
useEffect(cancelFilterValueUpdate, [filter?.values, cancelFilterValueUpdate]);
const changeNumber = (value: number[]) => {
cancelFilterValueUpdate();
setValues(value);
setNumberFilterValue(value);
};
const changeSlider = (value: number | readonly number[]) => {
const nextValues = Array.isArray(value) ? [...value] : [value];
setValues(nextValues);
setFilterValueDebounced(nextValues);
};
const changeMinNumber = (value: number) => {
const newValues = createNumberRange([value, values[1] ?? 0]);
changeNumber(newValues);
};
const changeMaxNumber = (value: number) => {
const newValues = createNumberRange([values[0] ?? 0, value]);
changeNumber(newValues);
};
const changeType = useCallback(
(type: "single" | "range") => {
let newValues: number[] = [];
if (type === "single") {
newValues = [values[0] ?? 0];
} else if (minMax) {
const value = values[0] ?? 0;
newValues =
value - minMax[0] < minMax[1] - value
? createNumberRange([value, minMax[1]])
: createNumberRange([minMax[0], value]);
} else {
newValues = createNumberRange([values[0] ?? 0, values[1] ?? 0]);
}
const newOperator = type === "single" ? "is" : "is between";
setValues(newValues);
// Cancel the old value before changing operators.
cancelFilterValueUpdate();
actions.setFilterOperator({ columnId: column.id, operator: newOperator, type: "number" });
actions.setFilterValue({ columnId: column.id, type: column.type, values: newValues });
},
[values, column, actions, minMax, cancelFilterValueUpdate],
);
return (
<Command
onBlur={flushFilterValueUpdate}
onKeyDown={(event) => {
if (event.key === "Escape") {
flushFilterValueUpdate();
}
}}
>
<CommandList className="w-[300px] px-2 py-2">
<CommandGroup>
<div className="flex w-full flex-col">
<Tabs
value={isNumberRange ? "range" : "single"}
onValueChange={(v) => changeType(v === "range" ? "range" : "single")}
>
<TabsList className="w-full *:text-xs">
<TabsTrigger value="single">single</TabsTrigger>
<TabsTrigger value="range">range</TabsTrigger>
</TabsList>
<TabsContent value="single" className="mt-4 flex flex-col gap-4">
{minMax && (
<Slider
value={[values[0] ?? 0]}
onValueChange={changeSlider}
onValueCommitted={flushFilterValueUpdate}
min={sliderMin}
max={sliderMax}
step={1}
aria-orientation="horizontal"
/>
)}
<div className="flex items-center gap-2">
<span className="text-xs font-medium">value</span>
<DebouncedInput
id="single"
type="number"
value={(values[0] ?? 0).toString()}
onChange={(v) => changeNumber([Number(v)])}
/>
</div>
</TabsContent>
<TabsContent value="range" className="mt-4 flex flex-col gap-4">
{minMax && (
<Slider
value={values}
onValueChange={changeSlider}
onValueCommitted={flushFilterValueUpdate}
min={sliderMin}
max={sliderMax}
step={1}
aria-orientation="horizontal"
/>
)}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">min</span>
<DebouncedInput
type="number"
value={values[0] ?? 0}
onChange={(v) => changeMinNumber(Number(v))}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">max</span>
<DebouncedInput
type="number"
value={values[1] ?? 0}
onChange={(v) => changeMaxNumber(Number(v))}
/>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</CommandGroup>
</CommandList>
</Command>
);
};
export const FilterValueController = ({
binding,
actions,
strategy,
}: Omit<FilterValueProps, "entityName">) => {
switch (binding.type) {
case "option": {
return (
<FilterValueOptionController
filter={binding.filter}
column={binding.column}
actions={actions}
strategy={strategy}
/>
);
}
case "multiOption": {
return (
<FilterValueOptionController
filter={binding.filter}
column={binding.column}
actions={actions}
strategy={strategy}
/>
);
}
case "date": {
return (
<FilterValueDateController
filter={binding.filter}
column={binding.column}
actions={actions}
strategy={strategy}
/>
);
}
case "text": {
return (
<FilterValueTextController
filter={binding.filter}
column={binding.column}
actions={actions}
strategy={strategy}
/>
);
}
case "number": {
return (
<FilterValueNumberController
filter={binding.filter}
column={binding.column}
actions={actions}
strategy={strategy}
/>
);
}
case "boolean": {
return null;
}
default: {
return null;
}
}
};
export const FilterValue = ({ binding, actions, strategy, entityName }: FilterValueProps) => {
const [open, setOpen] = useState(false);
return (
<Popover
open={open}
onOpenChange={(next) => {
if (binding.type !== "boolean") {
setOpen(next);
}
}}
>
<PopoverTrigger
render={
<Button
variant="ghost"
className={cn(
"m-0 h-full w-fit rounded-none p-0 px-2 text-xs whitespace-nowrap",
binding.type === "boolean" && "hover:bg-inherit",
)}
/>
}
>
<FilterValueDisplay binding={binding} actions={actions} entityName={entityName} />
</PopoverTrigger>
<PopoverContent align="start" side="bottom" className="w-fit origin-(--transform-origin) p-0">
<FilterValueController binding={binding} actions={actions} strategy={strategy} />
</PopoverContent>
</Popover>
);
};
/_components/ui.tsx
"use client";
import type { ComponentProps } from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "cn";
import { CheckIcon, SearchIcon } from "lucide-react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/style.css";
import "../filter-bar.css";
type Styled<Props> = Omit<Props, "className"> & { className?: string };
export const Button = ({
className,
variant = "default",
size = "default",
...props
}: Styled<ButtonPrimitive.Props> & {
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "icon";
}) => (
<ButtonPrimitive
className={cn(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--ring) disabled:pointer-events-none disabled:opacity-50 [&_svg]:shrink-0",
variant === "default" && "bg-(--primary) text-(--primary-foreground) hover:bg-(--primary)/80",
variant === "outline" && "border border-(--border) bg-(--background) hover:bg-(--muted)",
variant === "ghost" && "hover:bg-(--muted) aria-expanded:bg-(--muted)",
size === "default" && "h-9 px-4 py-2",
size === "sm" && "h-8 px-2.5",
size === "icon" && "size-9",
className,
)}
{...props}
/>
);
export const Input = ({ className, ...props }: ComponentProps<"input">) => (
<input
className={cn(
"h-9 w-full min-w-0 rounded-md border border-(--input) bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-(--ring) disabled:opacity-50",
className,
)}
{...props}
/>
);
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverContent = ({
className,
align = "center",
side = "bottom",
...props
}: Styled<PopoverPrimitive.Popup.Props> &
Pick<PopoverPrimitive.Positioner.Props, "align" | "side">) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
side={side}
sideOffset={4}
className="filter-bar isolate z-50"
>
<PopoverPrimitive.Popup
className={cn(
"flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-(--popover) p-4 text-sm text-(--popover-foreground) shadow-md ring-1 ring-(--foreground)/10 outline-none",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
export const Checkbox = ({ className, ...props }: Styled<CheckboxPrimitive.Root.Props>) => (
<CheckboxPrimitive.Root
className={cn(
"relative size-[15px] shrink-0 rounded-[5px] border-[1.5px] border-(--input) outline-none focus-visible:ring-2 focus-visible:ring-(--ring) data-checked:border-transparent",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="absolute inset-0 grid place-content-center text-(--primary)">
<CheckIcon className="size-4" strokeWidth={2.5} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
export const Separator = ({
className,
orientation = "horizontal",
...props
}: Styled<SeparatorPrimitive.Props>) => (
<SeparatorPrimitive
orientation={orientation}
className={cn(
"shrink-0 bg-(--border) data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className,
)}
{...props}
/>
);
export const Command = ({ className, ...props }: ComponentProps<typeof CommandPrimitive>) => (
<CommandPrimitive
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl bg-(--popover) p-1 text-(--popover-foreground)",
className,
)}
{...props}
/>
);
export const CommandInput = ({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Input>) => (
<div className="relative m-1 flex h-8 items-center gap-2 rounded-lg border border-(--input)/30 bg-(--input)/30 px-2">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
aria-label="Search filters"
className={cn("w-full bg-transparent text-sm outline-none disabled:opacity-50", className)}
{...props}
/>
</div>
);
export const CommandList = ({
className,
...props
}: ComponentProps<typeof CommandPrimitive.List>) => (
<CommandPrimitive.List
className={cn("max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none", className)}
{...props}
/>
);
export const CommandEmpty = ({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Empty>) => (
<CommandPrimitive.Empty className={cn("py-6 text-center text-sm", className)} {...props} />
);
export const CommandGroup = ({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Group>) => (
<CommandPrimitive.Group
className={cn(
"overflow-hidden p-1 text-(--foreground) **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:text-(--muted-foreground)",
className,
)}
{...props}
/>
);
export const CommandItem = ({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Item>) => (
<CommandPrimitive.Item
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-(--muted) [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
export const CommandSeparator = ({
className,
...props
}: ComponentProps<typeof CommandPrimitive.Separator>) => (
<CommandPrimitive.Separator className={cn("-mx-1 h-px bg-(--border)", className)} {...props} />
);
export const Slider = ({ className, value, ...props }: Styled<SliderPrimitive.Root.Props>) => {
const count = Array.isArray(value) ? value.length : 1;
return (
<SliderPrimitive.Root
value={value}
className={cn("relative flex w-full touch-none items-center select-none", className)}
{...props}
>
<SliderPrimitive.Control className="relative flex w-full items-center">
<SliderPrimitive.Track className="relative h-1.5 w-full rounded-full bg-(--muted)">
<SliderPrimitive.Indicator className="rounded-full bg-(--primary)" />
</SliderPrimitive.Track>
{Array.from({ length: count }, (_, index) => (
<SliderPrimitive.Thumb
key={index}
index={index}
className="block size-4 rounded-full border border-(--primary) bg-(--background) shadow-sm outline-none focus-visible:ring-4 focus-visible:ring-(--primary)/30"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
};
export const Tabs = ({ className, ...props }: Styled<TabsPrimitive.Root.Props>) => (
<TabsPrimitive.Root className={cn("flex flex-col gap-2", className)} {...props} />
);
export const TabsList = ({ className, ...props }: Styled<TabsPrimitive.List.Props>) => (
<TabsPrimitive.List
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-(--muted) p-[3px] text-(--muted-foreground)",
className,
)}
{...props}
/>
);
export const TabsTrigger = ({ className, ...props }: Styled<TabsPrimitive.Tab.Props>) => (
<TabsPrimitive.Tab
className={cn(
"inline-flex h-full flex-1 items-center justify-center rounded-md px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-(--ring) data-active:bg-(--background) data-active:text-(--foreground)",
className,
)}
{...props}
/>
);
export const TabsContent = ({ className, ...props }: Styled<TabsPrimitive.Panel.Props>) => (
<TabsPrimitive.Panel className={cn("flex-1 outline-none", className)} {...props} />
);
export const Calendar = (props: ComponentProps<typeof DayPicker>) => (
<DayPicker showOutsideDays className="filter-bar-calendar p-3" {...props} />
);
/_components/use-ai-filter-simulation.ts
import { useCallback } from "react";
import { useChat } from "@ai-sdk/react";
import { StaticChatTransport } from "@loremllm/transport";
import type { Column, DataTableFilterActions } from "../filter-package";
import type { UIMessage } from "@ai-sdk/react";
type FilterUIMessage = UIMessage<
unknown,
{
setFilter: {
columnId: string;
values: string[];
};
}
>;
const transport = new StaticChatTransport<FilterUIMessage>({
chunkDelayMs: [600, 1400],
async *mockResponse() {
yield {
data: {
columnId: "skills",
values: ["javascript", "typescript", "react", "nodejs"],
},
type: "data-setFilter",
};
yield {
data: {
columnId: "department",
values: ["design"],
},
type: "data-setFilter",
};
},
});
interface UseAiFilterSimulationParams {
columns: Column[];
actions: DataTableFilterActions;
}
export const useAiFilterSimulation = ({ columns, actions }: UseAiFilterSimulationParams) => {
const { sendMessage, status } = useChat<FilterUIMessage>({
onData: (dataPart) => {
if (dataPart.type === "data-setFilter") {
const { columnId, values } = dataPart.data;
const column = columns.find((col) => col.id === columnId);
if (column?.type === "option" || column?.type === "multiOption") {
actions.setFilterValue({ columnId, type: column.type, values });
}
}
},
transport,
});
const handleAiFilterSubmit = useCallback(
(prompt: string) => {
actions.removeAllFilters();
void sendMessage({ text: prompt });
},
[sendMessage, actions],
);
const aiGenerating = status === "streaming" || status === "submitted";
return {
aiGenerating,
handleAiFilterSubmit,
};
};
/_components/use-debounced-callback.ts
import { useCallback, useEffect, useRef } from "react";
export const useDebouncedCallback = <TArgs extends unknown[]>(
callback: (...args: TArgs) => void,
delay: number,
) => {
const callbackRef = useRef(callback);
const pendingRef = useRef<{ timeout: ReturnType<typeof setTimeout>; args: TArgs } | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const cancel = useCallback(() => {
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current.timeout);
}
pendingRef.current = null;
}, []);
useEffect(() => cancel, [cancel]);
const flush = useCallback(() => {
const pending = pendingRef.current;
if (!pending) {
return;
}
cancel();
callbackRef.current(...pending.args);
}, [cancel]);
const schedule = useCallback(
(...args: TArgs) => {
cancel();
pendingRef.current = { args, timeout: setTimeout(flush, delay) };
},
[cancel, delay, flush],
);
return { cancel, flush, schedule };
};
/filter-bar.css
.filter-bar {
--background: #fff;
--foreground: #18181b;
--primary: #f66b2c;
--primary-foreground: #fff;
--secondary: #f4f4f5;
--secondary-foreground: #27272a;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #27272a;
--popover: #fff;
--popover-foreground: #18181b;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #a1a1aa;
--destructive: #dc2626;
color: var(--foreground);
}
.dark .filter-bar {
--background: #18181b;
--foreground: #fafafa;
--primary-foreground: #27272a;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--popover: #27272a;
--popover-foreground: #fafafa;
--border: #ffffff1a;
--input: #ffffff26;
--ring: #71717a;
--destructive: #f87171;
}
.filter-bar-calendar {
--rdp-accent-color: var(--primary);
--rdp-accent-background-color: var(--accent);
--rdp-day-height: 32px;
--rdp-day-width: 32px;
--rdp-day_button-height: 32px;
--rdp-day_button-width: 32px;
font-size: 0.875rem;
}
@layer base {
:where(.filter-bar, .filter-bar *) {
border-color: var(--border);
}
}
/filter-package/core/columns/column-factory.ts
import type { Column, ColumnConfig, ColumnOption, FilterStrategy } from "../types";
const isPresent = <T>(value: T | null | undefined): value is T =>
value !== null && value !== undefined;
const prepareOptions = (
values: (string | ColumnOption)[],
configured: ColumnOption[] | undefined,
strategy: FilterStrategy,
) => {
if (strategy === "server" && !configured) {
throw new Error("Server filters require explicit column options");
}
const entries = values.map((value) =>
// eslint-disable-next-line anti-slop/no-runtime-typeof -- Discriminates the typed string | ColumnOption config union.
typeof value === "string" ? { label: value, value } : value,
);
const unique = new Map<string, ColumnOption>();
for (const option of configured ?? entries) {
if (!unique.has(option.value)) {
unique.set(option.value, option);
}
}
const counts = new Map<string, number>();
for (const entry of entries) {
counts.set(entry.value, (counts.get(entry.value) ?? 0) + 1);
}
return {
options: Array.from(unique.values(), (option) => ({
...option,
count: strategy === "server" ? option.count : (counts.get(option.value) ?? 0),
})),
values: entries.filter((entry) => unique.has(entry.value)).map((entry) => entry.value),
};
};
const createColumn = <Row>(
data: Row[],
config: ColumnConfig<Row>,
strategy: FilterStrategy,
): Column => {
const common = {
displayName: config.displayName,
hidden: config.hidden,
icon: config.icon,
id: config.id,
};
switch (config.type) {
case "text": {
return {
...common,
type: "text",
values: data.map(config.accessor).filter(isPresent),
};
}
case "date": {
return {
...common,
type: "date",
values: data.map(config.accessor).filter(isPresent),
};
}
case "boolean": {
return {
...common,
toggledStateName: config.toggledStateName,
type: "boolean",
values: data.map(config.accessor).filter(isPresent),
};
}
case "number": {
const values = data.map(config.accessor).filter(isPresent).filter(Number.isFinite);
let min = values[0] ?? 0;
let max = min;
for (const value of values) {
min = Math.min(min, value);
max = Math.max(max, value);
}
return {
...common,
max: config.max ?? max,
min: config.min ?? min,
type: "number",
values,
};
}
case "option": {
return {
...common,
type: "option",
...prepareOptions(data.map(config.accessor).filter(isPresent), config.options, strategy),
};
}
case "multiOption": {
return {
...common,
type: "multiOption",
...prepareOptions(
data.flatMap((row) => config.accessor(row) ?? []),
config.options,
strategy,
),
};
}
default: {
throw new Error(`Unsupported column type: ${String(config satisfies never)}`);
}
}
};
export const createColumns = <Row>(
data: Row[],
configs: readonly ColumnConfig<Row>[],
strategy: FilterStrategy,
): Column[] => configs.map((config) => createColumn(data, config, strategy));
/filter-package/core/filters.ts
import type {
FilterModel,
FiltersState,
FilterValueUpdate,
FilterOperatorUpdate,
Column,
OptionBasedColumnDataType,
} from "./types";
import { DEFAULT_OPERATORS, determineNewOperator } from "./operators";
const replaceFilter = (filters: FiltersState, next: FilterModel): FiltersState => {
if (next.values.length === 0) {
return filters.filter((filter) => filter.columnId !== next.columnId);
}
return filters.some((filter) => filter.columnId === next.columnId)
? filters.map((filter) => (filter.columnId === next.columnId ? next : filter))
: [...filters, next];
};
const defaultOperator = <Operator>(
count: number,
operators: { single: Operator; multiple: Operator },
) => (count > 1 ? operators.multiple : operators.single);
export const setFilterValue = (filters: FiltersState, update: FilterValueUpdate): FiltersState => {
const current = filters.find((filter) => filter.columnId === update.columnId);
const { columnId } = update;
switch (update.type) {
case "text": {
const values = [...new Set(update.values)].filter((value) => value.trim().length > 0);
return replaceFilter(filters, {
columnId,
operator: current?.type === "text" ? current.operator : "contains",
type: "text",
values,
});
}
case "boolean": {
const values = update.values.slice(0, 1);
return replaceFilter(filters, {
columnId,
operator: current?.type === "boolean" ? current.operator : "is",
type: "boolean",
values,
});
}
case "number": {
const values = update.values
.filter(Number.isFinite)
.slice(0, 2)
.toSorted((a, b) => a - b);
const operator =
current?.type === "number"
? determineNewOperator("number", current.values, values, current.operator)
: defaultOperator(values.length, DEFAULT_OPERATORS.number);
return replaceFilter(filters, { columnId, operator, type: "number", values });
}
case "date": {
const values = update.values
.filter((value) => Number.isFinite(value.getTime()))
.slice(0, 2)
.toSorted((a, b) => a.getTime() - b.getTime());
const operator =
current?.type === "date"
? determineNewOperator("date", current.values, values, current.operator)
: defaultOperator(values.length, DEFAULT_OPERATORS.date);
return replaceFilter(filters, { columnId, operator, type: "date", values });
}
case "option": {
const values = [...new Set(update.values)];
const operator =
current?.type === "option"
? determineNewOperator("option", current.values, values, current.operator)
: defaultOperator(values.length, DEFAULT_OPERATORS.option);
return replaceFilter(filters, { columnId, operator, type: "option", values });
}
case "multiOption": {
const values = [...new Set(update.values)];
const operator =
current?.type === "multiOption"
? determineNewOperator("multiOption", current.values, values, current.operator)
: defaultOperator(values.length, DEFAULT_OPERATORS.multiOption);
return replaceFilter(filters, { columnId, operator, type: "multiOption", values });
}
default: {
throw new Error(`Unsupported filter type: ${String(update satisfies never)}`);
}
}
};
export const setFilterOperator = (
filters: FiltersState,
update: FilterOperatorUpdate,
): FiltersState =>
filters.map((filter) => {
if (filter.columnId !== update.columnId) {
return filter;
}
switch (update.type) {
case "text": {
return filter.type === "text" ? { ...filter, operator: update.operator } : filter;
}
case "number": {
return filter.type === "number" ? { ...filter, operator: update.operator } : filter;
}
case "date": {
return filter.type === "date" ? { ...filter, operator: update.operator } : filter;
}
case "boolean": {
return filter.type === "boolean" ? { ...filter, operator: update.operator } : filter;
}
case "option": {
return filter.type === "option" ? { ...filter, operator: update.operator } : filter;
}
case "multiOption": {
return filter.type === "multiOption" ? { ...filter, operator: update.operator } : filter;
}
default: {
return filter;
}
}
});
export const toggleFilterValues = (
filters: FiltersState,
column: Column<OptionBasedColumnDataType>,
values: string[],
add: boolean,
): FiltersState => {
const current = filters.find((filter) => filter.columnId === column.id);
const existing =
current?.type === "option" || current?.type === "multiOption" ? current.values : [];
return setFilterValue(filters, {
columnId: column.id,
type: column.type,
values: add ? [...existing, ...values] : existing.filter((value) => !values.includes(value)),
});
};
/filter-package/core/operators.ts
import type {
ColumnDataType,
FilterDetails,
FilterOperators,
FilterOperatorTarget,
FilterTypeOperatorDetails,
FilterValues,
} from "./types";
export const DEFAULT_OPERATORS = {
boolean: {
multiple: "is",
single: "is",
},
date: {
multiple: "is between",
single: "is",
},
multiOption: {
multiple: "include any of",
single: "include",
},
number: {
multiple: "is between",
single: "is",
},
option: {
multiple: "is any of",
single: "is",
},
text: {
multiple: "contains",
single: "contains",
},
} satisfies Record<ColumnDataType, Record<FilterOperatorTarget, FilterOperators[ColumnDataType]>>;
export const optionFilterOperators = {
is: {
key: "is",
multiple: "is any of",
target: "single",
value: "is",
},
"is any of": {
key: "is any of",
single: "is",
target: "multiple",
value: "is any of",
},
"is none of": {
key: "is none of",
single: "is not",
target: "multiple",
value: "is none of",
},
"is not": {
key: "is not",
multiple: "is none of",
target: "single",
value: "is not",
},
} as const satisfies FilterDetails<"option">;
export const multiOptionFilterOperators = {
exclude: {
key: "excludes",
multiple: "exclude if any of",
target: "single",
value: "exclude",
},
"exclude if all": {
key: "excludes all of",
single: "exclude",
target: "multiple",
value: "exclude if all",
},
"exclude if any of": {
key: "excludes if any of",
single: "exclude",
target: "multiple",
value: "exclude if any of",
},
include: {
key: "includes",
multiple: "include any of",
target: "single",
value: "include",
},
"include all of": {
key: "includes all of",
single: "include",
target: "multiple",
value: "include all of",
},
"include any of": {
key: "includes any of",
single: "include",
target: "multiple",
value: "include any of",
},
} as const satisfies FilterDetails<"multiOption">;
export const dateFilterOperators = {
is: {
key: "is",
multiple: "is between",
target: "single",
value: "is",
},
"is after": {
key: "is after",
multiple: "is between",
target: "single",
value: "is after",
},
"is before": {
key: "is before",
multiple: "is between",
target: "single",
value: "is before",
},
"is between": {
key: "is between",
single: "is",
target: "multiple",
value: "is between",
},
"is not": {
key: "is not",
multiple: "is not between",
target: "single",
value: "is not",
},
"is not between": {
key: "is not between",
single: "is not",
target: "multiple",
value: "is not between",
},
"is on or after": {
key: "is on or after",
multiple: "is between",
target: "single",
value: "is on or after",
},
"is on or before": {
key: "is on or before",
multiple: "is between",
target: "single",
value: "is on or before",
},
} as const satisfies FilterDetails<"date">;
export const textFilterOperators = {
contains: {
key: "contains",
target: "single",
value: "contains",
},
"does not contain": {
key: "does not contain",
target: "single",
value: "does not contain",
},
} as const satisfies FilterDetails<"text">;
export const numberFilterOperators = {
is: {
key: "is",
multiple: "is between",
target: "single",
value: "is",
},
"is between": {
key: "is between",
single: "is",
target: "multiple",
value: "is between",
},
"is greater than": {
key: "greater than",
multiple: "is between",
target: "single",
value: "is greater than",
},
"is greater than or equal to": {
key: "greater than or equal",
multiple: "is between",
target: "single",
value: "is greater than or equal to",
},
"is less than": {
key: "less than",
multiple: "is between",
target: "single",
value: "is less than",
},
"is less than or equal to": {
key: "less than or equal",
multiple: "is between",
target: "single",
value: "is less than or equal to",
},
"is not": {
key: "is not",
multiple: "is not between",
target: "single",
value: "is not",
},
"is not between": {
key: "is not between",
single: "is not",
target: "multiple",
value: "is not between",
},
} as const satisfies FilterDetails<"number">;
export const booleanFilterOperators = {
is: {
key: "is",
multiple: "is not",
target: "single",
value: "is",
},
"is not": {
key: "is not",
multiple: "is",
target: "single",
value: "is not",
},
} as const satisfies FilterDetails<"boolean">;
export const filterTypeOperatorDetails: FilterTypeOperatorDetails = {
boolean: booleanFilterOperators,
date: dateFilterOperators,
multiOption: multiOptionFilterOperators,
number: numberFilterOperators,
option: optionFilterOperators,
text: textFilterOperators,
};
// Preserve negation when the value count switches between one and many.
export const determineNewOperator = <TType extends ColumnDataType>(
type: TType,
oldVals: FilterValues<TType>,
nextVals: FilterValues<TType>,
currentOperator: FilterOperators[TType],
): FilterOperators[TType] => {
const a = oldVals.length;
const b = nextVals.length;
if (a === b || (a >= 2 && b >= 2) || (a <= 1 && b <= 1)) {
return currentOperator;
}
const opDetails = filterTypeOperatorDetails[type][currentOperator];
if (a < b && b >= 2) {
return opDetails.multiple ?? currentOperator;
}
if (a > b && b <= 1) {
return opDetails.single ?? currentOperator;
}
return currentOperator;
};
/filter-package/core/types.ts
import type { ElementType, ReactElement, Dispatch, SetStateAction } from "react";
export interface ColumnOption {
label: string;
value: string;
icon?: ReactElement | ElementType;
count?: number;
}
export interface ColumnOptionExtended extends ColumnOption {
selected?: boolean;
}
export type ColumnDataType = "text" | "number" | "date" | "boolean" | "option" | "multiOption";
export type OptionBasedColumnDataType = "option" | "multiOption";
export type FilterStrategy = "client" | "server";
interface ColumnBase {
id: string;
displayName: string;
icon?: ReactElement | ElementType;
hidden?: boolean;
}
interface ColumnValues {
text: string[];
number: number[];
date: Date[];
boolean: boolean[];
option: string[];
multiOption: string[];
}
interface ColumnDetails {
text: object;
number: { min: number; max: number };
date: object;
boolean: { toggledStateName?: string };
option: { options: ColumnOption[] };
multiOption: { options: ColumnOption[] };
}
export type Column<K extends ColumnDataType = ColumnDataType> = {
[Type in K]: ColumnBase & { type: Type; values: ColumnValues[Type] } & ColumnDetails[Type];
}[K];
type OptionValue = string | ColumnOption;
interface ColumnInputs<Row> {
text: { accessor: (row: Row) => string | null | undefined };
number: { accessor: (row: Row) => number | null | undefined; min?: number; max?: number };
date: { accessor: (row: Row) => Date | null | undefined };
boolean: { accessor: (row: Row) => boolean | null | undefined; toggledStateName?: string };
option: { accessor: (row: Row) => OptionValue | null | undefined; options?: ColumnOption[] };
multiOption: {
accessor: (row: Row) => readonly OptionValue[] | null | undefined;
options?: ColumnOption[];
};
}
export type ColumnConfig<Row> = {
[Type in ColumnDataType]: ColumnBase & { type: Type } & ColumnInputs<Row>[Type];
}[ColumnDataType];
export type TextFilterOperator = "contains" | "does not contain";
export type NumberFilterOperator =
| "is"
| "is not"
| "is less than"
| "is greater than or equal to"
| "is greater than"
| "is less than or equal to"
| "is between"
| "is not between";
export type DateFilterOperator =
| "is"
| "is not"
| "is before"
| "is on or after"
| "is after"
| "is on or before"
| "is between"
| "is not between";
export type BooleanFilterOperator = "is" | "is not";
export type OptionFilterOperator = "is" | "is not" | "is any of" | "is none of";
export type MultiOptionFilterOperator =
| "include"
| "exclude"
| "include any of"
| "include all of"
| "exclude if any of"
| "exclude if all";
export interface FilterOperators {
text: TextFilterOperator;
number: NumberFilterOperator;
date: DateFilterOperator;
boolean: BooleanFilterOperator;
option: OptionFilterOperator;
multiOption: MultiOptionFilterOperator;
}
export type FilterValues<K extends ColumnDataType> = ColumnValues[K];
export type FilterModel<K extends ColumnDataType = ColumnDataType> = {
[Type in K]: {
columnId: string;
type: Type;
operator: FilterOperators[Type];
values: FilterValues<Type>;
};
}[K];
export type FiltersState = FilterModel[];
export type FilterValueUpdate = {
[Type in ColumnDataType]: Pick<FilterModel<Type>, "columnId" | "type" | "values">;
}[ColumnDataType];
export type FilterOperatorUpdate = {
[Type in ColumnDataType]: Pick<FilterModel<Type>, "columnId" | "type" | "operator">;
}[ColumnDataType];
export interface DataTableFilterActions {
setFilterValue: (update: FilterValueUpdate) => void;
setFilterOperator: (update: FilterOperatorUpdate) => void;
addFilterValue: (column: Column<OptionBasedColumnDataType>, values: string[]) => void;
removeFilterValue: (column: Column<OptionBasedColumnDataType>, values: string[]) => void;
removeFilter: (columnId: string) => void;
removeAllFilters: () => void;
}
export type FilterOperatorTarget = "single" | "multiple";
export interface FilterOperatorDetails<Operator, K extends ColumnDataType> {
key: string;
value: Operator;
target: FilterOperatorTarget;
multiple?: FilterOperators[K];
single?: FilterOperators[K];
}
export type FilterDetails<K extends ColumnDataType> = {
[Operator in FilterOperators[K]]: FilterOperatorDetails<Operator, K>;
};
export type FilterTypeOperatorDetails = { [K in ColumnDataType]: FilterDetails<K> };
export type DataTableFiltersOptions<Row> = {
data: Row[];
columnsConfig: readonly ColumnConfig<Row>[];
strategy?: FilterStrategy;
entityName?: string;
} & (
| {
filters: FiltersState;
onFiltersChange: Dispatch<SetStateAction<FiltersState>>;
defaultFilters?: never;
}
| { filters?: never; onFiltersChange?: never; defaultFilters?: FiltersState }
);
export type FilterBinding = {
[K in ColumnDataType]: { type: K; column: Column<K>; filter?: FilterModel<K> };
}[ColumnDataType];
/filter-package/hooks/use-data-table-filters.ts
"use client";
import { useMemo, useState } from "react";
import type { DataTableFilterActions, DataTableFiltersOptions, FiltersState } from "../core/types";
import { createColumns } from "../core/columns/column-factory";
import { setFilterOperator, setFilterValue, toggleFilterValues } from "../core/filters";
export const useDataTableFilters = <Row>({
data,
columnsConfig,
filters: controlledFilters,
onFiltersChange,
defaultFilters = [],
strategy = "client",
entityName,
}: DataTableFiltersOptions<Row>) => {
const [internalFilters, setInternalFilters] = useState<FiltersState>(defaultFilters);
const filters = controlledFilters ?? internalFilters;
const setFilters = onFiltersChange ?? setInternalFilters;
const columns = useMemo(
() => createColumns(data, columnsConfig, strategy),
[data, columnsConfig, strategy],
);
const actions = useMemo<DataTableFilterActions>(
() => ({
addFilterValue: (column, values) =>
setFilters((current) => toggleFilterValues(current, column, values, true)),
removeAllFilters: () => setFilters([]),
removeFilter: (columnId) =>
setFilters((current) => current.filter((filter) => filter.columnId !== columnId)),
removeFilterValue: (column, values) =>
setFilters((current) => toggleFilterValues(current, column, values, false)),
setFilterOperator: (update) => setFilters((current) => setFilterOperator(current, update)),
setFilterValue: (update) => setFilters((current) => setFilterValue(current, update)),
}),
[setFilters],
);
return { actions, columns, entityName, filters, strategy };
};
/filter-package/index.ts
export * from "./core/types";
export * from "./core/operators";
export { useDataTableFilters } from "./hooks/use-data-table-filters";
export { getColumn, bindFilter, createNumberRange } from "./lib/helpers";
/filter-package/lib/helpers.ts
import type { Column, FilterModel, FilterBinding } from "../core/types";
export const getColumn = (columns: Column[], id: string) => {
const column = columns.find((candidate) => candidate.id === id);
if (!column) {
throw new Error(`Column with id ${id} not found`);
}
return column;
};
export const createNumberRange = (values: number[]): number[] => {
const [a = 0, b = 0] = values;
return a < b ? [a, b] : [b, a];
};
export const bindFilter = (column: Column, filter?: FilterModel): FilterBinding => {
switch (column.type) {
case "text": {
return { column, filter: filter?.type === "text" ? filter : undefined, type: "text" };
}
case "number": {
return { column, filter: filter?.type === "number" ? filter : undefined, type: "number" };
}
case "date": {
return { column, filter: filter?.type === "date" ? filter : undefined, type: "date" };
}
case "boolean": {
return { column, filter: filter?.type === "boolean" ? filter : undefined, type: "boolean" };
}
case "option": {
return { column, filter: filter?.type === "option" ? filter : undefined, type: "option" };
}
case "multiOption": {
return {
column,
filter: filter?.type === "multiOption" ? filter : undefined,
type: "multiOption",
};
}
default: {
throw new Error(`Unsupported column type: ${String(column satisfies never)}`);
}
}
};
/filters.tsx
import { cn } from "cn";
import {
BuildingIcon,
CalendarArrowUpIcon,
DollarSignIcon,
TargetIcon,
UserCheckIcon,
UserIcon,
} from "lucide-react";
import type { Person, PersonDepartment, PersonSkill } from "./types";
import type { ColumnConfig } from "./filter-package";
export const PERSON_DEPARTMENTS: PersonDepartment[] = [
{ color: "blue", id: "engineering", name: "Engineering" },
{ color: "purple", id: "design", name: "Design" },
{ color: "green", id: "product", name: "Product" },
{ color: "orange", id: "marketing", name: "Marketing" },
{ color: "red", id: "sales", name: "Sales" },
{ color: "pink", id: "hr", name: "Human Resources" },
{ color: "teal", id: "finance", name: "Finance" },
{ color: "indigo", id: "operations", name: "Operations" },
{ color: "emerald", id: "customer-success", name: "Customer Success" },
{ color: "cyan", id: "data", name: "Data & Analytics" },
{ color: "rose", id: "security", name: "Security" },
{ color: "violet", id: "legal", name: "Legal" },
] as const;
export const PERSON_SKILLS = [
{ color: "yellow", id: "javascript", level: "expert", name: "JavaScript" },
{ color: "blue", id: "typescript", level: "advanced", name: "TypeScript" },
{ color: "cyan", id: "react", level: "expert", name: "React" },
{ color: "green", id: "nodejs", level: "advanced", name: "Node.js" },
{ color: "yellow", id: "python", level: "intermediate", name: "Python" },
{ color: "red", id: "java", level: "advanced", name: "Java" },
{ color: "blue", id: "go", level: "beginner", name: "Go" },
{ color: "orange", id: "rust", level: "intermediate", name: "Rust" },
{ color: "indigo", id: "sql", level: "advanced", name: "SQL" },
{ color: "orange", id: "aws", level: "intermediate", name: "AWS" },
{ color: "blue", id: "docker", level: "advanced", name: "Docker" },
{
color: "blue",
id: "kubernetes",
level: "intermediate",
name: "Kubernetes",
},
{ color: "purple", id: "figma", level: "expert", name: "Figma" },
{ color: "yellow", id: "sketch", level: "advanced", name: "Sketch" },
{ color: "blue", id: "photoshop", level: "intermediate", name: "Photoshop" },
{
color: "orange",
id: "illustrator",
level: "beginner",
name: "Illustrator",
},
{ color: "green", id: "leadership", level: "expert", name: "Leadership" },
{
color: "pink",
id: "communication",
level: "advanced",
name: "Communication",
},
{
color: "teal",
id: "project-management",
level: "intermediate",
name: "Project Management",
},
{
color: "cyan",
id: "data-analysis",
level: "advanced",
name: "Data Analysis",
},
{
color: "purple",
id: "machine-learning",
level: "beginner",
name: "Machine Learning",
},
{ color: "rose", id: "ui-design", level: "expert", name: "UI Design" },
{
color: "indigo",
id: "ux-research",
level: "advanced",
name: "UX Research",
},
{
color: "emerald",
id: "content-strategy",
level: "intermediate",
name: "Content Strategy",
},
{ color: "lime", id: "seo", level: "beginner", name: "SEO" },
{
color: "pink",
id: "social-media",
level: "advanced",
name: "Social Media",
},
{
color: "blue",
id: "email-marketing",
level: "intermediate",
name: "Email Marketing",
},
{ color: "blue", id: "salesforce", level: "expert", name: "Salesforce" },
{ color: "orange", id: "hubspot", level: "advanced", name: "HubSpot" },
{
color: "green",
id: "recruiting",
level: "intermediate",
name: "Recruiting",
},
{
color: "purple",
id: "training",
level: "beginner",
name: "Training & Development",
},
{ color: "teal", id: "accounting", level: "advanced", name: "Accounting" },
{
color: "indigo",
id: "budgeting",
level: "intermediate",
name: "Budgeting",
},
{ color: "red", id: "compliance", level: "expert", name: "Compliance" },
{
color: "orange",
id: "risk-management",
level: "advanced",
name: "Risk Management",
},
{
color: "green",
id: "customer-support",
level: "intermediate",
name: "Customer Support",
},
{
color: "blue",
id: "technical-writing",
level: "beginner",
name: "Technical Writing",
},
{
color: "purple",
id: "public-speaking",
level: "advanced",
name: "Public Speaking",
},
{
color: "pink",
id: "negotiation",
level: "intermediate",
name: "Negotiation",
},
{
color: "emerald",
id: "strategic-planning",
level: "expert",
name: "Strategic Planning",
},
{
color: "cyan",
id: "change-management",
level: "advanced",
name: "Change Management",
},
{
color: "lime",
id: "team-building",
level: "intermediate",
name: "Team Building",
},
{ color: "rose", id: "mentoring", level: "beginner", name: "Mentoring" },
{
color: "violet",
id: "conflict-resolution",
level: "advanced",
name: "Conflict Resolution",
},
{
color: "amber",
id: "time-management",
level: "expert",
name: "Time Management",
},
{
color: "sky",
id: "problem-solving",
level: "expert",
name: "Problem Solving",
},
{
color: "indigo",
id: "critical-thinking",
level: "advanced",
name: "Critical Thinking",
},
{
color: "fuchsia",
id: "creativity",
level: "intermediate",
name: "Creativity",
},
{ color: "teal", id: "adaptability", level: "expert", name: "Adaptability" },
{
color: "pink",
id: "emotional-intelligence",
level: "advanced",
name: "Emotional Intelligence",
},
{
color: "green",
id: "cross-cultural",
level: "intermediate",
name: "Cross-cultural Communication",
},
{
color: "blue",
id: "multilingual",
level: "beginner",
name: "Multilingual",
},
] as const satisfies readonly PersonSkill[];
export const LABEL_STYLES_BG = {
amber: "bg-amber-500",
blue: "bg-blue-500",
cyan: "bg-cyan-500",
emerald: "bg-emerald-500",
fuchsia: "bg-fuchsia-500",
green: "bg-green-500",
indigo: "bg-indigo-500",
lime: "bg-lime-500",
neutral: "bg-neutral-500",
orange: "bg-orange-500",
pink: "bg-pink-500",
purple: "bg-purple-500",
red: "bg-red-500",
rose: "bg-rose-500",
sky: "bg-sky-500",
teal: "bg-teal-500",
violet: "bg-violet-500",
yellow: "bg-yellow-500",
} as const;
export type TW_COLOR = keyof typeof LABEL_STYLES_BG;
export const columnsConfig: readonly ColumnConfig<Person>[] = [
{
accessor: (row) => `${row.firstName} ${row.lastName}`,
displayName: "Name",
icon: UserIcon,
id: "name",
type: "text",
},
{
accessor: (row) => row.department.id,
displayName: "Department",
icon: BuildingIcon,
id: "department",
options: PERSON_DEPARTMENTS.map((department) => ({
icon: BuildingIcon,
label: department.name,
value: department.id,
})),
type: "option",
},
{
accessor: (row) => row.salary,
displayName: "Salary",
icon: DollarSignIcon,
id: "salary",
max: 300_000,
min: 20_000,
type: "number",
},
{
accessor: (row) => row.skills?.map((skill) => skill.id),
displayName: "Skills",
icon: TargetIcon,
id: "skills",
options: PERSON_SKILLS.map((skill) => ({
icon: <div className={cn("size-2.5 rounded-full", LABEL_STYLES_BG[skill.color])} />,
label: skill.name,
value: skill.id,
})),
type: "multiOption",
},
{
accessor: (row) => row.startDate,
displayName: "Start Date",
icon: CalendarArrowUpIcon,
id: "startDate",
type: "date",
},
{
accessor: (row) => row.isActive,
displayName: "Active employees",
icon: UserCheckIcon,
id: "isActive",
toggledStateName: "active",
type: "boolean",
},
];
/preview.tsx
"use client";
import { useState } from "react";
import type { Person } from "./types";
import type { FiltersState } from "./filter-package/core/types";
import { DataTableFilter } from "./_components/data-table-filter";
import { useDataTableFilters } from "./filter-package";
import { columnsConfig } from "./filters";
const people: Person[] = [];
const Preview = () => {
const [filtersState, setFiltersState] = useState<FiltersState>([]);
const { columns, filters, actions, strategy, entityName } = useDataTableFilters({
columnsConfig,
data: people,
entityName: "Person",
filters: filtersState,
onFiltersChange: setFiltersState,
strategy: "client",
});
return (
<div className="flex h-screen items-center bg-stone-100 p-10 dark:bg-stone-900">
<DataTableFilter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
entityName={entityName}
/>
</div>
);
};
export default Preview;
/styles.d.ts
declare module "*.css" {}
/types.ts
import type { LucideIcon } from "lucide-react";
export interface Person {
id: string;
firstName: string;
lastName: string;
email: string;
phone?: string;
age: number;
gender: PersonGender;
department: PersonDepartment;
position: string;
salary: number;
startDate: Date;
endDate?: Date;
isActive: boolean;
skills?: PersonSkill[];
location: PersonLocation;
manager?: Person;
isRemote: boolean;
experience: number;
education: PersonEducation;
performance: PersonPerformance;
}
export interface PersonGender {
id: "male" | "female" | "non-binary" | "other";
name: string;
order: number;
icon: LucideIcon;
}
export interface PersonDepartment {
id: string;
name: string;
color: string;
}
export interface PersonSkill {
id: string;
name: string;
level: "beginner" | "intermediate" | "advanced" | "expert";
color: string;
}
export interface PersonLocation {
city: string;
state: string;
country: string;
timezone: string;
}
export interface PersonEducation {
degree: string;
field: string;
institution: string;
graduationYear: number;
}
export interface PersonPerformance {
// 1-5
rating: number;
lastReview: Date;
goals: string[];
}