/** @jsxImportSource react */ import { memo, type CSSProperties } from "react"; import { TextAttributes } from "../../../../ui/host"; import { DisclosureMarker } from "../../../../components/ui/disclosure-marker"; import type { DataTableCell, DataTableColumn, DataTableProps, DataTableSectionHeader, } from "../../../../components/ui/data-table"; import { tableColumnLeadGap } from "../../../../components/ui/table-layout"; import { WebIcon } from "../desktop/icons"; import { useFrozenColumnInsets } from "./frozen-column"; import { WEB_CELL_HEIGHT, WEB_CELL_WIDTH } from "../input-host"; import { CSS_BG, CSS_PANEL, CSS_SELECTED, CSS_SELECTED_TEXT, CSS_TEXT, CSS_TEXT_BRIGHT, CSS_TEXT_DIM, TABLE_INLINE_PADDING_PX, tableHeaderPx, cellTextStyle, clippedCellTextStyle, eventWithCellCoordinates, } from "./dom"; /** Space between a header label and its sort marker. */ const SORT_MARKER_GAP_PX = 4; const SORT_MARKER_SIZE_PX = 9; function contentJustifyForAlign(align: string | undefined): CSSProperties["justifyContent"] { if (align === "right") return "flex-end"; if (align === "center") return "center"; return "flex-start"; } function columnGapCss(columnGap: number): CSSProperties["columnGap"] { return columnGap === 0 ? 0 : `calc(${columnGap} * var(--cell-w))`; } /** * The extra blank before a left-aligned column that follows a right-aligned * one. Its grid track already includes it; the cell steps past it as a margin, * so the frozen-column inset measured from the cell box stays right. */ function leadGapCss(columns: readonly C[], index: number, columnGap: number): CSSProperties["marginLeft"] { const lead = tableColumnLeadGap(columns, index, columnGap); return lead === 0 ? undefined : `calc(${lead} * var(--cell-w))`; } function inlinePaddingPx(horizontalPadding: number): number { return TABLE_INLINE_PADDING_PX * horizontalPadding; } export function WebDataTableHeader({ columns, freezeFirstColumn, scrollLeft = 0, viewportWidth = 0, columnGap, horizontalPadding, focusPane, onTableMouseDown, gridTemplateColumns, onHeaderClick, sortColumnId, sortDirection, }: { columns: C[]; freezeFirstColumn?: boolean; scrollLeft?: number; viewportWidth?: number; columnGap: number; horizontalPadding: number; focusPane: () => void; onTableMouseDown?: (event: any) => void; gridTemplateColumns: string; onHeaderClick?: (columnId: string) => void; sortColumnId: string | null; sortDirection: "asc" | "desc"; }) { const { ref, insets } = useFrozenColumnInsets(freezeFirstColumn === true, scrollLeft, viewportWidth, columns, columnGap * WEB_CELL_WIDTH); return (
{columns.map((column, columnIndex) => { const isSorted = sortColumnId === column.id; const color = isSorted ? CSS_TEXT : column.headerColor ?? CSS_TEXT_DIM; return (
0 ? insets[columnIndex] : undefined, boxSizing: "border-box", height: tableHeaderPx(), overflow: "hidden", // The sort marker sits on the side away from the column's // alignment, inside the two cells every column reserves for it, // so a label never moves when sorting reaches it and a // right-aligned label stays flush over its numbers. display: "flex", alignItems: "center", justifyContent: contentJustifyForAlign(column.align), gap: column.label ? SORT_MARKER_GAP_PX : 0, backgroundColor: column.headerBackgroundColor ?? CSS_PANEL, boxShadow: freezeFirstColumn && columnIndex === 0 ? `-${inlinePaddingPx(horizontalPadding)}px 0 0 ${column.headerBackgroundColor ?? CSS_PANEL}, ${columnGap * WEB_CELL_WIDTH}px 0 0 ${column.headerBackgroundColor ?? CSS_PANEL}` : undefined, }} onMouseDown={onHeaderClick ? (event) => { focusPane(); onTableMouseDown?.(event); event.preventDefault(); onHeaderClick(column.id); } : undefined} > {column.label} {isSorted ? ( ) : null}
); })}
); } function WebDataTableRowInner< T, C extends DataTableColumn, >({ columns, freezeFirstColumn, scrollLeft = 0, viewportWidth = 0, columnGap, horizontalPadding, focusPane, onTableMouseDown, onActivateRow, onRowContextMenu, onRowMouseDown, onSelectRow, index, item, itemKey, gridTemplateColumns, getRowBackgroundColor, renderCell, renderSectionHeader, rowSize, rowStart, rowContextMenuSurface, selected, }: { /** Only compared by the row memo; see `getRowVersion`. */ rowVersion?: unknown; columns: C[]; freezeFirstColumn?: boolean; scrollLeft?: number; viewportWidth?: number; columnGap: number; horizontalPadding: number; focusPane: () => void; onTableMouseDown?: (event: any) => void; onActivateRow?: (item: T, index: number) => void; onRowContextMenu?: DataTableProps["onRowContextMenu"]; onRowMouseDown?: DataTableProps["onRowMouseDown"]; onSelectRow: (item: T, index: number) => void; index: number; item: T; itemKey: string; gridTemplateColumns: string; getRowBackgroundColor?: DataTableProps["getRowBackgroundColor"]; renderCell: DataTableProps["renderCell"]; renderSectionHeader?: DataTableProps["renderSectionHeader"]; rowSize: number; rowStart: number; rowContextMenuSurface: boolean; selected: boolean; }) { const { ref, insets } = useFrozenColumnInsets(freezeFirstColumn === true, scrollLeft, viewportWidth, columns, columnGap * WEB_CELL_WIDTH); const sectionHeader: DataTableSectionHeader | null = renderSectionHeader?.(item, index) ?? null; const baseRowStyle: CSSProperties = { position: "absolute", top: 0, left: 0, transform: `translateY(${rowStart}px)`, display: "grid", gridTemplateColumns, columnGap: columnGapCss(columnGap), alignItems: "center", width: "100%", minWidth: 0, height: rowSize, paddingLeft: inlinePaddingPx(horizontalPadding), paddingRight: inlinePaddingPx(horizontalPadding), boxSizing: "border-box", lineHeight: "var(--cell-h)", }; if (sectionHeader) { // A header the cursor can land on (a collapsible group) shows it like a row. const headerColor = sectionHeader.color ?? (selected ? CSS_SELECTED_TEXT : CSS_TEXT_BRIGHT); return (
{ focusPane(); onTableMouseDown?.(event); sectionHeader.onMouseDown?.(event); event.preventDefault(); }} >
{sectionHeader.expanded !== undefined && ( )} {sectionHeader.text}
); } const rowState = { selected }; const rowBackgroundColor = getRowBackgroundColor?.(item, index, rowState); const rowBg = selected ? CSS_SELECTED : rowBackgroundColor ?? CSS_BG; return (
{ focusPane(); onTableMouseDown?.(event); if (onRowMouseDown?.(item, index, eventWithCellCoordinates(event)) === true) { return; } event.preventDefault(); onSelectRow(item, index); }} onContextMenu={(event) => { focusPane(); onRowContextMenu?.(item, index, eventWithCellCoordinates(event)); }} onDoubleClick={(event) => { focusPane(); event.preventDefault(); event.stopPropagation(); onActivateRow?.(item, index); }} > {columns.map((column, columnIndex) => { const cell: DataTableCell = renderCell(item, column, index, rowState); return (
0 ? insets[columnIndex] : undefined, boxSizing: "border-box", height: WEB_CELL_HEIGHT, overflow: "hidden", backgroundColor: cell.backgroundColor ?? rowBg, boxShadow: freezeFirstColumn && columnIndex === 0 ? `-${inlinePaddingPx(horizontalPadding)}px 0 0 ${cell.backgroundColor ?? rowBg}, ${columnGap * WEB_CELL_WIDTH}px 0 0 ${cell.backgroundColor ?? rowBg}` : undefined, }} onMouseDown={(event) => { focusPane(); onTableMouseDown?.(event); if (cell.onMouseDown) { cell.onMouseDown(eventWithCellCoordinates(event)); return; } if (onRowMouseDown?.(item, index, eventWithCellCoordinates(event)) === true) { event.stopPropagation(); return; } event.preventDefault(); event.stopPropagation(); onSelectRow(item, index); }} onDoubleClick={(event) => { if (cell.onMouseDown) { event.preventDefault(); event.stopPropagation(); return; } focusPane(); event.preventDefault(); event.stopPropagation(); onActivateRow?.(item, index); }} > {cell.content !== undefined ? (
{cell.content}
) : ( {cell.text} )}
); })}
); } export const WebDataTableRow = memo(WebDataTableRowInner) as typeof WebDataTableRowInner;