import { useMemo, useRef, useCallback, useState, ReactNode } from 'react'; import { getWebProps } from 'react-native-unistyles/web'; import { tableStyles } from './Table.styles'; import type { TableProps, TableColumn, TableType, TableSizeVariant, TableAlignVariant, SortDirection } from './types'; import type { MenuItem } from '../Menu/types'; import { getWebAriaProps } from '../utils/accessibility'; import { IconSvg } from '../Icon/IconSvg/IconSvg.web'; import { IconRegistry } from '../Icon/IconRegistry'; import Menu from '../Menu/Menu.web'; import { mdiArrowUp, mdiArrowDown, mdiArrowUpDown, mdiDotsVertical } from '@mdi/js'; // Self-register icons used internally by Table IconRegistry.registerMany({ 'arrow-up': mdiArrowUp, 'arrow-down': mdiArrowDown, 'arrow-up-down': mdiArrowUpDown, 'dots-vertical': mdiDotsVertical, }); // ============================================================================ // Helpers // ============================================================================ function getStickyStyle( sticky: boolean | 'left' | 'right' | undefined, offset: number | string | undefined, zIndex: number, ): React.CSSProperties | undefined { if (!sticky) return undefined; const side = sticky === 'right' ? 'right' : 'left'; return { position: 'sticky', [side]: offset ?? 0, zIndex, }; } // ============================================================================ // Sub-component Props // ============================================================================ interface TRProps { children: ReactNode; size?: TableSizeVariant; type?: TableType; clickable?: boolean; dividers?: boolean; even?: boolean; onClick?: () => void; testID?: string; } interface THProps { children: ReactNode; size?: TableSizeVariant; type?: TableType; align?: TableAlignVariant; width?: number | string; sticky?: boolean | 'left' | 'right'; stickyOffset?: number | string; resizable?: boolean; onResize?: (width: number) => void; minWidth?: number; accessibilitySort?: 'ascending' | 'descending' | 'none' | 'other'; sortable?: boolean; sortDirection?: SortDirection; onSort?: () => void; options?: MenuItem[]; } interface TDProps { children: ReactNode; size?: TableSizeVariant; type?: TableType; align?: TableAlignVariant; width?: number | string; sticky?: boolean | 'left' | 'right'; stickyOffset?: number | string; even?: boolean; } // ============================================================================ // TR Component // ============================================================================ function TR({ children, size = 'md', type = 'standard', clickable = false, dividers = false, even = false, onClick, testID, }: TRProps) { tableStyles.useVariants({ size, type, clickable, dividers, even, }); const rowProps = getWebProps([(tableStyles.row as any)({})]); return ( {children} ); } // ============================================================================ // TH Component // ============================================================================ function TH({ children, size = 'md', type = 'standard', align = 'left', width, sticky, stickyOffset, resizable, onResize, minWidth = 50, accessibilitySort, sortable, sortDirection, onSort, options, }: THProps) { const [menuOpen, setMenuOpen] = useState(false); tableStyles.useVariants({ size, type, align, sortable: !!sortable, sortActive: sortDirection != null, }); const headerCellProps = getWebProps([(tableStyles.headerCell as any)({ sticky: !!sticky })]); const sortIndicatorProps = getWebProps([(tableStyles.sortIndicator as any)({ sortActive: sortDirection != null })]); const optionsButtonProps = getWebProps([(tableStyles.optionsButton as any)({})]); const thRef = useRef(null); // Derive aria-sort from sortDirection const derivedAriaSort = accessibilitySort ?? ( sortDirection === 'asc' ? 'ascending' : sortDirection === 'desc' ? 'descending' : sortable ? 'none' : undefined ); // Sort indicator icon name const sortIconName = sortDirection === 'asc' ? 'arrow-up' : sortDirection === 'desc' ? 'arrow-down' : 'arrow-up-down'; const handlePointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); const th = thRef.current; if (!th) return; const startX = e.clientX; const startWidth = th.getBoundingClientRect().width; const handlePointerMove = (moveEvent: PointerEvent) => { const newWidth = Math.max(minWidth, startWidth + (moveEvent.clientX - startX)); th.style.width = `${newWidth}px`; }; const handlePointerUp = (_upEvent: PointerEvent) => { document.removeEventListener('pointermove', handlePointerMove); document.removeEventListener('pointerup', handlePointerUp); const finalWidth = th.getBoundingClientRect().width; onResize?.(finalWidth); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('pointermove', handlePointerMove); document.addEventListener('pointerup', handlePointerUp); }, [minWidth, onResize]); return ( {children} {sortable && ( )} {options && options.length > 0 && ( e.stopPropagation()} style={{ display: 'inline-flex', flexShrink: 0 }}> )} {resizable && ( )} ); } // ============================================================================ // TD Component // ============================================================================ function TD({ children, size = 'md', type = 'standard', align = 'left', width, sticky, stickyOffset, even = false, }: TDProps) { tableStyles.useVariants({ size, type, align, }); const cellProps = getWebProps([(tableStyles.cell as any)({ sticky: !!sticky, even, type })]); const justifyContent = align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'; return ( {children} ); } // ============================================================================ // TF Component (Footer Cell) // ============================================================================ interface TFProps { children: ReactNode; size?: TableSizeVariant; type?: TableType; align?: TableAlignVariant; width?: number | string; sticky?: boolean | 'left' | 'right'; stickyOffset?: number | string; } function TF({ children, size = 'md', type = 'standard', align = 'left', width, sticky, stickyOffset, }: TFProps) { tableStyles.useVariants({ size, type, align, }); const footerCellProps = getWebProps([(tableStyles.footerCell as any)({ sticky: !!sticky })]); const justifyContent = align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start'; return ( {children} ); } // ============================================================================ // Main Table Component // ============================================================================ /** * Data display component for structured tabular content with columns and rows. * Supports custom cell rendering, sticky headers, and row click handling. */ function Table({ columns, data, type = 'standard', size = 'md', stickyHeader = false, onRowPress, onColumnResize, onSort, dividers = false, emptyState, // Spacing variants from ContainerStyleProps gap, padding, paddingVertical, paddingHorizontal, margin, marginVertical, marginHorizontal, style, testID, id, // Accessibility props accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityHidden, }: TableProps) { // Sort state const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState(null); const handleSort = useCallback((columnKey: string) => { let newDir: SortDirection; if (sortColumn !== columnKey) { newDir = 'asc'; } else if (sortDirection === 'asc') { newDir = 'desc'; } else { setSortColumn(null); setSortDirection(null); onSort?.(columnKey, null); return; } setSortColumn(columnKey); setSortDirection(newDir); onSort?.(columnKey, newDir); }, [sortColumn, sortDirection, onSort]); // Generate ARIA props const ariaProps = useMemo(() => { return getWebAriaProps({ accessibilityLabel, accessibilityHint, accessibilityRole: accessibilityRole ?? 'table', accessibilityHidden, }); }, [accessibilityLabel, accessibilityHint, accessibilityRole, accessibilityHidden]); // Apply variants for container tableStyles.useVariants({ type, size, gap, padding, paddingVertical, paddingHorizontal, margin, marginVertical, marginHorizontal, }); const containerProps = getWebProps([(tableStyles.container as any)({}), style as any]); const tableProps = getWebProps([(tableStyles.table as any)({})]); // Helper to get cell value const getCellValue = (column: TableColumn, row: T, rowIndex: number) => { if (column.render) { const value = column.dataIndex ? (row as any)[column.dataIndex] : row; return column.render(value, row, rowIndex); } return column.dataIndex ? (row as any)[column.dataIndex] : ''; }; const isClickable = !!onRowPress; const hasFooter = columns.some((col) => col.footer !== undefined); // Helper to resolve footer content const getFooterContent = (column: TableColumn) => { if (typeof column.footer === 'function') { return column.footer(data); } return column.footer; }; // Compute cumulative offsets for sticky columns (left and right independently) const stickyOffsetMap = useMemo(() => { const map = new Map(); // Left sticky: accumulate left-to-right let cumulativeLeft = 0; for (const col of columns) { const side = col.sticky === 'right' ? 'right' : col.sticky ? 'left' : null; if (side !== 'left') continue; map.set(col.key, cumulativeLeft); if (typeof col.width === 'number') { cumulativeLeft += col.width; } } // Right sticky: accumulate right-to-left let cumulativeRight = 0; for (let i = columns.length - 1; i >= 0; i--) { const col = columns[i]; if (col.sticky !== 'right') continue; map.set(col.key, cumulativeRight); if (typeof col.width === 'number') { cumulativeRight += col.width; } } return map; }, [columns]); return (
{columns.map((column) => ( ))} {data.length === 0 && emptyState ? ( ) : ( data.map((row, rowIndex) => ( onRowPress?.(row, rowIndex)} testID={testID ? `${testID}-row-${rowIndex}` : undefined} > {columns.map((column) => ( ))} )) )} {hasFooter && ( {columns.map((column) => ( {getFooterContent(column) ?? null} ))} )}
onColumnResize(column.key, w) : undefined} accessibilitySort={column.accessibilitySort} sortable={column.sortable} sortDirection={sortColumn === column.key ? sortDirection : undefined} onSort={column.sortable ? () => handleSort(column.key) : undefined} options={column.options} > {column.title}
{emptyState}
{getCellValue(column, row, rowIndex)}
); } export default Table;