import { useCallback, useMemo } from 'react' import { Box, Checkbox, IconButton, Table as MuiTable, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow as MuiTableRow, TableSortLabel, type TableProps as MuiTableProps, } from '@mui/material' import type { TablePaginationActionsProps } from '@mui/material/TablePagination/TablePaginationActions' import { FirstPage as FirstPageIcon } from '@mui/icons-material' import { KeyboardArrowLeft } from '@mui/icons-material' import { KeyboardArrowRight } from '@mui/icons-material' import { LastPage as LastPageIcon } from '@mui/icons-material' import { DEFAULT_TABLE_LABELS, type TableLabels } from './labels' import { DEFAULT_TABLE_PAGE_SIZE_OPTIONS, type TableColumn, type TableRow, type TableSortDirection, type TableSortState, } from './types' import { styles } from './style' export interface TableUIProps { columns: readonly TableColumn[] /** Already-paginated, already-sorted rows for the current view. */ rows: readonly T[] /** Total row count (across all pages). Required for pagination footer. */ total: number page: number pageSize: number pageSizeOptions?: readonly number[] sort?: TableSortState /** * Column name to use as the row identity. Drives selection lookup, * React keys, and aria labels. Defaults to `'id'` — point it at * another column when your rows don't carry an `id` field. */ keyColumn?: string /** Selected row ids. Destination-owned. */ selection?: readonly (string | number)[] selectable?: boolean onSortChange?: (next: TableSortState) => void onPageChange?: (page: number) => void onPageSizeChange?: (pageSize: number) => void onSelectionChange?: (next: readonly (string | number)[]) => void onRowClick?: (row: T) => void onRowHover?: (row: T | null) => void labels?: Partial /** Row rendered when `rows` is empty for the current page. */ emptyContent?: React.ReactNode /** * Forwarded to MUI's `` — `'small'` for compact rows, * `'medium'` for the default density. Leave `undefined` (the default) * to let MUI's own default kick in. */ size?: MuiTableProps['size'] } /** * Pure renderer for a paginated, sortable, optionally-selectable table. * Has no widget-store coupling — `
` (the bridge) reads from the store * and feeds this UI with already-projected data. */ export function TableUI({ columns, rows, total, page, pageSize, pageSizeOptions = DEFAULT_TABLE_PAGE_SIZE_OPTIONS, sort, keyColumn = 'id', selection, selectable = false, onSortChange, onPageChange, onPageSizeChange, onSelectionChange, onRowClick, onRowHover, labels, emptyContent, size, }: TableUIProps) { if (process.env.NODE_ENV !== 'production') { // Dev-time guard: a nullish identity collapses every row into the // same selection-set entry, which manifests as "click one → all // appear selected". Surface it loudly instead of silently degrading. const missing = rows.some((r) => r[keyColumn] == null) if (missing) { // eslint-disable-next-line no-console console.error( `: rows are missing the identity column \`${keyColumn}\`. ` + 'Set the `keyColumn` prop to a column present on every row, or ' + 'add the column to the data.', ) } } const _labels = useMemo( () => ({ ...DEFAULT_TABLE_LABELS, ...labels }), [labels], ) // Auto-hide the pagination footer when every row already fits in the // smallest available page size — the Rows-per-page selector + prev / // next buttons would just be inert noise. Consumers force the footer // on by passing a smaller `pageSizeOptions[0]` than `total`. const minPageSize = Math.min(...pageSizeOptions) const showPagination = total > minPageSize // Own the `ActionsComponent` slot ourselves so the Meridian theme's // `theme.components.MuiTablePagination.defaultProps.ActionsComponent` // (which depends on `react-intl`) doesn't leak into the library. The // MUI / Meridian style overrides for `MuiIconButton` / // `MuiTablePagination` still apply. const PaginationActions = useMemo( () => makePaginationActions(_labels), [_labels], ) const selectionSet = useMemo( () => new Set(selection ?? []), [selection], ) // Resolve each row's identity from `keyColumn`, falling back to the row's // index when the cell is nullish. The dev-time guard above surfaces the // misconfiguration loudly, but in production we must NOT collapse every // nullish-keyed row into the same selection-set entry (that's the // "click one → all selected" bug) — a per-index fallback keeps ids // distinct and React keys stable for a given page. const resolveRowId = useCallback( (row: T, index: number): string | number => (row[keyColumn] ?? index) as string | number, [keyColumn], ) const pageRowIds = useMemo(() => rows.map(resolveRowId), [rows, resolveRowId]) const allOnPageSelected = pageRowIds.length > 0 && pageRowIds.every((id) => selectionSet.has(id)) const someOnPageSelected = !allOnPageSelected && pageRowIds.some((id) => selectionSet.has(id)) const handleSort = (columnId: string) => { if (!onSortChange) return const sameCol = sort?.columnId === columnId const nextDir: TableSortDirection = sameCol && sort?.direction === 'asc' ? 'desc' : 'asc' onSortChange({ columnId, direction: nextDir }) } const handleSelectAllOnPage = () => { if (!onSelectionChange) return if (allOnPageSelected) { onSelectionChange( (selection ?? []).filter((id) => !pageRowIds.includes(id)), ) } else { const merged = new Set(selection ?? []) for (const id of pageRowIds) merged.add(id) onSelectionChange([...merged]) } } const handleSelectRow = (rowId: string | number) => { if (!onSelectionChange) return const next = new Set(selection ?? []) if (next.has(rowId)) next.delete(rowId) else next.add(rowId) onSelectionChange([...next]) } return ( {selectable ? ( ) : null} {columns.map((column) => { const isSorted = sort?.columnId === column.id const direction = isSorted ? sort?.direction : undefined return ( {column.sortable && onSortChange ? ( handleSort(column.id)} > {column.label} ) : ( column.label )} ) })} {rows.length === 0 ? ( {emptyContent ?? null} ) : ( rows.map((row, index) => { const rowId = resolveRowId(row, index) const isSelected = selectionSet.has(rowId) return ( onRowClick?.(row)} onMouseEnter={() => onRowHover?.(row)} onMouseLeave={() => onRowHover?.(null)} sx={{ ...styles.row, ...(onRowClick ? styles.rowClickable : null), }} > {selectable ? ( { e.stopPropagation() handleSelectRow(rowId) }} inputProps={{ 'aria-label': _labels.selectRow(rowId), }} /> ) : null} {columns.map((column) => ( {column.formatter ? column.formatter(row[column.id], row) : stringifyCell(row[column.id])} ))} ) }) )} {showPagination ? ( onPageChange?.(next)} onRowsPerPageChange={(e) => { const next = parseInt(e.target.value, 10) onPageSizeChange?.(next) }} labelRowsPerPage={_labels.rowsPerPage} labelDisplayedRows={({ from, to, count }) => _labels.paginationOf(from, to, count) } ActionsComponent={PaginationActions} sx={styles.pagination} /> ) : null} ) } function stringifyCell(value: unknown): React.ReactNode { if (value == null) return '' if (typeof value === 'string' || typeof value === 'number') return value if (typeof value === 'boolean') return String(value) if (Array.isArray(value) || typeof value === 'object') { return JSON.stringify(value) } return '' } /** * Build the `ActionsComponent` used by `` — four MUI * `IconButton`s for first / previous / next / last. We provide this * ourselves so the Meridian theme's `defaultProps.ActionsComponent` * (which depends on `react-intl`) is bypassed at the call site without * disturbing the rest of the Meridian theme. The factory closes over * the merged label set so aria-labels stay consistent with the rest * of the table's i18n surface. */ function makePaginationActions(labels: TableLabels) { return function PaginationActions({ count, page, rowsPerPage, onPageChange, }: TablePaginationActionsProps) { const lastPage = Math.max(0, Math.ceil(count / rowsPerPage) - 1) return ( onPageChange(e, 0)} disabled={page === 0} aria-label={labels.firstPage} > onPageChange(e, page - 1)} disabled={page === 0} aria-label={labels.previousPage} > onPageChange(e, page + 1)} disabled={page >= lastPage} aria-label={labels.nextPage} > onPageChange(e, lastPage)} disabled={page >= lastPage} aria-label={labels.lastPage} > ) } }