diff --git a/components/table/TableBody/TableBody.tsx b/components/table/TableBody/TableBody.tsx index 0974eb7..7b22d9e 100644 --- a/components/table/TableBody/TableBody.tsx +++ b/components/table/TableBody/TableBody.tsx @@ -16,9 +16,11 @@ import { } from 'react'; import { useInView } from 'react-intersection-observer'; import { isPresent } from '../../../utils/isPresent'; +import { Checkbox } from '../../Checkbox/Checkbox'; import { tableActionColumnSize } from '../consts'; import { TableCell } from '../TableCell/TableCell'; import { TableCellContext } from '../TableCell/TableCellContext'; +import { TableStickyColumnsContext } from '../TableCell/TableStickyColumnsContext'; import { TableExpandCell } from '../TableExpandCell/TableExpandCell'; import { TableExpandedRowHeader } from '../TableExpandedRowHeader/TableExpandedRowHeader'; import { TableHeader } from '../TableHeader/TableHeader'; @@ -99,6 +101,28 @@ export const TableBody = ({ .join(' '); }, [table]); + const stickyColumnSides = useMemo(() => { + const headers = table.getFlatHeaders(); + const stickySides: Record = {}; + + let leftIndex = 0; + while ( + leftIndex < headers.length && + headers[leftIndex].column.columnDef.meta?.sticky + ) { + stickySides[headers[leftIndex].column.id] = 'left'; + leftIndex += 1; + } + + let rightIndex = headers.length - 1; + while (rightIndex >= leftIndex && headers[rightIndex].column.columnDef.meta?.sticky) { + stickySides[headers[rightIndex].column.id] = 'right'; + rightIndex -= 1; + } + + return stickySides; + }, [table.getFlatHeaders]); + // biome-ignore lint/correctness/useExhaustiveDependencies: needs to recalculate on sizing changes const columnSizeVars = useMemo(() => { const headers = table.getFlatHeaders(); @@ -106,24 +130,47 @@ export const TableBody = ({ const canRowsExpand = table.options.enableExpanding; const canRowsSelect = table.options.enableRowSelection; // assign static sizing to extra columns at the start of the row + // in the same order as they are rendered: selection -> expand -> data columns let iterIndex = 0; - if (canRowsExpand) { - colSizes['--col-0-size'] = tableActionColumnSize; + let cumulativeStickyOffset = 0; + if (canRowsSelect) { + colSizes['--selection-sticky-offset'] = cumulativeStickyOffset; + colSizes[`--col-${iterIndex}-size`] = tableActionColumnSize; + cumulativeStickyOffset += tableActionColumnSize; iterIndex += 1; } - if (canRowsSelect) { + if (canRowsExpand) { + colSizes['--expand-sticky-offset'] = cumulativeStickyOffset; colSizes[`--col-${iterIndex}-size`] = tableActionColumnSize; + cumulativeStickyOffset += tableActionColumnSize; iterIndex += 1; } + // Data columns - sticky ones use the cumulative offset for (const header of headers) { colSizes[`--col-${iterIndex}-size`] = header.column.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); + if (stickyColumnSides[header.column.id] === 'left') { + colSizes[`--col-${header.column.id}-sticky-left-offset`] = cumulativeStickyOffset; + cumulativeStickyOffset += header.column.getSize(); + } iterIndex += 1; } + + let cumulativeStickyRightOffset = 0; + for (let index = headers.length - 1; index >= 0; index -= 1) { + const header = headers[index]; + if (stickyColumnSides[header.column.id] === 'right') { + colSizes[`--col-${header.column.id}-sticky-right-offset`] = + cumulativeStickyRightOffset; + cumulativeStickyRightOffset += header.column.getSize(); + } + } + return colSizes; }, [ table.getState().columnSizingInfo, table.getState().columnSizing, + stickyColumnSides, table.getFlatHeaders, ]); @@ -230,105 +277,131 @@ export const TableBody = ({ }, [table]); return ( -
+
- - {table.options.enableRowSelection && ( - { - table.toggleAllRowsSelected(); - }} - /> - )} - {table.options.enableExpanding && } - {table.getHeaderGroups()[0].headers.map((header) => { - return ; - })} - - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = rows[virtualRow.index]; - const isExpanded = row.getIsExpanded() && row.getCanExpand(); - const canSelect = row.getCanSelect(); - const isLast = virtualRow.index === rows.length - 1 && !hasNextPage; - return ( -
rowVirtualizer.measureElement(node)} - data-index={virtualRow.index} - className={clsx('virtual-row', { - expanded: isExpanded && !isLast, - })} - key={row.id} - style={{ - position: 'absolute', - transform: `translateY(${virtualRow.start}px)`, - minWidth: tableWidth, - width: '100%', - }} - > - + + {table.options.enableRowSelection && ( + + { + table.toggleAllRowsSelected(); + }} + /> + + )} + {table.options.enableExpanding && ( + + )} + {table.getHeaderGroups()[0].headers.map((header) => { + return ; + })} + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + const isExpanded = row.getIsExpanded() && row.getCanExpand(); + const canSelect = row.getCanSelect(); + const isLast = virtualRow.index === rows.length - 1 && !hasNextPage; + return ( +
rowVirtualizer.measureElement(node)} + data-index={virtualRow.index} + className={clsx('virtual-row', { + expanded: isExpanded && !isLast, })} + key={row.id} + style={{ + position: 'absolute', + transform: `translateY(${virtualRow.start}px)`, + minWidth: tableWidth, + width: '100%', + }} > - {canSelect && ( - { - row.toggleSelected(); + + {canSelect && ( + { + row.toggleSelected(); + }} + /> + )} + {table.options.enableExpanding && ( + + )} + {row.getAllCells().map((cell) => ( + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + ))} + + {isExpanded && isPresent(expandedHeaders) && ( + )} - {table.options.enableExpanding && } - {row.getAllCells().map((cell) => ( - - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - - ))} - - {isExpanded && isPresent(expandedHeaders) && ( - - )} - {isExpanded && - isPresent(renderExpandedRow) && - renderExpandedRow(row, isLast)} + {isExpanded && + isPresent(renderExpandedRow) && + renderExpandedRow(row, isLast)} +
+ ); + })} + {hasNextPage && isPresent(onNextPage) && ( +
+
- ); - })} - {hasNextPage && isPresent(onNextPage) && ( -
- -
- )} + )} +
-
+ ); }; diff --git a/components/table/TableBody/style.scss b/components/table/TableBody/style.scss index 9b2768d..b535442 100644 --- a/components/table/TableBody/style.scss +++ b/components/table/TableBody/style.scss @@ -1,6 +1,8 @@ .table { + --table-inline-padding: var(--spacing-xl); + box-sizing: border-box; - padding: 0 var(--spacing-xl); + padding: 0 var(--table-inline-padding); width: 100%; } diff --git a/components/table/TableCell/TableCell.tsx b/components/table/TableCell/TableCell.tsx index a7ad69c..2f8a4a2 100644 --- a/components/table/TableCell/TableCell.tsx +++ b/components/table/TableCell/TableCell.tsx @@ -4,6 +4,7 @@ import { type CSSProperties, useContext, useMemo } from 'react'; import { isPresent } from '../../../utils/isPresent'; import { tableActionColumnSize } from '../consts'; import { TableCellContext } from './TableCellContext'; +import { TableStickyColumnsContext } from './TableStickyColumnsContext'; import type { TableCellProps } from './types'; export const TableCell = ({ @@ -16,34 +17,53 @@ export const TableCell = ({ alignContent = 'left', flex = false, radius = false, + sticky = false, style: outsideStyle, ...props }: TableCellProps) => { const cell = useContext(TableCellContext); + const stickyColumns = useContext(TableStickyColumnsContext); + + const isStickyFromMeta = cell?.column.columnDef.meta?.sticky ?? false; + const isSticky = sticky || isStickyFromMeta; const style = useMemo((): CSSProperties => { const res: CSSProperties = {}; - if (outsideStyle?.width) return res; const id = columnId ?? cell?.column.id; const hasId = isPresent(id); - if (flex) { - return res; - } - if (empty && !hasId) { - res.width = tableActionColumnSize; - return res; + + if (!outsideStyle?.width) { + if (flex) { + return res; + } + if (empty && !hasId) { + res.width = tableActionColumnSize; + return res; + } + if (hasId) { + res.width = `calc(var(--col-${id}-size) * 1px)`; + } } - if (hasId) { - res.width = `calc(var(--col-${id}-size) * 1px)`; + + if (hasId && isSticky) { + const stickySide = stickyColumns[id]; + if (stickySide === 'left') { + res.left = `calc(var(--col-${id}-sticky-left-offset) * 1px - var(--table-inline-padding))`; + } + if (stickySide === 'right') { + res.right = `calc(var(--col-${id}-sticky-right-offset) * 1px - var(--table-inline-padding))`; + } } + return res; - }, [columnId, empty, flex, outsideStyle?.width, cell]); + }, [columnId, empty, flex, outsideStyle?.width, cell, isSticky, stickyColumns]); return (
>( + {}, +); diff --git a/components/table/TableCell/style.scss b/components/table/TableCell/style.scss index 2093682..771ddcf 100644 --- a/components/table/TableCell/style.scss +++ b/components/table/TableCell/style.scss @@ -8,6 +8,7 @@ box-sizing: border-box; padding: 0 var(--spacing-md); min-height: 48px; + min-width: 0; background-color: inherit; @include animate(background-color); @@ -65,7 +66,14 @@ justify-content: center; } + &.sticky { + position: sticky; + z-index: 1; + background-color: var(--bg-color); + } + & > span { + min-width: 0; font: var(--t-body-sm-400); color: var(--fg-default); overflow: hidden; diff --git a/components/table/TableCell/types.ts b/components/table/TableCell/types.ts index b750428..bc1e174 100644 --- a/components/table/TableCell/types.ts +++ b/components/table/TableCell/types.ts @@ -9,4 +9,5 @@ export interface TableCellProps extends HTMLProps { columnId?: string; ignoreStyleAssign?: boolean; alignContent?: 'center' | 'left' | 'right'; + sticky?: boolean; } diff --git a/components/table/TableEditCell/TableEditCell.tsx b/components/table/TableEditCell/TableEditCell.tsx index 283f024..c851403 100644 --- a/components/table/TableEditCell/TableEditCell.tsx +++ b/components/table/TableEditCell/TableEditCell.tsx @@ -2,15 +2,26 @@ import { IconButtonMenu } from '../../IconButtonMenu/IconButtonMenu'; import type { MenuItemsGroup } from '../../Menu/types'; import { tableEditColumnSize } from '../consts'; import { TableCell } from '../TableCell/TableCell'; +import type { TableCellProps } from '../TableCell/types'; type Props = { menuItems: MenuItemsGroup[]; disabled?: boolean; -}; +} & TableCellProps; -export const TableEditCell = ({ menuItems, disabled }: Props) => { +export const TableEditCell = ({ + menuItems, + disabled, + alignContent = 'right', + style, + ...cellProps +}: Props) => { return ( - + ); diff --git a/components/table/TableExpandCell/TableExpandCell.tsx b/components/table/TableExpandCell/TableExpandCell.tsx index da8b5e5..1d6628a 100644 --- a/components/table/TableExpandCell/TableExpandCell.tsx +++ b/components/table/TableExpandCell/TableExpandCell.tsx @@ -6,19 +6,26 @@ import { TableCell } from '../TableCell/TableCell'; type Props = { row: Row; + hasSelectionColumn: boolean; }; -export const TableExpandCell = ({ row }: Props) => { +export const TableExpandCell = ({ + row, + hasSelectionColumn, +}: Props) => { const expanded = row.getIsExpanded(); const canExpand = row.getCanExpand(); - const canSelect = row.getCanSelect(); return ( {canExpand && ( ({ header }: Props) isPresent(filterOptions) && isPresent(filterMessages); + const isSticky = header.column.columnDef.meta?.sticky ?? false; + const headerSorting = header.column.getIsSorted(); const isEmpty = @@ -92,8 +94,13 @@ export const TableHeaderCell = ({ header }: Props) if (isEmpty) return ( @@ -102,13 +109,16 @@ export const TableHeaderCell = ({ header }: Props) return ( <> { if (suppressSortOnNextClickRef.current) { diff --git a/components/table/TableHeaderCell/style.scss b/components/table/TableHeaderCell/style.scss index d71cf49..e4e61bc 100644 --- a/components/table/TableHeaderCell/style.scss +++ b/components/table/TableHeaderCell/style.scss @@ -1,10 +1,14 @@ .table .header-cell { --resize-bar-visibility: 0; + position: sticky; + top: 0; + z-index: 2; + background-color: var(--bg-disabled); column-gap: var(--spacing-sm); &.resizable { - position: relative; + position: sticky; &:hover, .resizing { @@ -12,6 +16,10 @@ } } + &.sticky { + z-index: 3; + } + .resize-bar { position: absolute; right: -6px; diff --git a/components/table/TableRowContainer/style.scss b/components/table/TableRowContainer/style.scss index a61a777..4d11149 100644 --- a/components/table/TableRowContainer/style.scss +++ b/components/table/TableRowContainer/style.scss @@ -13,6 +13,7 @@ --bg-color: var(--bg-default); .table-cell { + overflow: hidden; border-bottom: var(--border-1) solid var(--border-disabled); } } @@ -23,6 +24,7 @@ user-select: none; .table-cell { + overflow: hidden; min-height: 36px; span { @@ -37,6 +39,7 @@ border-bottom-right-radius: var(--radius-md); .table-cell { + overflow: hidden; border-bottom: var(--border-1) solid var(--border-disabled); &:first-child { diff --git a/components/table/TableSelectionCell/TableSelectionCell.tsx b/components/table/TableSelectionCell/TableSelectionCell.tsx index 4685b6f..082d345 100644 --- a/components/table/TableSelectionCell/TableSelectionCell.tsx +++ b/components/table/TableSelectionCell/TableSelectionCell.tsx @@ -9,10 +9,12 @@ type Props = { export const TableSelectionCell = ({ selected, onClick }: Props) => { return ( diff --git a/components/table/types.ts b/components/table/types.ts index 12bbade..4bf145f 100644 --- a/components/table/types.ts +++ b/components/table/types.ts @@ -1,6 +1,17 @@ +import type { SelectionOption } from '../../../components/SelectionSection/type'; + export type TableFilterMessages = { searchPlaceholder: string; clearButton: string; applyButton: string; emptyState: string; }; + +// Extend TanStack Table's ColumnMeta with custom fields +declare module '@tanstack/react-table' { + interface ColumnMeta { + flex?: boolean; + filterOptions?: SelectionOption[]; + sticky?: boolean; + } +}