'use client'
import type { ReactNode } from 'react'
import { flexRender, type Header } from '@tanstack/react-table'
import { cn } from '../../../utils/cn'
import {
Arrow01DownIcon,
Arrow01UpIcon,
SwitchVrIcon,
} from '../../icons-v2-generated'
import { DataTableColumnFilter } from './data-table-column-filter'
import { useDataTableContext } from './data-table'
import type { TailwindBreakpoint } from './types'
import { alignJustify, BREAKPOINT_ORDER, getHideAtVisibility } from './utils'
/** Single-column sort descriptor consumed by the header. */
export interface DataTableSortState {
id: string
desc: boolean
}
export interface DataTableHeaderProps {
className?: string
/** Keep the header visible while scrolling. */
stickyHeader?: boolean
/** Tailwind top class for sticky offset, e.g. `'top-[56px]'`. */
stickyHeaderOffset?: string
/**
* Content rendered at the right edge of the header, on the same row as
* column labels. Use for row-count, header-level toolbar buttons, etc.
*
* @example
* } />
*/
rightSlot?: ReactNode
/**
* Current sort descriptor. The header only renders the direction indicator
* based on this value — it doesn't own the state. Pair with `onSortChange`
* and let the consumer decide what a click means (server query, in-memory
* sort, TanStack's row-model sort, …).
*/
sort?: DataTableSortState | null
/**
* Fires when a sortable column header is clicked. The consumer owns the
* toggle cycle (e.g. none → asc → desc → none) and the actual data sort.
*/
onSortChange?: (columnId: string) => void
}
export function DataTableHeader({
className,
stickyHeader,
stickyHeaderOffset,
rightSlot,
sort = null,
onSortChange,
}: DataTableHeaderProps) {
const table = useDataTableContext()
// Flat header group (nested headers can be added later if needed).
const headerGroup = table.getHeaderGroups()[0]
if (!headerGroup) return null
// Below lg only filterable columns (and explicit opt-ins) are visible — the same
// `keepsCellOnTablet` predicate the cells themselves use, so the two cannot drift.
// If a table has none, every cell is hidden there, the flex row has no height, and
// an absolutely-positioned rightSlot has nothing to sit in — so that slot goes
// in-flow instead. Derived from column META, never from the viewport: the same
// answer on the server, on the first client paint and after, which is the whole
// point of this file no longer reading a media query.
const hasTabletVisibleCell = headerGroup.headers.some(
header => !header.isPlaceholder && keepsCellOnTablet(header.column.columnDef.meta),
)
return (
{headerGroup.headers.map(header => (
))}
{rightSlot && (
{rightSlot}
)}
)
}
/* ─────────────────────────────── internals ─────────────────────────────── */
type AnyHeader = Header
interface HeaderCellProps {
header: AnyHeader
sort: DataTableSortState | null
onSortChange?: (columnId: string) => void
}
type ColumnMeta = AnyHeader['column']['columnDef']['meta']
/**
* Whether a column's header stays visible below `lg`, where the row is narrow
* enough that only the controls a user can act on earn their space: the filter
* dropdowns, plus anything explicitly opted in via `meta.alwaysShowHeader`.
*/
function keepsCellOnTablet(meta: ColumnMeta): boolean {
return Boolean(meta?.filter) || meta?.alwaysShowHeader === true
}
// Literal class maps — Tailwind's scanner needs the full class strings, which a
// `${bp}:flex` template would not give it.
const SHOW_FROM: Record = {
md: 'md:flex',
lg: 'lg:flex',
xl: 'xl:flex',
'2xl': '2xl:flex',
}
const HIDE_FROM: Record = {
md: 'md:hidden',
lg: 'lg:hidden',
xl: 'xl:hidden',
'2xl': '2xl:hidden',
}
/** Index of `lg` in the `[base, md, lg, xl, 2xl]` visibility array. */
const LG_STEP = BREAKPOINT_ORDER.indexOf('lg') + 1
/**
* Visibility classes for one header cell, as a plain min-width ladder.
*
* Two rules stack here: the column's own `hideAt`, and "below `lg` only the
* cells a user can act on". Layering them as classes does not work — a
* `max-lg:` override cannot cancel a `md:hidden` from `hideAt`, because Tailwind
* emits the `max-lg` block BEFORE the `md` one and equal-specificity rules are
* decided by source order. So both rules are resolved on booleans first, and only
* the result is turned into classes: at most one utility per breakpoint, each a
* `min-width` variant, no ordering left to chance.
*/
function getCellVisibilityClasses(
keepOnTablet: boolean,
hideAt: TailwindBreakpoint | TailwindBreakpoint[] | undefined,
): string {
const fromHideAt = getHideAtVisibility(hideAt)
const visible = fromHideAt.map((shown, step) =>
keepOnTablet
? // Reachable below lg whatever `hideAt` says; from lg it governs again.
step < LG_STEP || shown
: // Otherwise lg is a floor `hideAt` can raise but never lower.
shown && step >= LG_STEP,
)
const classes = [visible[0] ? 'flex' : 'hidden']
for (let step = 1; step < visible.length; step++) {
if (visible[step] === visible[step - 1]) continue
const breakpoint = BREAKPOINT_ORDER[step - 1]
classes.push(visible[step] ? SHOW_FROM[breakpoint] : HIDE_FROM[breakpoint])
}
return classes.join(' ')
}
/**
* One header cell.
*
* Everything responsive here is a CSS class, deliberately. This used to branch on
* `useLgUp() ?? false`, which answers `undefined` until an effect has run — so the
* FIRST render (server included) took the "not lg" path on every viewport: each
* non-filterable cell returned `null`, and the survivors rendered with no width and
* no alignment. One frame later the real values arrived, so the header visibly
* filled in and the columns snapped into place — while `DataTableRow` and
* `DataTableSkeleton`, which have always used plain `hideAt` classes, were correct
* from the start. Header and body therefore disagreed about which columns exist and
* how wide they are for exactly one frame, which is the flicker-and-jump on every
* table's load.
*/
function HeaderCell({ header, sort, onSortChange }: HeaderCellProps) {
if (header.isPlaceholder) return null
const column = header.column
const meta = column.columnDef.meta
const hasFilter = Boolean(meta?.filter)
const keepOnTablet = keepsCellOnTablet(meta)
const align = meta?.align ?? 'left'
// Sort is opt-in via `meta.sortable`. Direction is fully consumer-driven via
// the `sort` prop; we do not consult TanStack's sort APIs here.
const canSort = meta?.sortable === true
const sortDir: false | 'asc' | 'desc' =
sort?.id === column.id ? (sort.desc ? 'desc' : 'asc') : false
return (
{hasFilter ? (
) : (
onSortChange?.(column.id) : undefined}
>
{canSort && }
)}
)
}
function HeaderLabel({ header }: { header: AnyHeader }) {
const headerDef = header.column.columnDef.header
if (headerDef === undefined) return null
if (typeof headerDef === 'string') {
return (
{headerDef}
)
}
// Render-function or ReactNode: caller is responsible for styling.
return <>{flexRender(headerDef, header.getContext())}>
}
function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) {
if (sorted === 'asc') return
if (sorted === 'desc') return
return
}
function resolveHeaderLabel(header: AnyHeader): string {
const h = header.column.columnDef.header
return typeof h === 'string' ? h : header.column.id
}