/* 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 { 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 { Button } from "../ui/button"; import { DraggablePopover } from "../ui/draggable-popover"; import { Input } from "../ui/input"; import { RegexInput } from "./regex-input"; import { NumberField } from "../ui/number-field"; import { PopoverClose } from "../ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { FilterByValuesList } from "./filter-by-values-picker"; import { OPERATOR_LABELS } from "./operator-labels"; import { type ColumnFilterForType, type ColumnFilterValue, DATETIME_OPS, Filter, isDatetimeComparisonOp, isNumberComparisonOp, isTextScalarOp, NUMBER_OPS, TEXT_OPS, } from "./filters"; import { type DateLikeFilterType, DateLikeInput, DateLikeRangeInput, } from "./date-filter-inputs"; 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, calculateTopKRows)} {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, calculateTopKRows?: CalculateTopKRows, ) { 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 === "date" || filterType === "datetime" || filterType === "time" ) { return ( {filterMenuItem} ); } logNever(filterType); return null; } const OperatorSelect = ({ operator, options, onChange, }: { operator: OperatorType; options: readonly OperatorType[]; onChange: (next: OperatorType) => void; }) => ( ); 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 ); }; type NumberComparisonFilter = Extract< ColumnFilterForType<"number">, { value: number } >; const isNumberComparisonFilter = ( filter: ColumnFilterForType<"number">, ): filter is NumberComparisonFilter => isNumberComparisonOp(filter.operator); export const NumberFilterMenu = ({ 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?.operator === "between" ? currentFilter.min : undefined, ); const [max, setMax] = useState( currentFilter?.operator === "between" ? currentFilter.max : undefined, ); const [value, setValue] = useState( currentFilter !== undefined && isNumberComparisonFilter(currentFilter) ? currentFilter.value : undefined, ); const isComparison = isNumberComparisonOp(operator); const isNullish = operator === "is_null" || operator === "is_not_null"; const applyDisabled = (operator === "between" && (min === undefined || max === undefined)) || (isComparison && value === undefined); const handleApply = () => { if (isNullish) { column.setFilterValue(Filter.number({ operator })); return; } if (operator === "between" && min !== undefined && max !== undefined) { column.setFilterValue(Filter.number({ operator: "between", min, max })); return; } if (isComparison && value !== undefined) { column.setFilterValue(Filter.number({ operator, value })); } }; const handleClear = () => { setMin(undefined); setMax(undefined); setValue(undefined); column.setFilterValue(undefined); }; const handleOperatorChange = (next: OperatorType) => { setOperator(next); }; return (
{operator === "between" && (
)} {isComparison && ( )}
); }; type DateComparisonFilter = Extract< ColumnFilterForType, { value: Date } >; const isDateComparisonFilter = ( filter: ColumnFilterForType, ): filter is DateComparisonFilter => isDatetimeComparisonOp(filter.operator); export const DateFilterMenu = ({ column, filterType, }: { column: Column; filterType: DateLikeFilterType; }) => { const currentFilter = column.getFilterValue() as | ColumnFilterForType | undefined; const hasFilter = currentFilter !== undefined; const [operator, setOperator] = useState( currentFilter?.operator ?? "between", ); const [min, setMin] = useState( currentFilter?.operator === "between" ? currentFilter.min : undefined, ); const [max, setMax] = useState( currentFilter?.operator === "between" ? currentFilter.max : undefined, ); const [value, setValue] = useState( currentFilter !== undefined && isDateComparisonFilter(currentFilter) ? currentFilter.value : undefined, ); const isComparison = isDatetimeComparisonOp(operator); const isNullish = operator === "is_null" || operator === "is_not_null"; const applyDisabled = (operator === "between" && (min === undefined || max === undefined)) || (isComparison && value === undefined); const buildFilter = ( opts: Parameters[0], ): ColumnFilterForType => { switch (filterType) { case "date": return Filter.date(opts); case "datetime": return Filter.datetime(opts); case "time": return Filter.time(opts); } }; const handleApply = () => { if (isNullish) { column.setFilterValue(buildFilter({ operator })); return; } if (operator === "between" && min !== undefined && max !== undefined) { column.setFilterValue(buildFilter({ operator: "between", min, max })); return; } if (isComparison && value !== undefined) { column.setFilterValue(buildFilter({ operator, value })); } }; const [resetKey, setResetKey] = useState(0); const handleClear = () => { setMin(undefined); setMax(undefined); setValue(undefined); setResetKey((k) => k + 1); column.setFilterValue(undefined); }; const handleOperatorChange = (next: OperatorType) => { setOperator(next); }; return (
{ if (e.key === "Tab") { e.stopPropagation(); } }} > {operator === "between" && ( { setMin(nextMin); setMax(nextMax); }} className="shadow-none! border-border hover:shadow-none!" /> )} {isComparison && ( )}
); }; export const TextFilterMenu = ({ column, calculateTopKRows, }: { column: Column; calculateTopKRows?: CalculateTopKRows; }) => { const currentFilter = column.getFilterValue() as | ColumnFilterForType<"text"> | undefined; const hasFilter = currentFilter !== undefined; const [operator, setOperator] = useState( currentFilter?.operator ?? "contains", ); const [text, setText] = useState( currentFilter && "text" in currentFilter ? currentFilter.text : "", ); const [values, setValues] = useState( currentFilter && "values" in currentFilter ? [...currentFilter.values] : [], ); const isScalar = isTextScalarOp(operator); const isMulti = operator === "in" || operator === "not_in"; const isNullish = operator === "is_null" || operator === "is_not_null" || operator === "is_empty"; const applyDisabled = (isScalar && text === "") || (isMulti && values.length === 0); const handleApply = () => { if (isNullish) { column.setFilterValue(Filter.text({ operator })); return; } if (isScalar && text !== "") { column.setFilterValue(Filter.text({ operator, text })); return; } if (isMulti && values.length > 0) { column.setFilterValue(Filter.text({ operator, values })); } }; const handleClear = () => { setText(""); setValues([]); column.setFilterValue(undefined); }; const handleOperatorChange = (next: OperatorType) => { setOperator(next); }; return (
{isScalar && operator === "regex" && ( { e.stopPropagation(); if (e.key === "Enter") { handleApply(); } }} /> )} {isScalar && operator !== "regex" && ( } value={text} onChange={(e) => setText(e.target.value)} placeholder="Text..." onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") { handleApply(); } }} className="shadow-none! border-border hover:shadow-none!" /> )} {isMulti && ( setValues(next.map(String))} creatable={true} /> )}
); }; /** * 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} />
); };