data-table-filters
blockdata-table-filter-commandsource: ok · hit
Data Table Filter Command Palette
Command palette for power-user filter input with history and keyboard shortcuts.
npx shadcn add "https://data-table.openstatus.dev/r/data-table-filter-command.json"free
Where it goes
data-tablekanban
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightdate-fns@^4.1.0lucide-react@^0.469.0
Registry dependencies
https://data-table.openstatus.dev/r/data-table.jsonbuttoncommandkbdseparator
Files2 · source cached 10/9/2026, 9:54:30 PM
src/components/data-table/data-table-filter-command/index.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { buttonVariants } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Kbd } from "@/components/ui/kbd";
import { Separator } from "@/components/ui/separator";
import { useHotKey } from "@/hooks/use-hot-key";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { getCommandHistoryKey } from "@/lib/constants/local-storage";
import { formatCompactNumber } from "@/lib/format";
import type { SchemaDefinition } from "@/lib/store/schema/types";
import { boxRadiusClassName } from "@/lib/style";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { formatDistanceToNow } from "date-fns";
import { LoaderCircle, Search, X } from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import type { DataTableFilterField } from "../types";
import {
columnFiltersParserFromSchema,
getFieldOptions,
getFilterValue,
getWordByCaretPosition,
replaceInputByFieldType,
} from "./utils";
interface DataTableFilterCommandProps {
// Schema definition for parsing/serializing filter values (BYOS)
schema: SchemaDefinition;
// Unique ID for this table (used to namespace localStorage)
tableId?: string;
}
/**
* Box chrome — radius, border, background, focus ring — comes from the
* project's own `buttonVariants` rather than from hardcoded utilities: every
* shadcn style keeps its box rhythm in that string, so a `rounded-lg border`
* that looks right on the default style is wrong on `nova`, which rounds and
* pads its boxes with other tokens. The closed trigger and the open command
* box share the constant, which is what keeps the two states identical.
*/
const boxClassName = cn(
buttonVariants({ variant: "outline" }),
"h-11 w-full justify-start gap-2 px-3 text-sm font-normal",
);
export function DataTableFilterCommand({
schema,
tableId = "default",
}: DataTableFilterCommandProps) {
const {
table,
isLoading,
filterFields: _filterFields,
getFacetedUniqueValues,
} = useDataTable();
const columnFilters = table.state.columnFilters;
const inputRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState<boolean>(false);
const [currentWord, setCurrentWord] = useState<string>("");
// Guard to prevent effect cycle when serializing
const isSerializingRef = useRef(false);
const filterFields = useMemo(
() => _filterFields?.filter((i) => !i.commandDisabled),
[_filterFields],
);
const columnParser = useMemo(
() => columnFiltersParserFromSchema({ schema, filterFields }),
[schema, filterFields],
);
const [inputValue, setInputValue] = useState<string>(
columnParser.serialize(columnFilters),
);
const [lastSearches, setLastSearches] = useLocalStorage<
{
search: string;
timestamp: number;
}[]
>(getCommandHistoryKey(tableId), []);
useEffect(() => {
// Skip if this update came from serialization (prevents infinite loop)
if (isSerializingRef.current) {
isSerializingRef.current = false;
return;
}
// TODO: we could check for ARRAY_DELIMITER or SLIDER_DELIMITER to auto-set filter when typing
if (currentWord !== "" && open) return;
// reset
if (currentWord !== "" && !open) setCurrentWord("");
// avoid recursion
if (inputValue.trim() === "" && !open) return;
const searchParams = columnParser.parse(inputValue);
const currentFilters = table.state.columnFilters;
const currentEnabledFilters = currentFilters.filter((filter) => {
const field = _filterFields?.find((field) => field.value === filter.id);
return !field?.commandDisabled;
});
const currentDisabledFilters = currentFilters.filter((filter) => {
const field = _filterFields?.find((field) => field.value === filter.id);
return field?.commandDisabled;
});
const commandDisabledFilterKeys = currentDisabledFilters.reduce(
(prev, curr) => {
prev[curr.id] = curr.value;
return prev;
},
{} as Record<string, unknown>,
);
for (const key of Object.keys(searchParams)) {
const value = searchParams[key as keyof typeof searchParams];
table.getColumn(key)?.setFilterValue(value);
}
const currentFiltersToReset = currentEnabledFilters.filter((filter) => {
return !(filter.id in searchParams);
});
for (const filter of currentFiltersToReset) {
table.getColumn(filter.id)?.setFilterValue(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue, open, currentWord]);
useEffect(() => {
// REMINDER: only update the input value if the command is closed (avoids jumps while open)
if (!open) {
// Set flag to prevent the parse effect from running after serialization
isSerializingRef.current = true;
setInputValue(columnParser.serialize(columnFilters));
}
}, [columnFilters, filterFields, open]);
useHotKey(() => setOpen((open) => !open), "k");
useEffect(() => {
if (open) {
inputRef?.current?.focus();
}
}, [open]);
return (
<div>
{/* REMINDER: the closed trigger and the open command box draw the same
`boxClassName`, so toggling the command can never change the box's
height, radius or padding, and the dropdown borrows its radius from
the same place. Keep the height on the boxes, not on their children —
a taller child (kbd, spinner, browser extension node) must not grow
one state only. */}
<button
type="button"
className={cn(
boxClassName,
"text-muted-foreground",
open ? "hidden" : "visible",
)}
onClick={() => setOpen(true)}
>
{isLoading ? (
<LoaderCircle className="size-4 shrink-0 animate-spin opacity-50" />
) : (
<Search className="size-4 shrink-0 opacity-50" />
)}
<span className="w-full max-w-sm truncate text-left md:max-w-xl lg:max-w-4xl xl:max-w-5xl">
{inputValue.trim() ? (
<span className="text-foreground">{inputValue}</span>
) : (
<span>Search data table...</span>
)}
</span>
<Kbd className="text-muted-foreground ml-auto">
<span className="mr-1">⌘</span>
<span>K</span>
</Kbd>
</button>
<Command
className={cn(
// The root only lays the box and the dropdown out: every visible
// edge below is drawn by them, so strip the surface a style may put
// here (nova ships `bg-popover p-1`, whose padding inset the
// dropdown by 4px and whose radius disagreed with the trigger). Its
// size is left alone: styles use `size-full`, which `h-*`/`w-*` do
// not replace in `cn`, and a full height resolves to auto here.
"overflow-visible border-none bg-transparent p-0 shadow-none",
open ? "visible" : "hidden",
)}
filter={(value, search, keywords) =>
getFilterValue({ value, search, keywords, currentWord })
}
// loop
>
<div
className={cn(
boxClassName,
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
)}
>
<Search className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") inputRef?.current?.blur();
}}
onBlur={() => {
setOpen(false);
// FIXME: doesnt reflect the jumps
// FIXME: will save non-existing searches
// TODO: extract into function
const search = inputValue.trim();
if (!search) return;
const timestamp = Date.now();
const searchIndex = lastSearches.findIndex(
(item) => item.search === search,
);
if (searchIndex !== -1) {
lastSearches[searchIndex].timestamp = timestamp;
setLastSearches(lastSearches);
return;
}
setLastSearches([...lastSearches, { search, timestamp }]);
return;
}}
onInput={(e: React.FormEvent<HTMLInputElement>) => {
const caretPosition = e.currentTarget?.selectionStart || -1;
const value = e.currentTarget?.value || "";
const word = getWordByCaretPosition({ value, caretPosition });
setCurrentWord(word);
}}
placeholder="Search data table..."
className="text-foreground placeholder:text-muted-foreground flex h-full w-full bg-transparent text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="relative">
<div
className={cn(
"bg-popover text-popover-foreground animate-in absolute top-2 z-10 w-full overflow-hidden border shadow-md outline-hidden",
boxRadiusClassName,
)}
>
{/* default height is 300px but in case of more, we'd like to tease the user */}
<CommandList className="max-h-[310px]">
<CommandGroup heading="Filter">
{filterFields.map((field) => {
if (typeof field.value !== "string") return null;
if (inputValue.includes(`${field.value}:`)) return null;
// TBD: should we handle this in the component?
return (
<CommandItem
key={field.value}
value={field.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue((prev) => {
if (currentWord.trim() === "") {
const input = `${prev}${value}`;
return `${input}:`;
}
// lots of cheat
const isStarting = currentWord === prev;
const prefix = isStarting ? "" : " ";
const input = prev.replace(
`${prefix}${currentWord}`,
`${prefix}${value}`,
);
return `${input}:`;
});
setCurrentWord(`${value}:`);
}}
className="group"
>
{field.value}
<CommandItemSuggestions field={field} />
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Query">
{filterFields?.map((field) => {
if (typeof field.value !== "string") return null;
if (!currentWord.includes(`${field.value}:`)) return null;
const column = table.getColumn(field.value);
const facetedValue =
getFacetedUniqueValues?.(table, field.value) ||
column?.getFacetedUniqueValues();
const options = getFieldOptions({ field, facetedValue });
return options.map((optionValue) => {
return (
<CommandItem
key={`${String(field.value)}:${optionValue}`}
value={`${String(field.value)}:${optionValue}`}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue((prev) =>
replaceInputByFieldType({
prev,
currentWord,
optionValue,
value,
field,
}),
);
setCurrentWord("");
}}
>
{`${optionValue}`}
{/* data-slot="command-shortcut" hides the check icon the shadcn nova
presets append to every item - a second ml-auto child would
otherwise split the row and leave the count mid-way */}
{facetedValue?.has(optionValue) ? (
<span
data-slot="command-shortcut"
className="text-muted-foreground ml-auto font-mono"
>
{formatCompactNumber(
facetedValue.get(optionValue) || 0,
)}
</span>
) : null}
</CommandItem>
);
});
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Suggestions">
{lastSearches
?.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 5)
.map((item) => {
return (
<CommandItem
key={`suggestion:${item.search}`}
value={`suggestion:${item.search}`}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
const search = value.replace("suggestion:", "");
setInputValue(`${search} `);
setCurrentWord("");
}}
className="group"
>
{item.search}
<span
data-slot="command-shortcut"
className="text-muted-foreground/80 ml-auto truncate group-aria-selected:block"
>
{formatDistanceToNow(item.timestamp, {
addSuffix: true,
})}
</span>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// TODO: extract into function
setLastSearches(
lastSearches.filter(
(i) => i.search !== item.search,
),
);
}}
className={cn(
"hover:bg-background ml-1 hidden p-0.5 group-aria-selected:block",
boxRadiusClassName,
)}
>
<X className="h-4 w-4" />
</button>
</CommandItem>
);
})}
</CommandGroup>
<CommandEmpty>No results found.</CommandEmpty>
</CommandList>
<div
className="bg-accent/50 text-accent-foreground flex flex-wrap justify-between gap-3 border-t px-2 py-1.5 text-sm"
cmdk-footer=""
>
<div className="flex flex-wrap gap-3">
<span>
Use <Kbd>↑</Kbd> <Kbd>↓</Kbd> to navigate
</span>
<span>
<Kbd>Enter</Kbd> to query
</span>
<span>
<Kbd>Esc</Kbd> to close
</span>
<Separator
orientation="vertical"
className="my-auto data-[orientation=vertical]:h-3"
/>
<span>
Union: <Kbd>regions:a,b</Kbd>
</span>
<span>
Range: <Kbd>p95:59-340</Kbd>
</span>
<span>
Spaces: <Kbd>name:"a b"</Kbd>
</span>
</div>
{lastSearches.length ? (
<button
type="button"
className="text-muted-foreground hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => setLastSearches([])}
>
Clear suggestions
</button>
) : null}
</div>
</div>
</div>
</Command>
</div>
);
}
// function CommandItemType<TData>
function CommandItemSuggestions<TData>({
field,
}: {
field: DataTableFilterField<TData>;
}) {
const { table, getFacetedMinMaxValues, getFacetedUniqueValues } =
useDataTable();
const value = field.value as string;
switch (field.type) {
case "checkbox": {
return (
<span className="text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block">
{getFacetedUniqueValues
? Array.from(getFacetedUniqueValues(table, value)?.keys() || [])
.map((value) => `[${value}]`)
.join(" ")
: field.options?.map(({ value }) => `[${value}]`).join(" ")}
</span>
);
}
case "slider": {
const [min, max] = getFacetedMinMaxValues?.(table, value) || [
field.min,
field.max,
];
return (
<span className="text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block">
[{min} - {max}]
</span>
);
}
case "input": {
return (
<span className="text-muted-foreground/80 ml-1 hidden truncate group-aria-selected:block">
[{`${String(field.value)}`} input]
</span>
);
}
default: {
return null;
}
}
}
src/components/data-table/data-table-filter-command/utils.ts
import {
ARRAY_DELIMITER,
RANGE_DELIMITER,
SLIDER_DELIMITER,
} from "@/lib/delimiters";
import { isArrayOfDates } from "@/lib/is-array";
import type {
FieldBuilder,
SchemaDefinition,
} from "@/lib/store/schema/types";
import type { ColumnFiltersState } from "@tanstack/react-table";
import type { DataTableFilterField } from "../types";
/**
* Extracts the word from the given string at the specified caret position.
*/
export function getWordByCaretPosition({
value,
caretPosition,
}: {
value: string;
caretPosition: number;
}) {
let start = caretPosition;
let end = caretPosition;
while (start > 0 && value[start - 1] !== " ") start--;
while (end < value.length && value[end] !== " ") end++;
const word = value.substring(start, end);
return word;
}
/**
* Quote a value if it contains spaces
*/
function quoteIfNeeded(val: string | number | boolean | undefined): string {
const str = `${val}`;
if (str.includes(" ")) {
return `"${str}"`;
}
return str;
}
export function replaceInputByFieldType<TData>({
prev,
currentWord,
optionValue,
value,
field,
}: {
prev: string;
currentWord: string;
optionValue?: string | number | boolean | undefined; // FIXME: use DataTableFilterField<TData>["options"][number];
value: string;
field: DataTableFilterField<TData>;
}) {
switch (field.type) {
case "checkbox": {
if (currentWord.includes(ARRAY_DELIMITER)) {
const words = currentWord.split(ARRAY_DELIMITER);
words[words.length - 1] = quoteIfNeeded(optionValue);
const input = prev.replace(currentWord, words.join(ARRAY_DELIMITER));
return `${input.trim()} `;
}
break;
}
case "slider": {
if (currentWord.includes(SLIDER_DELIMITER)) {
const words = currentWord.split(SLIDER_DELIMITER);
words[words.length - 1] = `${optionValue}`;
const input = prev.replace(currentWord, words.join(SLIDER_DELIMITER));
return `${input.trim()} `;
}
break;
}
case "timerange": {
if (currentWord.includes(RANGE_DELIMITER)) {
const words = currentWord.split(RANGE_DELIMITER);
words[words.length - 1] = `${optionValue}`;
const input = prev.replace(currentWord, words.join(RANGE_DELIMITER));
return `${input.trim()} `;
}
break;
}
}
// Default: set a fresh filter value, quoting if it contains spaces
const quotedValue = quoteIfNeeded(optionValue) || value;
const input = prev.replace(
currentWord,
`${String(field.value)}:${quotedValue}`,
);
return `${input.trim()} `;
}
export function getFieldOptions<TData>({
field,
facetedValue,
}: {
field: DataTableFilterField<TData>;
facetedValue?: Map<unknown, number>;
}) {
switch (field.type) {
case "slider": {
if (field.options?.length) {
return field.options
.map(({ value }) => value)
.sort((a, b) => Number(a) - Number(b))
.filter(notEmpty);
}
// Use only the values that actually exist in the data to avoid
// generating thousands of intermediate integers (e.g. salary 58k-155k).
if (facetedValue?.size) {
return Array.from(facetedValue.keys())
.map(Number)
.filter((n) => !isNaN(n))
.sort((a, b) => a - b);
}
return [];
}
default: {
return field.options?.map(({ value }) => value).filter(notEmpty) || [];
}
}
}
export function getFilterValue({
value,
search,
currentWord,
}: {
value: string;
search: string;
keywords?: string[] | undefined;
currentWord: string;
}): number {
/**
* @example value "suggestion:public:true regions,ams,gru,fra"
*/
if (value.startsWith("suggestion:")) {
const rawValue = value.toLowerCase().replace("suggestion:", "");
if (rawValue.includes(search)) return 1;
return 0;
}
/** */
if (value.toLowerCase().includes(currentWord.toLowerCase())) return 1;
/**
* @example checkbox [filter, query] = ["regions", "ams,gru,fra"]
* @example slider [filter, query] = ["p95", "0-3000"]
* @example input [filter, query] = ["name", "api"]
*/
// Split on the FIRST colon only — values legitimately contain colons (urls,
// timestamps), and `split(":")` would truncate the query at the second one.
const lowerCurrentWord = currentWord.toLowerCase();
const separatorIndex = lowerCurrentWord.indexOf(":");
const filter =
separatorIndex === -1
? lowerCurrentWord
: lowerCurrentWord.slice(0, separatorIndex);
const query =
separatorIndex === -1
? undefined
: lowerCurrentWord.slice(separatorIndex + 1);
if (query && value.startsWith(`${filter}:`)) {
if (query.includes(ARRAY_DELIMITER)) {
/**
* array of n elements
* @example queries = ["ams", "gru", "fra"]
*/
const queries = query.split(ARRAY_DELIMITER);
const rawValue = value.toLowerCase().replace(`${filter}:`, "");
if (
queries.some((item, i) => item === rawValue && i !== queries.length - 1)
)
return 0;
if (queries.some((item) => rawValue.includes(item))) return 1;
}
if (query.includes(SLIDER_DELIMITER)) {
/**
* range between 2 elements
* @example queries = ["0", "3000"]
*/
const queries = query.split(SLIDER_DELIMITER);
const rawValue = value.toLowerCase().replace(`${filter}:`, "");
const rawValueAsNumber = Number.parseInt(rawValue);
const queryAsNumber = Number.parseInt(queries[0]);
if (queryAsNumber < rawValueAsNumber) {
if (rawValue.includes(queries[1])) return 1;
return 0;
}
return 0;
}
const rawValue = value.toLowerCase().replace(`${filter}:`, "");
if (rawValue.includes(query)) return 1;
}
return 0;
}
export function getFieldValueByType<TData>({
field,
value,
}: {
field?: DataTableFilterField<TData>;
value: unknown;
}) {
if (!field) return null;
switch (field.type) {
case "slider": {
if (Array.isArray(value)) {
return value.join(SLIDER_DELIMITER);
}
return value;
}
case "checkbox": {
if (Array.isArray(value)) {
return value.join(ARRAY_DELIMITER);
}
// REMINER: inversed logic
if (typeof value === "string") {
return value.split(ARRAY_DELIMITER);
}
return value;
}
case "timerange": {
if (Array.isArray(value)) {
if (isArrayOfDates(value)) {
return value.map((date) => date.getTime()).join(RANGE_DELIMITER);
}
return value.join(RANGE_DELIMITER);
}
if (value instanceof Date) {
return value.getTime();
}
return value;
}
default: {
return value;
}
}
}
export function notEmpty<TValue>(
value: TValue | null | undefined,
): value is TValue {
return value !== null && value !== undefined;
}
/**
* Tokenize input string, respecting quoted values
*
* Examples:
* - `name:john regions:ams` → [["name", "john"], ["regions", "ams"]]
* - `name:"john doe" regions:ams` → [["name", "john doe"], ["regions", "ams"]]
* - `url:"https://example.com/path with spaces"` → [["url", "https://example.com/path with spaces"]]
*/
export function tokenizeFilterInput(input: string): Array<[string, string]> {
const results: Array<[string, string]> = [];
const trimmed = input.trim();
// Regex to match: key:"quoted value" or key:unquoted_value
// This handles:
// - key:"value with spaces"
// - key:'value with spaces' (single quotes)
// - key:valueWithoutSpaces
const regex = /(\w+):(?:"([^"]*)"|'([^']*)'|(\S+))/g;
let match;
while ((match = regex.exec(trimmed)) !== null) {
const key = match[1];
// Value is in group 2 (double quotes), group 3 (single quotes), or group 4 (unquoted)
const value = match[2] ?? match[3] ?? match[4];
if (key && value !== undefined) {
results.push([key, value]);
}
}
return results;
}
/**
* Serialize a value, adding quotes if it contains spaces
*/
export function serializeFilterValue(value: string): string {
if (value.includes(" ")) {
return `"${value}"`;
}
return value;
}
/**
* Schema-based column filters parser for BYOS
*
* This parser works with the new schema system instead of nuqs ParserBuilder.
*/
export function columnFiltersParserFromSchema<TData>({
schema,
filterFields,
}: {
schema: SchemaDefinition;
filterFields: DataTableFilterField<TData>[];
}) {
return {
parse: (inputValue: string) => {
// Use tokenizer that respects quoted values
const tokens = tokenizeFilterInput(inputValue);
const values = tokens.reduce(
(prev, [name, value]) => {
prev[name] = value;
return prev;
},
{} as Record<string, string>,
);
const searchParams = Object.entries(values).reduce(
(prev, [key, value]) => {
const fieldBuilder = schema[key] as FieldBuilder<unknown> | undefined;
if (!fieldBuilder) return prev;
let parsed = fieldBuilder._config.parse(value);
if (parsed !== null) {
// Slider fields expect [min, max] for inNumberRange — if a single
// value is provided (e.g. "amount:1800"), duplicate it so the range
// becomes [1800, 1800] (exact match).
const field = filterFields?.find((f) => f.value === key);
if (
field?.type === "slider" &&
Array.isArray(parsed) &&
parsed.length === 1
) {
parsed = [parsed[0], parsed[0]];
}
prev[key] = parsed;
}
return prev;
},
{} as Record<string, unknown>,
);
return searchParams;
},
serialize: (columnFilters: ColumnFiltersState) => {
const values = columnFilters.reduce((prev, curr) => {
const { commandDisabled } = filterFields?.find(
(field) => curr.id === field.value,
) || { commandDisabled: true };
const fieldBuilder = schema[curr.id] as
| FieldBuilder<unknown>
| undefined;
if (commandDisabled || !fieldBuilder) return prev;
const serialized = fieldBuilder._config.serialize(curr.value);
if (!serialized) return prev;
// Wrap in quotes if value contains spaces
const quotedValue = serializeFilterValue(serialized);
return `${prev}${curr.id}:${quotedValue} `;
}, "");
return values;
},
};
}