/* Copyright 2026 Marimo. All rights reserved. */ "use no memo"; import type { Column, Table } from "@tanstack/react-table"; import { EllipsisIcon, FilterIcon, MinusIcon, TextIcon, XIcon, } from "lucide-react"; import { useRef, useState } from "react"; import { useLocale } from "react-aria"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin"; import type { OperatorType } from "@/plugins/impl/data-frames/utils/operators"; import { logNever } from "@/utils/assertNever"; import { cn } from "@/utils/cn"; import { capitalize } from "@/utils/strings"; import { Button } from "../ui/button"; import { DraggablePopover } from "../ui/draggable-popover"; import { Input } from "../ui/input"; import { NumberField } from "../ui/number-field"; import { PopoverClose } from "../ui/popover"; import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue, } from "../ui/select"; import { FilterByValuesList } from "./filter-by-values-picker"; import { type ColumnFilterForType, type ColumnFilterValue, Filter, } from "./filters"; import { ClearFilterMenuItem, FilterButtons, renderColumnPinning, renderColumnWrapping, renderCopyColumn, renderDataType, renderFilterByValues, renderFormatOptions, renderSortFilterIcon, renderSorts, } from "./header-items"; interface DataTableColumnHeaderProps< TData, TValue, > extends React.HTMLAttributes { column: Column; header: React.ReactNode; subheader?: React.ReactNode; justify?: "left" | "center" | "right"; calculateTopKRows?: CalculateTopKRows; table?: Table; } export const DataTableColumnHeader = ({ column, header, subheader, justify, className, calculateTopKRows, table, }: DataTableColumnHeaderProps) => { const [isFilterValueOpen, setIsFilterValueOpen] = useState(false); const { locale } = useLocale(); // No header if (!header) { return null; } // No sorting or filtering if (!column.getCanSort() && !column.getCanFilter()) { return (
{header} {subheader}
); } const hasFilter = column.getFilterValue() !== undefined; return ( <>
{justify === "center" ? ( <> {column.getCanSort() && } {header} ) : ( <> {header} {column.getCanSort() && } )} {renderDataType(column)} {renderSorts(column, table)} {renderCopyColumn(column)} {renderColumnPinning(column)} {renderColumnWrapping(column)} {renderFormatOptions(column, locale)} {renderMenuItemFilter(column)} {renderFilterByValues(column, setIsFilterValueOpen)} {hasFilter && }
{subheader}
{isFilterValueOpen && ( )} ); }; const SortButton = ({ column, }: { column: Column; }) => { const sortDirection = column.getIsSorted(); const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!sortDirection) { column.toggleSorting(false, true); // asc } else if (sortDirection === "asc") { column.toggleSorting(true, true); // desc } else { column.clearSorting(); } }; return ( ); }; export function renderMenuItemFilter( column: Column, ) { const canFilter = column.getCanFilter(); if (!canFilter) { return null; } const filterType = column.columnDef.meta?.filterType; if (!filterType) { return null; } const filterMenuItem = ( Filter ); if (filterType === "boolean") { return ( {filterMenuItem} ); } if (filterType === "text") { return ( {filterMenuItem} ); } if (filterType === "number") { return ( {filterMenuItem} ); } if (filterType === "select") { // Not implemented return null; } if (filterType === "time") { // Not implemented return null; } if (filterType === "datetime") { // Not implemented return null; } if (filterType === "date") { // Not implemented return null; } logNever(filterType); return null; } // Type-safe constants for null filter operators const NULL_FILTER_OPERATORS = { is_null: "is_null", is_not_null: "is_not_null", } satisfies Record; const NullFilter = ({ column, defaultItem, operator, setOperator, }: { column: Column; defaultItem?: OperatorType | "between"; operator: OperatorType | "between"; setOperator: (operator: OperatorType) => void; }) => { const handleValueChange = (value: OperatorType) => { setOperator(value); if (value === "is_null" || value === "is_not_null") { column.setFilterValue(Filter.text({ operator: value })); } }; const isNullOrNotNull = operator === "is_null" || operator === "is_not_null"; return ( ); }; const BooleanFilter = ({ column, }: { column: Column; }) => { return ( <> column.setFilterValue( Filter.boolean({ value: true, operator: "is_true" }), ) } > True column.setFilterValue( Filter.boolean({ value: false, operator: "is_false" }), ) } > False column.setFilterValue(Filter.boolean({ operator: "is_null" })) } > Is null column.setFilterValue(Filter.boolean({ operator: "is_not_null" })) } > Is not null ); }; const NumberRangeFilter = ({ column, }: { column: Column; }) => { const currentFilter = column.getFilterValue() as | ColumnFilterForType<"number"> | undefined; const hasFilter = currentFilter !== undefined; const [operator, setOperator] = useState( currentFilter?.operator ?? "between", ); const [min, setMin] = useState(currentFilter?.min); const [max, setMax] = useState(currentFilter?.max); const minRef = useRef(null); const maxRef = useRef(null); const handleApply = (opts: { min?: number; max?: number } = {}) => { column.setFilterValue( Filter.number({ min: opts.min ?? min, max: opts.max ?? max, operator: operator === "between" ? undefined : operator, }), ); }; return (
{operator === "between" && ( <>
setMin(value)} aria-label="min" placeholder="min" onKeyDown={(e) => { if (e.key === "Enter") { handleApply({ min: Number.parseFloat(e.currentTarget.value), }); } if (e.key === "Tab") { maxRef.current?.focus(); } }} className="shadow-none! border-border hover:shadow-none!" /> setMax(value)} aria-label="max" onKeyDown={(e) => { if (e.key === "Enter") { handleApply({ max: Number.parseFloat(e.currentTarget.value), }); } if (e.key === "Tab") { minRef.current?.focus(); } }} placeholder="max" className="shadow-none! border-border hover:shadow-none!" />
{ setMin(undefined); setMax(undefined); column.setFilterValue(undefined); }} clearButtonDisabled={!hasFilter} /> )}
); }; const TextFilter = ({ column, }: { column: Column; }) => { const currentFilter = column.getFilterValue() as | ColumnFilterForType<"text"> | undefined; const hasFilter = currentFilter !== undefined; const [value, setValue] = useState(currentFilter?.text ?? ""); const [operator, setOperator] = useState( currentFilter?.operator ?? "contains", ); const handleApply = () => { if (operator !== "contains") { column.setFilterValue(Filter.text({ operator })); return; } if (value === "") { column.setFilterValue(undefined); return; } column.setFilterValue(Filter.text({ text: value, operator })); }; return (
{operator === "contains" && ( <> } value={value ?? ""} onChange={(e) => setValue(e.target.value)} placeholder="Text..." onKeyDown={(e) => { if (e.key === "Enter") { handleApply(); } }} className="shadow-none! border-border hover:shadow-none!" /> { setValue(""); column.setFilterValue(undefined); }} clearButtonDisabled={!hasFilter} /> )}
); }; /** * Seed the filter-by-values picker from a column's existing filter value. * * Reopening the picker should reflect what's already applied. Only `select` * filters carry checkbox-style values; other filter shapes (number, text, * etc.) seed an empty list. */ export function seedFromFilter(value: ColumnFilterValue | undefined): { values: unknown[]; operator: Extract; } { if (value && "type" in value && value.type === "select") { return { values: [...value.options], operator: value.operator === "not_in" ? "not_in" : "in", }; } return { values: [], operator: "in" }; } const PopoverFilterByValues = ({ setIsFilterValueOpen, calculateTopKRows, column, }: { setIsFilterValueOpen: (open: boolean) => void; calculateTopKRows?: CalculateTopKRows; column: Column; }) => { const seed = seedFromFilter( column.getFilterValue() as ColumnFilterValue | undefined, ); const [chosenValues, setChosenValues] = useState>( () => new Set(seed.values), ); const handleApply = () => { if (chosenValues.size === 0) { column.setFilterValue(undefined); return; } column.setFilterValue( Filter.select({ options: [...chosenValues], operator: seed.operator, }), ); }; return ( !open && setIsFilterValueOpen(false)} className="w-80 p-0" >
setChosenValues(new Set(values))} /> setChosenValues(new Set())} clearButtonDisabled={chosenValues.size === 0} />
); };