import React from "react"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, MoveUp, MoveDown } from "lucide-react"; import { isPaletteColor, resolveColorClass } from '../../utils/colorResolver' import { FieldWrapper } from "../shared/FieldWrapper"; import { GridColumn, type GridColumnProps } from "./GridColumn"; import { textAlignMap } from "../../utils/sailMaps"; import type { SAILLabelPosition, SAILMarginSize, SAILGridHeight, SortInfo, } from "../../types/sail"; export interface ReadOnlyGridProps { /** Text to display as the grid label */ label?: string; /** Determines where the label appears */ labelPosition?: SAILLabelPosition; /** Supplemental text about this grid */ instructions?: string; /** Displays a help icon with tooltip text */ helpTooltip?: string; /** Text to display when no data is available */ emptyGridMessage?: string; /** The data array to display */ data?: Record[]; /** GridColumn children defining the columns */ children?: React.ReactNode; /** Maximum rows per page. Default: 10 */ pageSize?: number; /** Initial sort configurations */ initialSorts?: SortInfo[]; /** Whether rows are selectable */ selectable?: boolean; /** Selection visual style */ selectionStyle?: "CHECKBOX" | "ROW_HIGHLIGHT" | "CHECKBOX_SUBTLE_HIGHLIGHT" | "SUBTLE_HIGHLIGHT"; /** Currently selected row identifiers */ selectionValue?: (string | number)[]; /** Callback when selection changes */ selectionSaveInto?: (selectedIds: (string | number)[]) => void; /** Validation messages to display below the grid */ validations?: string[]; /** Whether the component is displayed */ showWhen?: boolean; /** Cell spacing */ spacing?: "STANDARD" | "DENSE"; /** Grid height */ height?: SAILGridHeight; /** Border style */ borderStyle?: "STANDARD" | "LIGHT"; /** Whether to shade alternate rows */ shadeAlternateRows?: boolean; /** Index of column to use as row header for accessibility */ rowHeader?: number; /** Additional text for screen readers */ accessibilityText?: string; /** Space above component */ marginAbove?: SAILMarginSize; /** Space below component */ marginBelow?: SAILMarginSize; /** Determines if the paging includes the total row count. "STANDARD" hides total count for performance; "ROW_COUNT" shows total count and first/last controls. */ pagingControls?: "STANDARD" | "ROW_COUNT"; /** Additional Tailwind classes for prototype-specific styling (not part of SAIL API) */ className?: string; } /** Extract column definitions from GridColumn children */ function extractColumns(children: React.ReactNode): GridColumnProps[] { const columns: GridColumnProps[] = []; React.Children.forEach(children, (child) => { if (React.isValidElement(child) && child.type === GridColumn) { columns.push(child.props as GridColumnProps); } }); return columns; } /** Resolve a cell value from a row using the column's value accessor */ function resolveValue( value: GridColumnProps["value"], row: Record, rowIndex: number ): React.ReactNode { if (value === undefined || value === null) { return null; } if (typeof value === "string") { return row[value] ?? null; } if (typeof value === "function") { try { return value(row, rowIndex); } catch (e) { console.warn("GridColumn value accessor threw an error:", e); return null; } } return null; } /** Resolve background color for a cell */ function resolveBgColor( backgroundColor: GridColumnProps["backgroundColor"], row: Record ): { className?: string; style?: React.CSSProperties } { if (!backgroundColor) return {}; const colorValue = typeof backgroundColor === "function" ? backgroundColor(row) : backgroundColor; if (!colorValue || colorValue === "NONE") return {}; if (bgColorMap[colorValue] !== undefined) { return bgColorMap[colorValue] ? { className: bgColorMap[colorValue] } : {}; } if (isPaletteColor(colorValue)) { return { className: resolveColorClass(colorValue, 'bg') }; } if (colorValue.startsWith("#")) { return { style: { backgroundColor: colorValue } }; } return {}; } // --- Styling maps --- const heightMap: Record = { SHORT: "max-h-40", SHORT_PLUS: "max-h-52", MEDIUM: "max-h-64", MEDIUM_PLUS: "max-h-80", TALL: "max-h-96", TALL_PLUS: "max-h-[28rem]", EXTRA_TALL: "max-h-[36rem]", AUTO: "", }; const widthMap: Record = { AUTO: "", ICON: "w-10", ICON_PLUS: "w-14", NARROW: "w-24", NARROW_PLUS: "w-32", MEDIUM: "w-40", MEDIUM_PLUS: "w-48", WIDE: "w-64", "1X": "flex-1", "2X": "flex-[2]", "3X": "flex-[3]", "4X": "flex-[4]", "5X": "flex-[5]", "6X": "flex-[6]", "7X": "flex-[7]", "8X": "flex-[8]", "9X": "flex-[9]", "10X": "flex-[10]", }; const bgColorMap: Record = { NONE: "", ACCENT: "bg-blue-50", SUCCESS: "bg-green-50", INFO: "bg-sky-50", WARN: "bg-yellow-50", ERROR: "bg-red-50", }; export const ReadOnlyGrid: React.FC = ({ label, labelPosition, instructions, helpTooltip, emptyGridMessage = "No items available", data, children, pageSize: pageSizeProp = 10, initialSorts, selectable, selectionStyle = "CHECKBOX", selectionValue = [], selectionSaveInto, validations, showWhen = true, borderStyle = "LIGHT", shadeAlternateRows = false, spacing = "STANDARD", height = "AUTO", pagingControls = "STANDARD", accessibilityText, marginAbove, marginBelow, className }) => { const gridId = React.useId(); // Handle invalid pageSize (0 or negative → default to 10) const pageSize = pageSizeProp > 0 ? pageSizeProp : 10; // Normalize data — treat undefined/null/non-array as empty const rows = Array.isArray(data) ? data : []; // Paging state (1-based) const [currentPage, setCurrentPage] = React.useState(1); // Sorting state const [sortField, setSortField] = React.useState(null); const [sortAscending, setSortAscending] = React.useState(true); // Apply initialSorts on first render React.useEffect(() => { if (initialSorts && initialSorts.length > 0) { setSortField(initialSorts[0].field); setSortAscending(initialSorts[0].ascending); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Reset currentPage to 1 when data length changes React.useEffect(() => { setCurrentPage(1); }, [rows.length]); // Sort handler const handleSort = (field: string) => { if (sortField === field) { setSortAscending((prev) => !prev); } else { setSortField(field); setSortAscending(true); } setCurrentPage(1); }; // Tag each row with its original index before sorting (for row ID fallback) const indexedRows = React.useMemo( () => rows.map((row, i) => ({ ...row, __originalIndex: i })), [rows] ); // Sort data before paging const sortedRows = React.useMemo(() => { if (!sortField) return indexedRows; return [...indexedRows].sort((a, b) => { const aVal = (a as Record)[sortField]; const bVal = (b as Record)[sortField]; if (aVal == null && bVal == null) return 0; if (aVal == null) return sortAscending ? -1 : 1; if (bVal == null) return sortAscending ? 1 : -1; if (typeof aVal === "string" && typeof bVal === "string") { return sortAscending ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); } if (aVal < bVal) return sortAscending ? -1 : 1; if (aVal > bVal) return sortAscending ? 1 : -1; return 0; }); }, [indexedRows, sortField, sortAscending]); // Requirement 8.1: showWhen=false renders nothing if (!showWhen) { return null; } const columns = extractColumns(children); // Requirement 2.5: filter out columns with showWhen=false const visibleColumns = columns.filter((col) => col.showWhen !== false); // Styling computations const isStandardBorder = borderStyle === "STANDARD"; // STANDARD: full outer border + column dividers; LIGHT: no outer border, no vertical borders const tableBorderClass = isStandardBorder ? "border border-gray-300" : ""; const headerRowBorderClass = isStandardBorder ? "border-b border-gray-300" : "border-b border-gray-200"; const cellBorderClass = isStandardBorder ? "border-b border-gray-300" : "border-b border-gray-200"; // STANDARD: vertical dividers between columns const colDividerClass = isStandardBorder ? "border-r border-gray-300" : ""; const cellPaddingClass = spacing === "DENSE" ? "px-2 py-1" : "px-3 py-2"; const heightClass = heightMap[height] || ""; const needsScrollContainer = height !== "AUTO" && heightClass !== ""; // Compute paging state (use sortedRows for total count) const totalPages = Math.ceil(sortedRows.length / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = Math.min(startIndex + pageSize, sortedRows.length); const hasPreviousPage = currentPage > 1; const hasNextPage = currentPage < totalPages; // Slice sorted data for current page const pageRows = sortedRows.slice(startIndex, endIndex); // Row identifier: use row.id if it exists, otherwise use original index const getRowId = (row: Record): string | number => { return row.id !== undefined ? row.id : row.__originalIndex; }; // Selection handlers const handleRowSelect = (rowId: string | number) => { if (!selectionSaveInto) return; const isSelected = selectionValue.includes(rowId); const newSelection = isSelected ? selectionValue.filter((id) => id !== rowId) : [...selectionValue, rowId]; selectionSaveInto(newSelection); }; const pageRowIds = selectable ? pageRows.map((row) => getRowId(row)) : []; const allPageRowsSelected = selectable && pageRowIds.length > 0 && pageRowIds.every((id) => selectionValue.includes(id)); const handleSelectAll = () => { if (!selectionSaveInto) return; if (allPageRowsSelected) { // Deselect all page rows selectionSaveInto(selectionValue.filter((id) => !pageRowIds.includes(id))); } else { // Select all page rows (add missing ones) const newSelection = [...selectionValue]; pageRowIds.forEach((id) => { if (!newSelection.includes(id)) newSelection.push(id); }); selectionSaveInto(newSelection); } }; const footer = validations && validations.length > 0 ? (
{validations.map((v, i) => (

{v}

))}
) : undefined; const getColWidthClass = (col: GridColumnProps) => col.width && widthMap[col.width] ? widthMap[col.width] : ""; const getColAlignClass = (col: GridColumnProps) => col.align && textAlignMap[col.align] ? textAlignMap[col.align] : "text-left"; const renderTable = () => ( {selectable && (selectionStyle === "CHECKBOX" || selectionStyle === "CHECKBOX_SUBTLE_HIGHLIGHT") && ( )} {visibleColumns.map((col, colIndex) => { const widthClass = getColWidthClass(col); const alignClass = getColAlignClass(col); const isLastCol = colIndex === visibleColumns.length - 1; const divider = !isLastCol && colDividerClass ? ` ${colDividerClass}` : ""; return ( ); })} {pageRows.map((row, rowIndex) => { const rowId = getRowId(row); const isRowSelected = selectable && selectionValue.includes(rowId); const isRowHighlightSelected = selectable && selectionStyle === "ROW_HIGHLIGHT" && isRowSelected; const isSubtleHighlightSelected = selectable && selectionStyle === "SUBTLE_HIGHLIGHT" && isRowSelected; const isCheckboxSubtleHighlightSelected = selectable && selectionStyle === "CHECKBOX_SUBTLE_HIGHLIGHT" && isRowSelected; const isClickableRow = selectable && (selectionStyle === "ROW_HIGHLIGHT" || selectionStyle === "SUBTLE_HIGHLIGHT" || selectionStyle === "CHECKBOX_SUBTLE_HIGHLIGHT"); const alternateRowClass = shadeAlternateRows && rowIndex % 2 === 0 && !isRowHighlightSelected && !isSubtleHighlightSelected && !isCheckboxSubtleHighlightSelected ? "bg-gray-50" : ""; return ( handleRowSelect(rowId) : undefined } > {selectable && (selectionStyle === "CHECKBOX" || selectionStyle === "CHECKBOX_SUBTLE_HIGHLIGHT") && ( )} {visibleColumns.map((col, colIndex) => { const widthClass = getColWidthClass(col); const alignClass = getColAlignClass(col); const bg = resolveBgColor(col.backgroundColor, row); const isLastCol = colIndex === visibleColumns.length - 1; const divider = !isLastCol && colDividerClass ? ` ${colDividerClass}` : ""; const textColorClass = isRowHighlightSelected ? "text-white" : "text-gray-900"; return ( ); })} ); })}
{col.sortField ? ( ) : ( col.label ?? "" )}
handleRowSelect(rowId)} aria-label={`Select row ${rowIndex + 1}`} className={[ 'appearance-none h-3 w-3 mt-1.5 shrink-0 rounded-xs border', isRowSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-400 bg-white', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:ring-offset-1', 'transition-colors duration-150 ease-in-out cursor-pointer', 'checked:bg-[url("data:image/svg+xml;charset=utf-8;base64,IDxzdmcgd2lkdGg9IjE3OTIiIGhlaWdodD0iMTc5MiIgdmlld0JveD0iMCAwIDE3OTIgMTc5MiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTY3MSA1NjZxMCA0MC0yOCA2OGwtNzI0IDcyNC0xMzYgMTM2cS0yOCAyOC02OCAyOHQtNjgtMjhsLTEzNi0xMzYtMzYyLTM2MnEtMjgtMjgtMjgtNjh0MjgtNjhsMTM2LTEzNnEyOC0yOCA2OC0yOHQ2OCAyOGwyOTQgMjk1IDY1Ni02NTdxMjgtMjggNjgtMjh0NjggMjhsMTM2IDEzNnEyOCAyOCAyOCA2OHoiIGZpbGw9IndoaXRlIi8+PC9zdmc+")] checked:bg-center checked:bg-no-repeat checked:bg-[length:10px_10px]', ].join(' ')} /> {resolveValue(col.value, row, startIndex + rowIndex)}
); return ( {rows.length === 0 ? (
{emptyGridMessage}
) : ( <> {needsScrollContainer ? (
{renderTable()}
) : ( renderTable() )} {totalPages > 1 && (
{pagingControls === "ROW_COUNT" && totalPages >= 3 && ( )} {pagingControls === "ROW_COUNT" ? ( {startIndex + 1} – {endIndex} {" "}of {sortedRows.length} ) : ( {startIndex + 1} – {endIndex} {" "}of many )} {pagingControls === "ROW_COUNT" && totalPages >= 3 && ( )}
)} )}
); };