import type { Column, Header, SortDirection } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table' import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from 'lucide-react' import * as React from 'react' import { useRef, useState } from 'react' import { ABSOLUTE_MIN_COLUMN_WIDTH_PX, HEADER_H_PADDING_PX, HEADER_HEIGHT_PX, HEADER_ICON_GAP_PX, } from '../constants' import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect' import { cn } from '../lib/cn' import type { ColumnFilterValue, TableRowData } from '../types' import { FilterPopover } from './FilterPopover' /** * True when a column lacks room for its header text and icon cluster side by * side, so the icons must overlay the text instead. Pure + exported for tests. * The text is never shrunk to make room — when it doesn't fit, the icons float. */ export function needsHeaderIconOverlay({ columnWidth, textWidth, iconsWidth, padding = HEADER_H_PADDING_PX, gap = HEADER_ICON_GAP_PX, }: { columnWidth: number textWidth: number iconsWidth: number padding?: number gap?: number }): boolean { // No icons, or no text to overlay → nothing to float. if (iconsWidth <= 0 || textWidth <= 0) return false const available = columnWidth - padding return textWidth + gap + iconsWidth > available } export type HeaderCellProps = { header: Header width: number /** Sort state passed explicitly (not read off the column) so React.memo invalidates. */ sorted: false | SortDirection sortIndex: number /** Total number of sorted columns (priority badges render when > 1). */ sortedCount: number columnLabel: string filterable: boolean filterValue: ColumnFilterValue | undefined /** Stable; invoked lazily while the popover is open. */ getUniqueValues: (column: Column) => string[] /** Stable column-keyed callbacks so memoized cells skip re-renders. */ onFilterChange: (column: Column, next: ColumnFilterValue | undefined) => void canResize: boolean onResize: (columnId: string, width: number) => void className?: string } function HeaderCellInner({ header, width, sorted, sortIndex, sortedCount, columnLabel, filterable, filterValue, getUniqueValues, onFilterChange, canResize, onResize, className, }: HeaderCellProps) { const column = header.column const canSort = column.getCanSort() const hasIcons = canSort || filterable const dragState = useRef<{ startX: number; startWidth: number } | null>(null) const textRef = useRef(null) const iconsRef = useRef(null) // When the header text + icon cluster can't sit side by side, the icons float // over the text (an overlay) instead of being hidden or shrinking the text. // Measured from the rendered DOM so it reacts to the real text/icon widths; // icon widths are read from the affordance children so the overlay's own // padding never feeds back into the decision (which would make it sticky). const [overlay, setOverlay] = useState(false) useIsomorphicLayoutEffect(() => { const textEl = textRef.current const iconsEl = iconsRef.current if (!textEl || !iconsEl) { setOverlay(false) return } const sortEl = iconsEl.querySelector('[data-tgx-sort-affordance]') const filterEl = iconsEl.querySelector('[data-tgx-filter-affordance]') let iconsWidth = 0 if (sortEl) iconsWidth += sortEl.offsetWidth if (filterEl) iconsWidth += filterEl.offsetWidth if (sortEl && filterEl) iconsWidth += HEADER_ICON_GAP_PX setOverlay( needsHeaderIconOverlay({ columnWidth: width, textWidth: textEl.scrollWidth, iconsWidth }), ) }, [width, canSort, filterable, sorted, sortedCount, sortIndex]) const toggleSort = canSort ? column.getToggleSortingHandler() : undefined return (
{ if (!canSort) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggleSort?.(e) } }} > {header.isPlaceholder ? null : flexRender(column.columnDef.header, header.getContext())} {hasIcons && (
{canSort && ( {sorted === 'asc' ? ( ) : sorted === 'desc' ? ( ) : ( )} {sorted && sortedCount > 1 && sortIndex >= 0 && ( {sortIndex + 1} )} )} {filterable && ( getUniqueValues(column)} onChange={(next) => onFilterChange(column, next)} /> )}
)} {canResize && (
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onPointerDown={(e) => { e.stopPropagation() e.preventDefault() e.currentTarget.setPointerCapture(e.pointerId) dragState.current = { startX: e.clientX, startWidth: width } }} onPointerMove={(e) => { const drag = dragState.current if (!drag) return const next = Math.max( ABSOLUTE_MIN_COLUMN_WIDTH_PX, drag.startWidth + (e.clientX - drag.startX), ) onResize(column.id, next) }} onPointerUp={(e) => { dragState.current = null e.currentTarget.releasePointerCapture(e.pointerId) }} onKeyDown={(e) => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault() e.stopPropagation() const delta = e.key === 'ArrowLeft' ? -8 : 8 onResize(column.id, Math.max(ABSOLUTE_MIN_COLUMN_WIDTH_PX, width + delta)) } }} > {/* Hairline by default (matches standard borders); widens + highlights on hover/focus. */}
)}
) } export const HeaderCell = React.memo(HeaderCellInner) as typeof HeaderCellInner